Why Your GC Pauses Are Longer Than You Think

Server hardware displaying diagnostic LEDs

You’ve tuned the garbage collector. Read the blog posts, tweaked the segment sizes, flipped the workstation-to-server switch. Then you pull a production trace and the pause times stare back, stubbornly larger than the numbers you penciled out on a napkin. I’ve spent years inside memory profilers and WinDbg sessions, and the gap between expected and actual GC pause duration is almost never a single misconfiguration. It’s a pile of overlooked mechanics that compound silently until they become the loudest thing in your tail latencies.

This article digs into the hidden contributors that inflate managed-heap pause times. We’ll walk through the internal phases of a blocking generation-2 collection, pin down the real cost of finalization, and expose why your allocation pattern is likely forcing more frequent—and longer—ephemeral collections than you think. By the end, you’ll have a concrete checklist to run against your own dumps and traces.

The Anatomy of a Blocking GC Pause

Before you can figure out why your pauses overshoot, you need a precise model of what the runtime actually does during a stop-the-world episode. A full blocking collection isn’t just “mark and sweep.” The pause spans several discrete stages, and each one can stretch for reasons that naive GC perf counters never capture.

  • SuspendEE: The runtime has to bring all managed threads to a safe point before the GC can proceed. This phase cooperates with the JIT-generated GC info tables. Threads stuck in tight loops without backward branches—think of a spin-wait inside System.Threading.SpinWait—can delay suspension for hundreds of microseconds.
  • Mark phase: The GC walks roots (stack roots, handle tables, the finalization queue) and builds the live-object graph. Duration here is proportional to the number of live references, not heap size. Dense object graphs with deep nesting, especially ones anchored by static collections, inflate mark time linearly.
  • Plan phase: The GC decides which objects to relocate and calculates new addresses. A high count of pinned objects forces the planner to fragment the heap plan, burning extra CPU.
  • Relocate and compact: Objects get moved, references get updated. This phase depends heavily on the number of pinned objects blocking compaction, plus the percentage of the heap that’s actually movable.
  • ResumeEE: Threads are released, finalization is scheduled. If the finalizer thread was busy, the resume can stall briefly while the runtime queues new work.

Measure only the total pause and you miss which phase is the culprit. A long SuspendEE points at thread-pool design issues. A bloated Mark phase suggests live-object retention problems. A slow compact phase screams pinning. The tools to separate these phases are ETW traces and the GCStats output in WinDbg’s SOS extension. Correlate the per-phase numbers with your own latency histograms, and you’ll stop blaming “the GC” as a monolith.

The Pin That Breaks the Compaction’s Back

Pinning is the single most pervasive reason for elongated gen-2 pauses in server workloads. Every pinned buffer forces the GC to leave an immovable island in the heap. During compaction, the planner has to work around these islands. The result is a fragmented heap that can’t be compressed into a single contiguous block, which increases the number of segments and the time spent calculating new addresses.

Close-up of a memory module on a motherboard

What rarely gets discussed: pinning doesn’t just slow down compaction. It forces the GC to demote objects from gen-0 and gen-1 into gen-2 earlier than they would naturally age. Since gen-2 collections are the most expensive, this premature promotion increases their frequency. A classic example is a network library that pins a large byte array for an asynchronous socket operation. That array lands in gen-2 after a single gen-0 collection, and every subsequent collection that tries to compact near it pays the price.

To quantify this, use !gchandles in SOS to enumerate pinned handles, and cross-reference the sizes with !dumpheap -stat. Look for System.Byte[] and System.Char[] at the top. If your pinned-array count is high, consider array pooling with ArrayPool<byte>.Shared or rewriting the I/O path to use System.IO.Pipelines, which manages its own buffer lifecycle and drastically reduces long-lived pins.

The Finalization Queue: A Hidden Thread of Delays

Objects that override Finalize() don’t die when they become unreachable. They get moved to the finalization queue, and a dedicated thread runs their destructors. Only after that thread finishes does the object become eligible for reclamation in the next collection. This two-cycle death means finalizable objects artificially extend the lifetime of everything they reference, sometimes dragging large sub-graphs into gen-2.

The pause inflation happens because the finalizer thread runs at normal priority. If your application creates a burst of finalizable objects—imagine closing a thousand file streams in a tight loop—the finalizer thread can fall behind. The GC then sees a growing queue and, during the next gen-2 collection, has to wait for that thread to drain enough entries to free memory. The wait isn’t always visible as a GC pause, but it shows up as a stall in the allocation path when the runtime can’t satisfy a new-object request without freeing gen-2 space.

Use !finalizequeue in WinDbg to inspect the count of objects ready for finalization. Anything above a few hundred warrants immediate investigation. The fix is to implement IDisposable and call Dispose() deterministically, suppressing finalization via GC.SuppressFinalize(this). For library authors, the pattern of wrapping unmanaged resources in a SafeHandle eliminates the need for a finalizer on the public class altogether.

Large Object Heap: Not Just for Large Objects

The Large Object Heap (LOH) is a separate region for allocations above 85,000 bytes. It’s never compacted by default, so fragmentation is a certainty over time. The hidden cost: LOH fragmentation forces the GC to ask the OS for more virtual memory, and each new segment allocation is an expensive kernel call that happens inside a GC pause. Even if your pause isn’t spent compacting, it’s spent in VirtualAlloc.

What many engineers miss is that the LOH also interacts with gen-2 collections. The LOH is collected only during gen-2 collections, so any LOH allocation activity effectively schedules a gen-2 event. If your application frequently allocates temporary large arrays—think of a serialization library that creates a 100 KB buffer per request—you’re triggering gen-2 collections at a rate set by your request throughput, not by small-object heap pressure. Those gen-2 pauses then halt all threads, even ones not touching the LOH.

