From e0b248f788f3fc6714da8c3dc588765eeb74d77e Mon Sep 17 00:00:00 2001 From: Ebicoglu Date: Fri, 17 Oct 2025 22:39:49 +0300 Subject: [PATCH] new article performance optimizations for dotnet (draft) --- .../Post.md | 251 ++++++++++++++++++ .../Post2.md | 236 ++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 docs/en/Community-Articles/2025-10-17-Optimize-Your-App-For-Production/Post.md create mode 100644 docs/en/Community-Articles/2025-10-17-Optimize-Your-App-For-Production/Post2.md diff --git a/docs/en/Community-Articles/2025-10-17-Optimize-Your-App-For-Production/Post.md b/docs/en/Community-Articles/2025-10-17-Optimize-Your-App-For-Production/Post.md new file mode 100644 index 0000000000..3bb24c4405 --- /dev/null +++ b/docs/en/Community-Articles/2025-10-17-Optimize-Your-App-For-Production/Post.md @@ -0,0 +1,251 @@ +# Optimize Your .NET App for Production (Complete Checklist) + +**Tags:** + +> #optimize #dotnet #aspnetcore #performance #kestrel #best-practises + +**Meta Desc 1** + +> Meta description: Learn a battle-tested checklist to optimize .NET apps for production: publish settings, AOT/trim, Kestrel + GC tuning, caching/compression, Docker, health checks and observability. + +**Meta Desc 2** + +> Optimize .NET for production with a practical checklist: Release publish, trim/R2R, Kestrel/GC tuning, caching/compression, Docker hardening, health checks and OpenTelemetry. Real commands, copy-paste snippets, minimal fluff. + +I see way too many .NET apps go to prod like it’s still “F5 on my laptop.” Here’s the checklist I wish someone shoved me years ago. It’s opinionated, pragmatic, copy-pasteable. + +------ + +## 1) Publish Command and CSPROJ Settings + +Don’t go to production with debug build! See the below command which publishes a .NET app for production. + +```bash +dotnet publish -c Release -o out -p:PublishTrimmed=true -p:PublishSingleFile=true -p:ReadyToRun=true +``` + +`csproj` changes for the best production settings: + +```xml + + true + true + true + true + +``` + +- **Trim** is great for APIs. Heavy reflection? Add `DynamicDependency` or a linker file. + +- **PublishReadyToRun** When you normally build a .NET app, your C# code is compiled into **IL** (Intrmediate Language), at runtime, the JIT (Just-In-Time) Compiler turns that IL into native CPU instructions when your app runs. This slows down startup. When you enable `PublishReadyToRun`, the build process precompiles your IL into native code ahead of time called AOT. This way your app starts faster. The downside is; the output files are now bigger. Also it'll compile for Windows and will not run on Linux anymore. + +- **Self-contained** When you publish your .NET app this way, it ncludes the .NET runtime inside your app files. It will run even on a machine that doesn’t have .NET installed. The binary is larger, but the runtime version is exactly what you built with. The downside is; bigger outputs. + + + +------ + +## 2) Kestrel Hosting + +By default, ASP.NET Core app listen only `localhost`, it means it accepts requests only from inside the machine. When you deploy to Docker or Kubernetes, the container’s internal network needs to expose the app to the outside world. To do this you can set it via environment variable as below: + +```bash +ASPNETCORE_URLS=http://0.0.0.0:8080 +``` + +Also if you’re building an internall API or a containerized microservice which is not multilngual, then add also the below setting. it disables operating system's globalization to reduce image size and dependencies.. + +```bash +DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 +``` + +Clean `Program.cs` startup! +Here's a minimal `Program.cs` which includes just the essential middleware and settings: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); + +builder.Services.AddResponseCompression(); +builder.Services.AddResponseCaching(); +builder.Services.AddHealthChecks(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/error"); + app.UseHsts(); +} + +app.UseResponseCompression(); +app.UseResponseCaching(); + +app.MapHealthChecks("/health"); +app.MapGet("/error", () => Results.Problem(statusCode: 500)); + +app.Run(); +``` + + + +------ + +## 3) Garbage Collection and ThreadPool + +### GC Memory Cleanup Mode + +GC (Garbage Collection) is how .NET automatically frees memory. There are two main modes: + +- **Workstation GC:** good for desktop apps (focuses on responsiveness) +- **Server GC:** good for servers (focuses on throughput) + +The below environment variable is telling the .NET runtime to use the *Server Garbage Collector (Server GC)* instead of the *Workstation GC*. Because our ASP.NET Core app must be optmized for servers not personal computers. + +```bash +COMPlus_gcServer=1 +``` + +### GC Limit Memory Usage + +Use at max 60% of the total available memory for the managed heap (the memory that .NET’s GC controls). So if your container or VM has, let's say 4 GB of RAM, .NET will try to keep the GC heap below 2.4 GB (60% of 4 GB). Especially when you run your app in containers, don’t let the GC assume host memory: + +```bash +COMPlus_GCHeapHardLimitPercent=60 +``` + +### Thread Pool Warm-up + +When your .NET app runs, it uses a thread pool. This is for handling background work like HTTP requests, async tasks, I/O things... By default, the thread pool starts small and grows dynamically as load increases. That’s good for desktop apps but for server apps it's too slow! Because during sudden peek of traffic, the app might waste time creating threads instead of handling requests. So below code keeps at least 200 worker threads and 200 I/O completion threads ready to go even if they’re idle. + +```csharp +ThreadPool.SetMinThreads(200, 200); +``` + + + +------ + +## 4) HTTP Performance + +### HTTP Response Compression + +`AddResponseCompression()` enables HTTP response compression. It shrinks your outgoing responses before sending them to the client. Making smaller payloads for faster responses and uses less bandwidth. Default compression method is `Gzip`. You can also add `Brotli` compression. `Brotli` is great for APIs returning JSON or text. If your CPU is already busy, keep the default `Gzip` method. + +```csharp +builder.Services.AddResponseCompression(options => +{ + options.Providers.Add(); + options.EnableForHttps = true; +}); +``` + + + +### HTTP Response Caching + +Use caching for GET endpoints where data doesn’t change often (e.g., configs, reference data). `ETags` and `Last-Modified` headers tell browsers or proxies skip downloading data that hasn’t changed. + +- **ETag** = a version token for your resource. +- **Last-Modified** = timestamp of last change. + +If a client sends `If-None-Match: "abc123"` and your resource’s `ETag` hasn’t changed, .NET automatically returns `304 Not Modified`. + + + +### HTTP/2 or HTTP/3 + +These newer protocols make web requests faster and smoother. It's good for microservices or frontends making many API calls. + +- **HTTP/2** : multiplexing (many requests over one TCP connection). +- **HTTP/3** : uses QUIC (UDP) for even lower latency. + +You can enable them on your reverse proxy (Nginx, Caddy, Kestrel)... +.NET supports both out of the box if your environment allows it. + + + +### Minimal Payloads with DTOs + +The best practise here is; Never send/recieve your entire database entity, use DTOs. In the DTOs include only the fields the client actually needs by doing so you will keep the responses smaller and even safer. Also, prefer `System.Text.Json` (now it’s faster than `Newtonsoft.Json`) and for very high-traffic APIs, use source generation to remove reflection overhead. + +```csharp +//define your entity DTO +[JsonSerializable(typeof(MyDto))] +internal partial class MyJsonContext : JsonSerializerContext { } + +//and simply serialize like this +var json = JsonSerializer.Serialize(dto, MyJsonContext.Default.MyDto) +``` + +------ + +## 5) Data Layer (Probably Where Most Apps Slow Down) + +### Reuse `DbContext` via Factory (Pooling) + +Creating a new `DbContext` for every query is expensive! Use `IDbContextFactory`, it gives you pooled `DbContext` instances from a pool that reuses objects instead of creating them from scratch. + +```csharp +services.AddDbContextFactory(options => + options.UseSqlServer(connectionString)); +``` + +Then inject the factory: + +```csharp +using var db = _contextFactory.CreateDbContext(); +``` + +Also, ensure your database server (SQL Server, PostgreSQL....) has **connection pooling enabled**. + +------ + +### N+1 Query Problem + +The N+1 problem occurs when your app runs **one query for the main data**, then **N more queries for related entities**. That kills performance!!! + +**Bad-Practise:** + +```csharp +var users = await context.Users.Include(u => u.Orders).ToListAsync(); +``` + +**Good-Practise:** +Project to DTOs using `.Select()` so EF-Core generates a single optimized SQL query: + +```csharp +var users = await context.Users.Select(u => new UserDto + { + Id = u.Id, + Name = u.Name, + OrderCount = u.Orders.Count + }).ToListAsync(); +``` + +------ + +### **Indexes** + +Use EF Core logging, SQL Server Profiler, or `EXPLAIN` (Postgres/MySQL) to find slow queries. Add missing indexes **only** where needed. For example [at this page](https://blog.sqlauthority.com/2011/01/03/sql-server-2008-missing-index-script-download/), he wrote an SQL query which lists missing index list (also there's another version at [Microsoft Docs](https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-db-missing-index-details-transact-sql?view=sql-server-ver17)). This perf improvement is mostly applied after running the app for a period of time. + + + +------ + +### Migrations + +In production run migrations manually, never do it on app startup.That way you can review schema changes, back up data and avoid breaking the live DB. + + + +------ + +### Resilience with Polly + +Use [Polly](https://www.pollydocs.org/) for retries, timeouts and circuit breakers for your DB or HTTP calls. Handles short outages gracefully + +To keep the article short and for the better readability I splitted it into 2 parts -> [Continue with the second part here](Post2.md)... + diff --git a/docs/en/Community-Articles/2025-10-17-Optimize-Your-App-For-Production/Post2.md b/docs/en/Community-Articles/2025-10-17-Optimize-Your-App-For-Production/Post2.md new file mode 100644 index 0000000000..f59be19336 --- /dev/null +++ b/docs/en/Community-Articles/2025-10-17-Optimize-Your-App-For-Production/Post2.md @@ -0,0 +1,236 @@ +## 6) Telemetry (Logs, Metrics, Traces) + +The below code adds `OpenTelemetry` to collect app logs, metrics, and traces in .NET. + +```csharp +builder.Services.AddOpenTelemetry() + .UseOtlpExporter() + .WithMetrics(m => m.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation()) + .WithTracing(t => t.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation()); +``` + +- `UseOtlpExporter()` Tells it where to send telemetry. Usually that’s an OTLP collector (like Grafana , Jaeger, Tempo, Azure Monitor). So you can visualize metrics and traces in dashboards. +- `WithMetrics()` means it'll collects metrics. These metrics are Request rate (RPS), Request duration (latency), GC pauses, Exceptions, HTTP client timings. +- `.WithTracing(...)` means it'll collect distributed traces. That's useful when your app calls other APIs or microservices. You can see the full request path from one service to another with timings and bottlenecks. + +### .NET Diagnostic Tools + +When your app is on-air, you should know about the below tools. You know in airplanes there's _black box recorder_ which is used to understand why the airplane crashed. For .NET below are our *black box recorders*. They capture what happened without attaching a debugger. + +| Tool | What It Does | When to Use | +| --------------------- | --------------------------------------- | ---------------------------- | +| **`dotnet-counters`** | Live metrics like CPU, GC, request rate | Monitor running apps | +| **`dotnet-trace`** | CPU sampling & performance traces | Find slow code | +| **`dotnet-gcdump`** | GC heap dumps (allocations) | Diagnose memory issues | +| **`dotnet-dump`** | Full process dumps | Investigate crashes or hangs | +| **`dotnet-monitor`** | HTTP service exposing all the above | Collect telemetry via API | + + + +------ + +## 7) Build and Run Your .NET App in Docker the Right Way + +A multi-stage build is a Docker technique where you use one image for building your app and another smaller image for running it. Why we do multi-stage build, because the .NET SDK image is big but has all the build tools. The .NET Runtime image is small and optimized for production. You copy only the published output from the build stage into the runtime stage. + +```dockerfile +# build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/out -p:PublishTrimmed=true -p:PublishSingleFile=true -p:ReadyToRun=true + +# run +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +WORKDIR /app +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +COPY --from=build /app/out . +ENTRYPOINT ["./YourApp"] # or ["dotnet","YourApp.dll"] +``` + +I'll explain what these Docker file commands; + +**Stage1: Build** + +* `FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build` + Uses the .NET SDK image including compilers and tools. The `AS build` name lets you reference this stage later. + +* `WORKDIR /src` + Sets the working directory inside the container. + +* `COPY . .` + Copies your source code into the container. + +* `RUN dotnet restore` + Restores NuGet packages. + +* `RUN dotnet publish ...` + Builds the project in **Release** mode, optimizes it for production, and outputs it to `/app/out`. + The flags; + * `PublishTrimmed=true` -> removes unused code + * `PublishSingleFile=true` -> bundles everything into one file + * `ReadyToRun=true` -> precompiles code for faster startup + +**Stage 2: Run** + +- `FROM mcr.microsoft.com/dotnet/aspnet:9.0` + Uses a lighter runtime image which no compiler, just the runtime. +- `WORKDIR /app` + Where your app will live inside the container. +- `ENV ASPNETCORE_URLS=http://+:8080` + Makes the app listen on port 8080 (and all network interfaces). +- `EXPOSE 8080` + Documents the port your container uses (for Docker/K8s networking). +- `COPY --from=build /app/out .` + Copies the published output from the **build stage** to this final image. +- `ENTRYPOINT ["./YourApp"]` + Defines the command that runs when the container starts. If you published as a single file, it’s `./YourApp`. f not, use `dotnet YourApp.dll`. + + + +------ + +## 8) Security + +### 8.1) HTTPS Everywhere Even Behind Proxy + +Even if your app runs behind a reverse proxy like Nginx, Cloudflare or a load balancer, always enforce HTTPS. Why? Because internal traffic can still be captured if you don't use SSL and also cookies, HSTS, browser APIs require HTTPS. In .NET, you can easily enforce HTTPS like this: + +```csharp +app.UseHttpsRedirection(); +``` + + + +### 8.2) Use HSTS in Production + +HSTS (HTTP Strict Transport Security) tells browsers: + +> Always use HTTPS for this domain — don’t even try HTTP again! + +Once you set, browsers cache this rule, so users can’t accidentally hit the insecure version. You can easily enforce this as below: + +```csharp +if (!app.Environment.IsDevelopment()) +{ + app.UseHsts(); +} +``` + +When you use HSTS, it sends browser this HTTP header: ` Strict-Transport-Security: max-age=31536000; includeSubDomains`. Browser will remember this setting for 1 year (31,536,000 seconds) that this site must only use HTTPS. And `includeSubDomains` option applies the rule to all subdomains as well (eg: `api.abp.io`, `cdn.abp.io`, `account.abp.io` etc..) + +### 8.3) Store Secrets on Environment Variables or Secret Stores + +Never store passwords, connection strings, or API keys in your code or Git. Then where should we keep them? + +- Best/practical way is **Environment variables**. You can easily sett an environment variable in a Unix-like system as below: + + - ```bash + export ConnectionStrings__Default="Server=...;User Id=...;Password=..." + ``` + +- And you can easily access these environment variables from your .NET app like this: + + - ```csharp + var conn = builder.Configuration.GetConnectionString("Default"); + ``` + +Or **Secret stores** like: Azure Key Vault, AWS Secrets Manager, HashiCorp Vault + + + +### 8.4) Add Rate-Limiting to Public Endpoints + +Don't forget there'll be not naive guys who will use your app! We've many times faced this issue in the past on our public front-facing websites. So protect your public APIs from abuse, bots, and DDoS. Use rate-limiting!!! Stop brute-force attacks, prevent your resources from exhaustion... + +In .NET, there's a built-in rate-limit feature for .NET (System.Threading.RateLimiting): + +```csharp +builder.Services.AddRateLimiter(_ => _ + .AddFixedWindowLimiter("default", options => + { + options.PermitLimit = 100; + options.Window = TimeSpan.FromMinutes(1); + })); + +app.UseRateLimiter(); +``` + +- Also there's an open-source rate-limiting library -> [github.com/stefanprodan/AspNetCoreRateLimit](https://github.com/stefanprodan/AspNetCoreRateLimit) +- Another one -> [nuget.org/packages/Polly.RateLimiting](https://www.nuget.org/packages/Polly.RateLimiting) + +### 8.5) Secure Cookies + +Cookies are often good targets for attacks. You must secure them properly otherwise you can face cookie stealing or CSRF attack. + +```csharp +options.Cookie.SecurePolicy = CookieSecurePolicy.Always; +options.Cookie.SameSite = SameSiteMode.Strict; // or Lax +``` + +- **`SecurePolicy = Always`** -> only send cookies over HTTPS +- **`SameSite=Lax/Strict`** -> prevent CSRF (Cross-Site Request Forgery) + - `Strict` = safest + - `Lax` = good balance for login sessions + + + +------ + +## 9) Startup/Cold Start + +### 9.1) Keep Tiered JIT On + +The **JIT (Just-In-Time) compiler** converts your app’s Intermediate Language (IL) into native CPU instructions when the code runs. _Tiered JIT_ means the runtime uses 2 stages of compilation. Actually this setting is enabled by default in modern .NET. So just keep it on. + +1. **Tier 0 (Quick JIT):** + Fast, low-optimization compile → gets your app running ASAP. + (Used at startup.) +2. **Tier 1 (Optimized JIT):** + Later, the runtime re-compiles *hot* methods (frequently used ones) with deeper optimizations for speed. + + + +### 9.2) **Use PGO (Profile-Guided Optimization)** + +PGO lets .NET learn from real usage of your app. It profiles which functions are used most often, then re-optimizes the build for that pattern. You can think of it as the runtime saying: + +> I’ve seen what your app actually does... I’ll rearrange and optimize code paths accordingly. + +In .NET 8+, you don’t have to manually enable PGO (Profile-Guided Optimization). The JIT collects runtime profiling data (e.g. which types are common, branch predictions) and uses it to generate more optimized code later. In .NET 9, PGO has been improved: the JIT uses PGO data for more patterns (like type checks / casts) and makes better decisions. + + + +------ + +## 10) Graceful Shutdown + +When we break up with our lover, we often argue and regret it later. When an application breaks up with an operating system, it should be done well 😘 ... +When your app stops, maybe you deploy a new version or Kubernetes restarts a pod... the OS sends a signal called `SIGTERM` (terminate). +A **graceful shutdown** means handling that signal properly, finishing what’s running, cleaning up, and exiting cleanly (like an adult)! + +```csharp +var app = builder.Build(); +var lifetime = app.Services.GetRequiredService(); +lifetime.ApplicationStopping.Register(() => +{ + // stop accepting, finish in-flight, flush telemetry +}); +app.Run(); +``` + +On K8s, set `terminationGracePeriodSeconds` and wire **readiness**/startup probes. + +------ + +## 11) Load Test + +Sometimes arguing with our lover is good. We can see her/his face before marrying 😀 Use **k6** or **bombardier** and test with realistic payloads and prod-like limits. Don't be surprise later when your app is running on prod! Test these topics: + +- CPU % +- Time in GC +- LOH allocations +- ThreadPool queue length +- Socket exhaustion