.NET garbage collection explained: what gen 0, 1, and 2 cost
There is a latency chart shape every .NET service eventually produces: mostly flat, with a sawtooth of spikes arriving every few minutes. The spikes last tens or sometimes hundreds of milliseconds, they correlate with nothing in your code or your traffic, and they survive every deploy. That is the garbage collector, doing exactly what it was designed to do, on a schedule your allocation rate wrote for it.
Most explanations of the GC stop at "it frees memory you no longer use." True, and useless for engineering, because it says nothing about cost: when the collector runs, what it has to do when it runs, and which of your habits make it run more. The version worth knowing fits in one empirical observation and three generations.
Most objects die young
The .NET heap is organized around a single bet, sometimes called the generational hypothesis: most objects die young. The string you built for a log line and the list a LINQ query materialized are both garbage microseconds after they were born. A small minority of objects survive for the life of the process: caches, connection pools, whatever your DI singletons hold. Very little lives in between.
So the runtime segregates objects by age. New allocations land in generation 0, a small nursery region designed to be collected fast and often. An object that survives a gen 0 collection is promoted to generation 1, a buffer zone for things that were alive at the wrong moment. Survive again and it is promoted to generation 2, where the runtime assumes it is long-lived and inspects it as rarely as it can get away with. The GC fundamentals documentation spells out the machinery; the diagram is the intuition.
Gen 0
collected constantly · well under 1 ms
Gen 1
survivors wait here
Gen 2
full-heap pause · tens of ms and up
Large object heap
85,000+ bytes · collected only with gen 2
One class of object skips the nursery entirely. Anything of 85,000 bytes or more, in practice almost always an array, is allocated directly on the large object heap, the LOH. That special treatment exists because copying big objects around is expensive, and it comes with a special bill we will get to shortly.
What each collection costs
A collection's cost scales with how much memory it has to walk, and the generational design exists to make the common collection walk almost nothing.
A gen 0 collection scans the nursery, copies the few survivors out, and declares the rest free in bulk. On a typical service this takes well under a millisecond. It happens constantly, and you will essentially never see it on a latency chart. Gen 1 collections cost a little more and run much less often. This is the design working: the objects that die young, which is most of them, are reclaimed by the cheapest operation the collector has.
Gen 2 is the expensive one. It walks the entire heap, including the LOH, which is collected only when gen 2 is. On a heap of several gigabytes, that walk costs somewhere between tens and hundreds of milliseconds of work, and those are rough orders of magnitude rather than a benchmark: the exact bill depends on heap size, survivor count, and hardware. Background GC, on by default, overlaps much of that work with your application instead of pausing it outright, but concurrent is not free. It competes for cores, and portions of the collection still stop the world. When your p99 has a sawtooth, gen 2 is usually holding the saw.
The LOH adds one more wrinkle: by default it is swept rather than compacted, meaning freed holes are reused instead of the heap being squeezed tight. Allocate and free odd-sized large arrays for long enough and the LOH can fragment, holding memory your process cannot actually use.
Survivors and promotion
Promotion is where healthy-looking code goes wrong, because promotion turns cheap garbage into expensive garbage. An object that survives gen 0 is not merely spared. It is copied into gen 1, and if it survives again, into gen 2, where reclaiming it requires the priciest collection the runtime has.
The classic failure is the mid-life object: something that lives just long enough to be alive when collections happen, then dies. Think of a cache entry with a 30 second TTL, or a request that buffers state for a few hundred milliseconds under load. These objects are garbage on any human timescale, but they survive enough gen 0 sweeps to get promoted, and then they die in gen 2, the one place where dying is expensive. A steady stream of them turns gen 2 into a landfill that must be excavated on every full collection.
Large buffers are the same story with a shortcut, since they start on the LOH:
// per request: a fresh 128 KB buffer, straight onto the large object heap
public async Task ExportAsync(Stream destination, CancellationToken ct)
{
var buffer = new byte[128 * 1024];
// ... fill the buffer and write it out ...
}
At 131,072 bytes, that buffer clears the 85,000 byte threshold, so every request creates an object that only a gen 2 collection can reclaim. Renting from the pool makes the allocation boring again:
var buffer = ArrayPool<byte>.Shared.Rent(128 * 1024);
try
{
// ... fill the buffer and write it out ...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
Same 128 KB of working memory, but it is allocated once and reused, instead of being allocated per request and reclaimed by your most expensive GC path.
Allocation rate sets the schedule
Here is the part that surprises people: how much memory your service holds matters less to latency than how fast it allocates. Gen 0 has a budget. When the budget fills, a collection runs. If you allocate at hundreds of megabytes per second, it does not matter that your live set is a modest 50 MB; you have scheduled collections to run continuously, and every one of them is an opportunity to promote whatever happens to be alive at that instant.
The GC does not bill you when you allocate. It bills you later, in pauses, and the invoice scales with how fast you allocate, not just how much you keep.
You do not have to guess at any of this. dotnet-counters will show you the shape live:
$ dotnet-counters monitor --process-id 4242 System.Runtime
Allocation Rate (B / 1 sec) 648,222,712
GC Heap Size (MB) 212
Gen 0 GC Count / 1 sec 9
Gen 1 GC Count / 1 sec 2
Gen 2 GC Count / 1 sec 1
% Time in GC since last GC (%) 11
Those numbers come from a deliberately allocation-happy demo service, and yours will differ. The shape is the tell: roughly 650 MB allocated per second, gen 2 collections arriving every second instead of every few minutes, and a double-digit percentage of CPU time spent collecting. A service can post numbers like these while every functional test stays green, which is the whole problem.
One paragraph on the knob everyone asks about. Workstation GC optimizes for footprint and for sharing a machine politely; Server GC, the default for ASP.NET Core workloads, gives each core its own heap segment and collects in parallel, trading memory for throughput and shorter pauses. It is worth knowing which one you are running, and it is almost never the fix. Changing GC mode moves the bill around. Changing the allocation rate shrinks it.
What to actually do about it
The lever is exactly the one the mental model predicts: allocate less on hot paths. Not everywhere, and not as a lifestyle. Find the paths that run per-request or per-item, profile what they allocate, and remove the churn that promotion turns into gen 2 debt. The everyday sources and their fixes get a full walkthrough in our .NET allocations article, and the Span and zero-allocation piece covers slicing data without copying it. The GC pause you never scheduled is downstream of allocations you never noticed, which makes the noticing the skill.
Seeing the invoice before production does
GC cost is the canonical invisible cost: the code is correct, the tests are green, and the sawtooth is waiting for traffic. Nothing in a diff shows you bytes per operation, so the habit of seeing allocations has to be trained against feedback that measures them.
Katabench trains it deliberately. Algorithm submissions are graded against
wall-clock and allocation budgets, so a solution that allocates inside its
hot loop fails honestly next to one that does not, with the byte counts on screen. The
algorithms track makes that comparison routine, and once it is routine,
new inside a loop starts to read the way an unindexed query reads to a database person:
probably fine, worth a look, and occasionally the entire incident. The generational hypothesis
says most objects die young. Your job is to stop paying for their funerals in gen 2.