Rows of server racks in a data center

To diagnose, capture an ETW trace with the Microsoft-Windows-DotNETRuntime provider and look for GCAllocationTick_V2 events filtered by allocation size > 85000. Correlate those timestamps with the GC/Start events. If the LOH allocation rate aligns with gen-2 collection frequency, you’ve found your culprit. The mitigation is to pool large buffers or use ArrayPool<byte> for temporary work. For truly unavoidable large allocations, consider the GCSettings.LargeObjectHeapCompactionMode property, which forces a one-time LOH compaction—but measure the resulting pause carefully; it can climb into seconds on a fragmented heap.

Server GC vs. Workstation GC: The Affinity Trap

Server GC creates a dedicated heap and a dedicated GC thread per logical processor. The intent is to maximize throughput by parallelizing collections. The trade-off: all these threads have to synchronize at the start of a gen-2 collection. If your process has 32 cores, you have 32 heaps and 32 GC threads. The pause duration is set by the slowest heap’s collection, not the average. A single heap with an unusually high pin count or a deep object graph will stretch the pause for all cores.

This is why I often see teams switch from Workstation to Server GC expecting a magic latency improvement and instead get worse tail latencies. Workstation GC runs on the thread that triggered the collection and uses a single heap. For applications that aren’t CPU-bound on collection work, the coordination overhead of Server GC can exceed the parallelization benefit. The break-even point is highly workload-specific, but as a rule of thumb, if your application has fewer than four cores, Server GC is almost never the right choice for latency.

Run !eeversion in SOS to confirm the GC mode, then use !threadpool to inspect the number of threads. If you see 32 GC threads and your 99th-percentile pause is above your target, test with in your app config. Compare the latency histograms before and after—the result often surprises people who assumed more parallelism equals lower pauses.

Allocation Rate and the Ephemeral Segment Trap

The ephemeral segment (generations 0 and 1) has a fixed size. When it fills, a gen-0 collection fires. If the collection doesn’t free enough space—because your code holds many live references—the survivor objects get promoted to gen-1. A subsequent rapid fill of gen-0 triggers another collection, and if gen-1 is now full, a gen-1 collection fires, promoting survivors to gen-2. This cascade is called an ephemeral promotion storm, and it’s the primary reason you see gen-2 collections more frequently than your object-lifetime model predicts.

The real kicker: during a promotion storm, the GC spends extra CPU time copying objects between generations. That time is inside the pause, and it scales with the number of promoted bytes. A high allocation rate combined with a mid-life object retention pattern—think of a cache that holds items for 30 seconds while requests allocate at 1 GB/s—creates a perfect storm where every gen-0 collection promotes a wave of data to gen-1, and every few gen-1 collections force a gen-2.

Use the % Time in GC performance counter. If it exceeds 10% for a sustained period, your allocation rate is the root cause. Profile with a memory profiler that captures allocation call stacks, and focus on the hottest allocation sites. Reducing allocations is more effective than any GC tuning knob.

Practical Diagnostic Workflow

Here’s the sequence I follow when a team reports unexpectedly high GC pauses:

  1. Collect an ETW trace with the GC provider and the kernel context-switch provider. Open it in PerfView or Windows Performance Analyzer.
  2. Isolate the SuspendEE duration. If it exceeds 1 ms, inspect thread states at the suspension point. Look for threads in JITCompilation or unmanaged code with disabled preemptive GC.
  3. For gen-2 pauses above 50 ms, break out the mark, plan, and compact phases. If compact dominates, run !gchandles on a dump to count pinned objects.
  4. Check the finalization queue. If it holds more than 100 objects, review the code for missing Dispose calls.
  5. Correlate LOH allocation ticks with gen-2 collection starts. If they align, pool or reduce large allocations.
  6. Verify Server GC thread count matches core count and is appropriate for the workload.

FAQ

Why do my GC pauses spike under load even though the heap size is stable?

Stable heap size doesn’t mean stable pause times. Under high load, the allocation rate increases, causing more frequent ephemeral collections. Each collection promotes survivors, which bumps up the live-object density the mark phase has to traverse. More live objects mean a longer mark phase, even if total heap bytes stay flat. The fix is to reduce the allocation rate or shorten the lifetime of mid-life objects.

Can pinned objects affect gen-0 collection times, or only gen-2?

Pinned objects directly hit gen-2 collections because compaction happens only in that generation. However, pinned objects in gen-0 and gen-1 get promoted to gen-2 earlier than they would otherwise, which increases the frequency and cost of gen-2 collections. So the effect on gen-0 is indirect but real: you end up with more gen-2 pauses over time.

Is there a way to completely avoid gen-2 collections in a server application?

Not practically. The GC will always trigger a gen-2 collection when the ephemeral segment fills and promotion pushes data into gen-2, or when the LOH runs out of space. You can minimize gen-2 collections by eliminating large temporary allocations, pooling buffers, and keeping object lifetimes short. Some specialized scenarios use unmanaged memory or object-handle recycling to bypass the GC entirely, but that brings its own complexity and is rarely worth the maintenance cost.

How do I know if I should switch from Server GC to Workstation GC?

Collect latency percentiles (p50, p95, p99) for GC pauses under both configurations using identical load. If Server GC shows higher p99 pauses despite similar p50 values, and your process runs on fewer than eight cores, Workstation GC is likely the better choice. The decision hinges on whether the coordination overhead of multiple GC threads outweighs the parallel collection benefit for your specific object graph.