Gen 1 as a buffer between short- and long-lived objects, Gen 1 GCs, objects that die soon after reaching Gen 2, and how to fix mid-life lifetimes.
Gen 1 is a small buffer generation. Objects that survive one Gen 0 GC (often just because they were in use at that moment, such as an in-flight request's objects) go to Gen 1, where they get a second chance to die cheaply in a Gen 1 GC instead of being promoted straight to Gen 2. The mid-life crisis is when objects live just long enough to be promoted to Gen 2 and then die soon after: they make Gen 2 grow, trigger expensive full GCs and fragment memory. Typical causes are buffering, slow requests, medium-lived caches and finalisable objects. Fix it by making lifetimes clearly short or clearly long.
"Gen 1 is a buffer between the very short-lived world of Gen 0 and the long-lived world of Gen 2. When a Gen 0 GC happens, some objects are alive only because the request using them is mid-flight. Gen 1 gives them a second, cheap chance to die before they're promoted to Gen 2, where only a full GC can reclaim them. The mid-life crisis is when objects survive long enough to reach Gen 2 anyway and then die soon after. Gen 2 grows, full GCs become frequent, and memory fragments. The fix is a design fix: make lifetimes clearly short, or clearly long."
A Gen 0 GC is a snapshot at an arbitrary moment. At that moment:
| Object | Truly long-lived? | Survives this Gen 0 GC? |
|---|---|---|
| Config loaded at startup | Yes | Yes (already in Gen 2) |
| In-flight request DTOs, buffers, state machines | No | Yes, because the request is still running |
| Strings built and discarded last millisecond | No | No |
Without Gen 1, the second row would go straight to Gen 2. With Gen 1, it waits in a small generation; by the next Gen 1 GC the request has usually finished and the objects die cheaply.
lifetime: |--Gen 0--|--Gen 1--|---------------- Gen 2 -------------------|
ideal: [dies here] [or lives forever ->]
problem: [reaches Gen 2 ... dies here]
^ only a full GC reclaims it
Objects that die in Gen 2 are the most expensive garbage in .NET: they grow Gen 2, raise its budget usage and force full GCs (or background GCs plus fragmentation).
| Cause | Why it creates mid-life objects |
|---|---|
| Slow requests (seconds) | Request objects survive two or more GCs during the request |
async methods waiting on slow I/O |
Locals used after an await are hoisted into the state machine on the heap and stay alive until the I/O completes |
Buffering whole results (ToListAsync() on big queries, building exports in memory) |
Every row survives for the whole operation |
| Batching / in-memory queues | Items wait across several GCs before processing |
| Medium-TTL caches (seconds to minutes) | Entries outlive Gen 1, then expire |
| Finalisable objects | Survive at least one extra GC by design, often promoted |
| Per-request objects captured by background work | A closure keeps the object graph alive after the response |
| Signal | Where |
|---|---|
| High Gen 1 survival / promoted bytes per Gen 1 GC | PerfView GCStats, dotnet-trace GC events |
| Gen 2 size sawtooth (rises, drops after each full GC) | dotnet-counters, GC.GetGCMemoryInfo() |
| Gen 2 GC count tracks business activity (exports, batches) | Dashboards correlating GC with traffic |
| Short-lived types (DTOs, rows) in Gen 2 in a heap snapshot | dotnet-gcdump |
The key distinction from a leak: a leak's Gen 2 baseline keeps rising after each full GC; mid-life crisis returns to roughly the same baseline.
| Goal | Technique |
|---|---|
| Make lifetimes short | Stream (IAsyncEnumerable<T>, PipeWriter, Utf8JsonWriter), smaller batches, shorter request paths |
Shorten what survives await |
Don't keep large objects in locals used after slow awaits; extract just the values you need |
| Make lifetimes long | Pool and reuse buffers so they settle in Gen 2 permanently instead of churning |
| Shrink cached data | Cache keys/IDs or compact structs, bound with SizeLimit |
| Avoid finalisers | SafeHandle, deterministic disposal, GC.SuppressFinalize |
| Don't capture request objects in background work | Copy the required data into a small message |
GC settings (GCgen1size, latency modes) are the last resort; they treat the symptom.
In our [reporting service], each CSV export of [millions of rows] built a List<Row> from EF Core and then wrote it out, taking [20 to 60 seconds]. Gen 2 showed a sawtooth and Gen 2 GCs fired [every few seconds] during exports, slowing every other endpoint on the instance. PerfView GCStats showed large promoted bytes on Gen 1 GCs, and a gcdump showed Row objects in Gen 2. I changed the export to stream: AsAsyncEnumerable() from EF Core, write each row to a PipeWriter, flush periodically. Each row now dies in Gen 0, Gen 2 GCs during exports dropped to [rare], and the neighbouring endpoints' p99 returned to normal.
The interviewer checks advanced GC understanding linked to design.
Green flags: Gen 1 as buffer, why in-flight objects survive, mid-life crisis causes and symptoms, survival and promotion metrics, PerfView GCStats, streaming and pooling fixes.
Red flags: treats Gen 1 as just another generation; blames GC settings; only looks for leaks.
For each generation the GC tracks survival rate = bytes surviving / bytes in the generation before the GC.
Gen 2 inflow per second ≈ allocation rate × Gen 0 survival × Gen 1 survival
Example: 500 MB/s allocation, 5% Gen 0 survival, 40% Gen 1 survival → 10 MB/s flowing into Gen 2. If those objects die minutes later, Gen 2 must be fully collected regularly just to keep up.
What to look at in a trace (dotnet-trace collect --profile gc-verbose, PerfView GCStats):
| Column | Meaning |
|---|---|
| Gen | Generation collected |
| Reason | AllocSmall, Induced, LowMemory, ... |
| Promoted MB | Bytes that survived and moved up |
| Gen 1 survival % | High values mean objects keep flowing into Gen 2 |
| Pause MSec | Pause time |
await creates mid-life objectspublic async Task<int> ExportAsync(Stream output)
{
List<Row> rows = await _db.Rows.ToListAsync(); // large
await WriteHeaderAsync(output); // slow network write
foreach (var r in rows) await WriteRowAsync(output, r);
return rows.Count;
}
The compiler rewrites this into a state machine struct. When the method first awaits something incomplete, the state machine is boxed onto the heap and every local used after an await (rows, output) becomes a field. As long as the I/O is pending, the whole rows graph is reachable from that box. If writes take seconds, the rows are promoted to Gen 2 mid-export.
Streaming version:
public async Task<int> ExportAsync(PipeWriter output, CancellationToken ct)
{
int count = 0;
await foreach (Row r in _db.Rows.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
WriteRow(output, r); // synchronous formatting into the pipe
if (++count % 1000 == 0)
await output.FlushAsync(ct);
}
await output.FlushAsync(ct);
return count;
}
Only the current row is alive at any time.
An object with a finaliser cannot be reclaimed in the GC that finds it dead. It is moved to the f-reachable queue, which is a root, so the object (and everything it references) survives and is promoted. Only after the finaliser thread runs it can a later GC reclaim it, often in Gen 2. That is why finalisable objects are a classic mid-life source.
Leak: Mid-life crisis:
Gen 2 after each full GC Gen 2 after each full GC
^ ___/ ^ /| /| /| /|
| ___/ | / | / | / | / |
| ___/ | / |/ |/ |/ |
+--------------> time +--------------> time
baseline keeps rising baseline stays flat, many full GCs
Take two gcdumps some minutes apart: in a leak the same types grow; in mid-life the types in Gen 2 change and dead objects are reclaimed.
With regions (.NET 7+), Gen 1 promotion can sometimes reassign whole regions to Gen 2, and freed Gen 2 regions return to the pool more easily. DATAS (default with Server GC in .NET 9+) adapts heap count to load. Neither changes the core problem: if objects die in Gen 2, someone has to run a Gen 2 GC to reclaim them.
| Need | Tool |
|---|---|
| GC rates and heap sizes live | dotnet-counters monitor System.Runtime |
| Per-GC promoted bytes and survival | PerfView GCStats from a dotnet-trace |
| What types are in Gen 2 | dotnet-gcdump collect then open in VS or PerfView |
| In-process telemetry | GC.GetGCMemoryInfo(GCKind.Ephemeral) → PromotedBytes, GenerationInfo |
| Version | Note |
|---|---|
| .NET Core 3.0 | IAsyncEnumerable<T> and await foreach make streaming easy |
| .NET 5 | GC.GetGCMemoryInfo(GCKind) overload with per-kind info |
| .NET 7 | Regions default; better decommit of freed Gen 2 space |
| .NET 9 / 10 | DATAS default for Server GC; mid-life objects still cost Gen 2 work |
await Task.Delay(50) inside the buffering loop to mimic slow I/O, and watch Gen 2 counts rise further.dotnet-trace of the buffering version and open it in PerfView GCStats; find Gen 1 GCs with large promoted MB.MemoryCache with a 30-second TTL holding 10 KB objects at 1,000 per second; watch the Gen 2 sawtooth with dotnet-counters.Run("Buffer everything", buffer: true);
Run("Stream items", buffer: false);
static void Run(string name, bool buffer)
{
GC.Collect(); // demo only: start from a clean state
int g1 = GC.CollectionCount(1), g2 = GC.CollectionCount(2);
long checksum = 0;
for (int batch = 0; batch < 50; batch++)
{
if (buffer)
{
// Holds 200,000 rows until the batch ends: rows survive several GCs
var rows = new List<Row>(200_000);
for (int i = 0; i < 200_000; i++) rows.Add(new Row(i, $"Item {i}"));
foreach (Row r in rows) checksum += r.Name.Length;
}
else
{
// Each row is used and dropped immediately: dies in Gen 0
for (int i = 0; i < 200_000; i++) checksum += new Row(i, $"Item {i}").Name.Length;
}
}
Console.WriteLine($"{name,-18} Gen 1 GCs: {GC.CollectionCount(1) - g1,4}, Gen 2 GCs: {GC.CollectionCount(2) - g2,3}, checksum {checksum}");
}
sealed record Row(int Id, string Name);
What happens: the team searches for a leak but finds no ever-growing type. Why it is wrong: objects that reach Gen 2 and then die also grow Gen 2 and drive full GCs. Do instead: check promotion and survival rates and the shape of the Gen 2 graph.
What happens: ToListAsync() on a large query, then write the response.
Why it is wrong: every row survives for the full operation and is promoted.
Do instead: stream with IAsyncEnumerable<T> / AsAsyncEnumerable() and write incrementally.
awaitsWhat happens: a big list is loaded, then the method awaits slow network writes while still holding it. Why it is wrong: hoisted locals keep the graph alive in the heap-allocated state machine. Do instead: process and release early; extract only needed values before awaiting.
What happens: a 1–5 minute cache of full entity graphs.
Why it is wrong: entries reach Gen 2 and then expire, a textbook mid-life pattern.
Do instead: cache small values, set SizeLimit, or make the cache long-lived and stable.
What happens: every disposable class gets a ~Finalizer().
Why it is wrong: finalisable objects always survive at least one extra GC and are promoted.
Do instead: only finalise via SafeHandle for raw native resources; dispose deterministically.
What happens: _ = Task.Run(() => Audit(httpContext.Request, order)).
Why it is wrong: the whole request graph lives until the background task finishes (and HttpContext use after the request is invalid anyway).
Do instead: copy only the data you need into a small message, ideally via a queue/channel.
What happens: a producer outruns the consumer; items wait for seconds.
Why it is wrong: queued items get promoted, Gen 2 balloons.
Do instead: bounded Channel<T> with backpressure; smaller batches.
What happens: changing GCgen1size, latency modes or Server/Workstation to "fix" full GCs.
Why it is wrong: the objects still die in Gen 2; settings shift cost around.
Do instead: fix lifetimes; tune settings only with evidence.
What happens: buffers are pooled but often dropped (not returned) or the pool is unbounded and trimmed.
Why it is wrong: pooled objects become another mid-life population.
Do instead: consistent try/finally return; size pools to steady-state need.
What happens: one gcdump is taken and conclusions drawn. Why it is wrong: a single snapshot cannot show whether Gen 2 objects are permanent or transient. Do instead: compare two or more snapshots and the Gen 2 baseline after full GCs.
Mid-life crisis is one of the most common causes of "mysterious" Gen 2 GCs in services.
| Pattern | Better design |
|---|---|
| Export builds a full list | Stream to the response |
| Message batching in memory for seconds | Smaller batches or channels with backpressure |
| Per-request objects captured in background tasks | Copy only the data needed |
| Disposable objects with finalisers | Deterministic disposal, SafeHandle |
Seniors explain GC behaviour in terms of object lifetimes, which leads to architectural fixes rather than GC flags.
A Gen 2 GC collects the whole managed heap: Gen 0, Gen 1, Gen 2, the LOH and the POH. It is triggered when Gen 2's budget is exceeded by promotions, when large object allocations exceed the LOH budget, under memory pressure (approaching the container or GCHeapHardLimit), or by code calling GC.Collect(). Most Gen 2 GCs run as background GCs that let threads keep running; blocking full GCs (with compaction) pause everything. To investigate frequent Gen 2 GCs, check the GC reason (dotnet-trace or PerfView GCStats), then the cause: LOH churn, mid-life objects, induced collections, or memory limit pressure.
The .NET GC divides the managed heap into Gen 0 (new objects), Gen 1 (a buffer for objects that survived one GC) and Gen 2 (long-lived objects, plus the LOH and POH logically). It relies on the generational hypothesis: most objects die young. A Gen 0 GC only examines young objects, so its cost is proportional to the few survivors, not to the whole heap. Survivors are promoted to the next generation. References from old objects to young ones are tracked by a write barrier and card table so a young GC does not have to scan Gen 2. Collecting generation N also collects all younger ones. Since .NET 7 the heap is organised in regions rather than large segments.
How to tell it (STAR)
Our [reporting service] held whole exports in memory, so rows lived long enough to reach Gen 2. I showed this with PerfView and switched to streaming. Gen 2 GCs during exports dropped from [every few seconds] to [rare], and other endpoints stopped slowing down.
Last updated 31 Oct 2026.