Optimizing .NET Garbage Collection for Web Applications
A .NET web application can appear healthy while the garbage collector quietly consumes CPU, increases response times, and creates irregular pauses. Average latency may look acceptable in a dashboard, yet users experience occasional slow page loads when several large allocations arrive together. These symptoms are often blamed on database queries or network conditions when memory management is part of the problem.
Garbage collection is essential to the .NET runtime. It reclaims managed objects that an application no longer uses, allowing developers to focus on application behaviour rather than manually freeing every allocation. The cost comes from identifying unreachable objects, compacting memory, growing the managed heap, and occasionally suspending application threads while collection takes place.
Web workloads make this especially important. An ASP.NET Core application may handle thousands of short-lived requests, serialise JSON, render views, process authentication claims, and construct temporary collections in a small amount of time. If the allocation rate is high, Gen 0 collections can occur constantly. If objects survive long enough, they move into older generations where collection is more expensive.
The best results come from measurement rather than from forcing the runtime into an arbitrary configuration. Garbage collection settings should reflect the application’s traffic pattern, memory limit, deployment model, and latency requirements. This is particularly relevant for Australian services hosted in Sydney or Melbourne cloud regions, where a poor allocation pattern can affect users across large distances and increase the cost of scaling during peak periods.
How The .NET Garbage Collector Works
The managed heap is divided into generations based on object lifetime. Gen 0 contains newly allocated objects and is collected frequently. Objects that survive collection are promoted to Gen 1 and then Gen 2. This arrangement is efficient because most temporary objects become unreachable quickly, while long-lived objects do not need to be examined during every small collection.
A Gen 0 collection is usually inexpensive, but frequent allocations can still create substantial CPU pressure. Gen 1 collections deal with objects that survived the first stage. Gen 2 collections are more disruptive because they inspect a larger portion of the heap. A full blocking collection can pause managed threads, which may appear as spikes in request latency.
The Large Object Heap, or LOH, stores large allocations, generally around 85,000 bytes or larger, although the exact threshold is an implementation detail. Large byte arrays, oversized strings, image buffers, and serialisation payloads can quickly fill the LOH. Full compaction is expensive, so repeated large allocations may cause fragmentation and higher committed memory.
Recent .NET versions also include the Pinned Object Heap for objects that must remain at a stable memory address. Pinning is useful for interoperation and high-performance I/O, but excessive pinned buffers can make compaction less effective. Understanding these heap areas helps explain why an application can show low request volume while retaining a large working set.
Select A Collection Mode That Fits The Workload
Server garbage collection is normally the right starting point for ASP.NET Core applications. It uses multiple dedicated GC threads and is designed for throughput on machines with several cores. Workstation GC is intended primarily for desktop applications and interactive scenarios. Running a busy API with the wrong mode can reduce throughput or produce less predictable pauses.
Server GC does not automatically solve every memory problem. It can use more CPU and reserve more memory, which matters in containers and smaller cloud instances. A service running in a 512 MB container should not be treated like a service deployed on a dedicated 16-core virtual machine. The runtime must operate within the resources actually available to the process.
Latency-focused applications can consider background server GC, which performs much of Gen 2 collection concurrently with application execution. This generally reduces long pauses, though collection still requires CPU and can compete with request processing. A system that is already CPU-bound may see little benefit if it has no spare capacity for GC work.
Configuration should be tested through the deployment environment rather than only on a developer laptop. A Melbourne-based customer-facing API, for example, may have a different traffic profile from an internal batch service in Brisbane. Compare sustained throughput, tail latency, memory usage, and CPU consumption under realistic Australian traffic patterns, including local business-hour peaks.
Reduce Allocation Pressure In Application Code
The most dependable optimisation is often to allocate fewer objects. Avoid creating temporary strings in hot paths when a structured value, span, or reusable buffer can perform the same job. Be careful with string interpolation, repeated concatenation, LINQ pipelines over large collections, and conversion routines inside every request. These patterns are readable, but they can create considerable Gen 0 activity at scale.
JSON serialisation deserves particular attention. Serialising large object graphs can allocate temporary buffers and produce sizeable strings or byte arrays. Prefer the built-in System.Text.Json stack where it meets the application’s needs, use source generation for stable models, and stream large responses when buffering the complete payload is unnecessary. Set sensible request and response size limits so an unexpected upload cannot overwhelm the LOH.
Pooling can help with repeatable, expensive allocations. ArrayPool<T>, Microsoft.Extensions.ObjectPool, and reusable pipelines are useful for buffers and temporary work items. Pools require discipline: returned arrays must not be used again, sensitive data should be cleared when appropriate, and oversized arrays should not be retained indefinitely. A poorly designed pool simply changes many short-lived allocations into a long-lived memory leak.
Common allocation sources worth reviewing include:
- Boxing value types when values are passed through non-generic APIs
- Captured variables and closures created inside frequently executed callbacks
- Repeated LINQ queries that build enumerators, delegates, or intermediate collections
- Large JSON, CSV, image, or PDF buffers held for the duration of a request
- Logging templates that eagerly format values before the logger checks its level
Allocation reduction should preserve clarity. A small allocation in a rarely used administration endpoint does not justify complicated pooling code. Focus on high-volume routes, authentication middleware, serialisation boundaries, database mapping, and background queues where a modest improvement is multiplied by thousands of operations.
Manage Large Objects And Native Resources
Large object handling is a frequent source of memory instability in web applications. A file upload endpoint may accept a 20 MB document, copy it several times, convert it to a byte array, and pass it through multiple services. Even when every object eventually becomes collectible, those copies can create temporary memory peaks and force expensive collections.
Use streams where possible and process data in bounded chunks. Configure upload limits in the web server and application framework, and reject unsuitable requests before large buffers are allocated. For image processing or report generation, consider a dedicated worker with controlled concurrency rather than allowing every incoming request to perform memory-intensive work simultaneously.
Managed memory is only part of a process’s footprint. Native libraries, database drivers, TLS buffers, memory-mapped files, and runtime structures can consume memory outside the managed heap. A container may be killed even when the GC heap appears reasonable. Monitor the working set, private bytes, container limit, and managed heap together.
Dispose IDisposable resources promptly, especially streams, database connections, compression objects, and native handles. Disposal does not directly force garbage collection, but it releases scarce resources without waiting for finalisation. Avoid calling GC.Collect in normal request code. Manual collections usually add pauses and hide the allocation pattern that should be fixed.
Australian privacy obligations add a practical reason to control buffers. Applications covered by the Privacy Act 1988 and the Australian Privacy Principles should avoid retaining uploaded identity documents or personal records longer than necessary. Clear ownership, disposal, and retention rules improve both memory behaviour and the handling of personal information in hosted systems.
Measure Memory Behaviour In Production
GC tuning should begin with evidence. Watch allocation rate, Gen 0 and Gen 1 collection frequency, Gen 2 collections, pause duration, heap size, LOH size, CPU, and request latency percentiles. Averages can hide the problem, so compare GC events with p95 and p99 response times for individual endpoints.
Useful tools include dotnet-counters for live runtime counters, dotnet-trace for event-based analysis, and dotnet-dump for investigating retained objects. A memory profiler can show which types dominate the heap and what object paths keep them alive. In production, use sampling and controlled diagnostics so troubleshooting does not create a second performance problem.
Track metrics that connect runtime behaviour to user-visible outcomes:
- Allocation rate alongside requests per second and endpoint latency
- Gen 2 collection frequency and duration alongside p95 and p99 response times
- Managed heap, LOH, working set, and container memory limit
- CPU consumed by the runtime during traffic peaks
- Queue depth, thread-pool starvation, and rejected or timed-out requests
A useful investigation compares a quiet period, a normal peak, and a deliberately stressful load test. For a service used throughout Sydney and Perth, account for time-zone-driven traffic patterns rather than testing only during a developer’s local working hours. Synthetic tests should include realistic JSON sizes, authentication claims, database results, and file-handling paths.
Observability data should avoid exposing personal information. Do not place full request bodies, identity documents, or access tokens into traces simply to understand memory use. Aggregate runtime metrics and use carefully controlled test payloads for deeper diagnosis.
Tune Containers And Hosting Limits Carefully
Modern .NET applications often run in Docker containers or managed platforms where memory and CPU limits influence GC decisions. The runtime can detect container constraints, but the result still depends on the .NET version, host configuration, and limits applied by the platform. Confirm what the process sees rather than assuming the virtual machine’s total memory is available.
A container with a strict memory limit may need a smaller heap target, reduced concurrency, or a different instance size. Environment variables such as DOTNET_GCHeapHardLimit can impose a managed heap ceiling, while GCHeapHardLimitPercent can express a percentage-based limit in supported configurations. These settings should be introduced cautiously because an overly low limit can increase collection frequency and cause out-of-memory failures.
Horizontal scaling is often safer than allowing one process to absorb unlimited traffic. Several modest instances can distribute allocation and GC work, while a single large process may produce larger pauses and harder recovery after a fault. The trade-off includes connection pools, cache duplication, licensing, and cloud cost, so benchmark the complete service rather than the GC in isolation.
Use load tests that reflect deployment reality: the same container limit, CPU quota, logging configuration, database latency, and autoscaling rules. Consider Australian hosting regions and data-transfer paths when evaluating results. A service hosted in Sydney may serve local users efficiently, while users in regional areas or across the country can experience different network latency; GC tuning cannot compensate for every infrastructure delay.
After each change, compare a baseline against the candidate configuration. Keep changes small, record the .NET version and runtime settings, and watch for regressions after deployments. A configuration that performs well during a quiet weekday may behave differently during end-of-month processing or a major retail promotion.
Use this operational checklist before changing GC settings:
- Confirm the runtime version, GC mode, container limits, and process architecture
- Capture baseline allocation, heap, CPU, pause, and tail-latency measurements
- Identify the endpoints and types responsible for the largest allocations
- Test streaming, pooling, batching, and bounded concurrency before forcing collections
- Reproduce production limits in a load test and compare several traffic levels
- Roll out changes gradually with alerts for memory growth and latency spikes
The right configuration is the one supported by repeatable measurements. A lower allocation rate, controlled payload size, and sensible concurrency often deliver a larger improvement than a collection switch made without diagnosis.
Apply these techniques to a representative ASP.NET Core service, capture a baseline, and change one variable at a time. With disciplined profiling and deployment-aware testing, .NET garbage collection becomes a measurable part of web performance engineering rather than an unpredictable source of pauses.