The .NET garbage collector is the platform feature most developers are happiest to ignore — until it starts pausing their app for 300 milliseconds at a time. This deep dive covers the model in enough detail to read a profiler trace and make sensible allocation choices, without turning into a textbook.

The mental model: generations

The CLR's GC is generational and compacting. Allocations go onto the small object heap (SOH), which is divided into three generations:

Plus the Large Object Heap (LOH) for allocations > 85,000 bytes, which is collected with Gen 2 and (by default) is not compacted.

The key insight: GC cost is proportional to the number of surviving objects scanned, not the total. So Gen 0 collections are cheap (fast death = few survivors), and Gen 2 collections are expensive (everything reachable gets walked).

What happens in a Gen 2 collection

A full GC has roughly four phases:

  1. Mark. Walk roots (statics, stacks, GC handles), mark every reachable object.
  2. Plan. Decide what survives and where it'll move.
  3. Sweep / compact. Free dead memory, move live objects toward the start of each generation.
  4. Update references. Patch every reference that points to a moved object.

With workstation GC (default for desktop apps), this happens on a single thread. With server GC (default for ASP.NET Core), it runs in parallel on one thread per logical CPU. Both can be either concurrent (most work happens while your code runs) or blocking (everything stops). You'll get blocking GCs under memory pressure regardless of mode.

Allocation patterns that hurt

Three patterns produce most of the GC pain we see in audits:

1. Hidden allocations in hot paths

// Innocent-looking, allocates a string per call.
public string Format(int id, string name) =>
    $"id={id};name={name}";

In a hot path, that interpolation produces a string.Format call, a params object[], and boxing for the int. Use DefaultInterpolatedStringHandler (the C# 10+ compiler does this for you in most cases) or write to a pre-existing StringBuilder.

2. Async state machine boxes

Every async method returning Task on an exception or non-cached completion path allocates a state machine object. For high-frequency code, return ValueTask and use IValueTaskSource for pooled completions. Don't do this everywhere — only on measured hot paths.

3. Large arrays on the LOH

Anything >= 85,000 bytes goes to the LOH and stays there until the next Gen 2. Use ArrayPool<T>.Shared for buffers you can rent and return:

var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(128_000);
try {
    ProcessChunk(buffer);
} finally {
    pool.Return(buffer, clearArray: true);
}

Reading a trace

The two tools we reach for first:

A healthy production trace looks roughly like: many Gen 0s, fewer Gen 1s, very few Gen 2s, and a heap that doesn't grow over time. If Gen 2 collections happen every few seconds, something is being promoted that shouldn't be — usually a cache without bounds, or objects held alive by an event handler.

Rule of thumb. Don't optimize for the GC until you've measured. Most apps don't need allocation-free hot paths. The ones that do — high-throughput services, game loops, market data feeds — need them everywhere, not occasionally.

The new ones: pinned object heap and regions

Two more recent additions worth knowing about:

Closing thought

The GC is one of those features you get to take for granted 99% of the time and must understand for the other 1%. Spend an afternoon profiling a real service of yours under load — you'll learn more than any blog post can teach.

We run a "performance for .NET developers" track that covers GC tuning, the Span<T> family, and benchmarking. Get in touch if you'd like to join.