Runtimegcruntimepausesallocationtail latency

Garbage Collection: Pause, Throughput, Footprint — Pick Two

A collector trades pause time against throughput against memory footprint, and no tuning flag escapes the triangle. The lever you actually control is not the collector — it is how much garbage your code produces per request.

▶ Run the labFollow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
My p99 has periodic spikes that no downstream service explains — is the collector pausing my process, and what do I do about it?
Symptom
p50 latency is flat and healthy. p99 shows regular spikes of tens or hundreds of milliseconds, on a rhythm rather than at random, and no trace span accounts for the gap.
Signal
GC pause duration and frequency correlated with the latency spikes confirms it; a trace waterfall showing an unexplained *gap* between spans is the giveaway. Average latency misleads completely — GC pauses live entirely in the tail.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

The triangle every collector negotiates

Runtime-specific · General across tracing collectors (JVM G1/ZGC, Go, .NET, V8); reference-counted runtimes such as CPython behave differently — see the note below.

A garbage collector must find unreachable objects and reclaim them. Every design decision about *how* trades three quantities against each other: how long the application is stopped (pause time), how much total CPU is spent collecting rather than serving (throughput), and how much memory the heap occupies above the live set (footprint).

The trades are concrete. Collecting concurrently with the application shrinks pauses but costs throughput, because the collector must coordinate with a mutating heap and do extra bookkeeping on writes. Collecting less often improves throughput but raises footprint and makes each collection longer. Giving the heap more headroom reduces collection frequency and raises memory cost. There is no configuration that improves all three, which is why "GC tuning" that produces a dramatic win in one dimension usually paid for it in another that nobody measured.

Not every runtime plays the same game. CPython uses reference counting for the common case with a cycle collector on top, so it has no large stop-the-world pause but pays a continuous per-operation cost and struggles with reference cycles — see CPython Performance: The Interpreter Tax and the GIL. C++ has no collector at all, moving the cost to deterministic destruction and allocator behaviour (C++ Memory Performance: Allocation, Copies and Locality). Claims about "GC pauses" are claims about a particular runtime, and should be labelled as such.

The three quantities, and what moving one does to the others
If you want…Typical mechanismWhat it costsWatch this signal
Shorter pausesConcurrent / incremental collection, smaller regionsTotal CPU spent on GC rises; write barriers slow the applicationGC CPU %, throughput at fixed load
Higher throughputCollect less often, larger young generationLonger individual pauses; higher footprintp99 latency, pause duration histogram
Smaller footprintCollect more often, tighter heap targetMore GC CPU; more frequent pausesGC frequency, allocation rate
All threeAllocate less per requestEngineering effort in application codeAllocation rate (bytes/s) — the lever you own

Allocation pressure is the lever you actually control

Runtime-specific · Applies to any allocating runtime; the specific collector determines how the cost manifests.

Collector flags get the attention, but the input to the whole system is allocation rate. A service allocating 50 MB/s and one allocating 500 MB/s on the same collector and heap configuration have completely different GC behaviour: the second collects ten times as often, spends roughly ten times the CPU on collection, and produces ten times as many opportunities to pause during a request.

This makes allocation profiling the highest-leverage measurement here, not GC log analysis. GC logs tell you the collector is working hard; an allocation profile tells you *which code path* is making it work hard, which is the actionable version (Allocation Rate Is a Cost Even Without a Leak). The usual culprits are boring: string concatenation in loops, JSON serialization of large payloads, defensive copying of collections, boxing in hot paths, and per-request object graphs that could be reused.

The connection to Payload Size: 20KB, 200KB, 5MB is worth naming: an endpoint returning a 5 MB response allocates that payload at least once and often several times through serialization. Trimming the response is simultaneously a network fix, a serialization-CPU fix and a GC-pressure fix. The same change shows up in three different dashboards, which is a good sign you have found a real cause rather than a symptom.

Same service, same collector, before and after a serialization fixILLUSTRATIVE
SignalValueWhat it tells youVerdict
allocation rate480 MB/s → 60 MB/sThe input to everything else. Cutting it is what actually changed the picture.smoking gun
GC frequency14/s → 2/sFell roughly in proportion to allocation rate, as expected.normal
GC CPU22% → 4%A fifth of the machine was being spent collecting garbage the code did not need to create.normal
p50 latency38 ms → 36 msBarely moved — which is why nobody noticed the problem from the average.normal
p99 latency410 ms → 74 msThe entire benefit lived in the tail, where the pauses were.smoking gun
heap live set1.2 GB → 1.2 GBUnchanged — this was garbage churn, not a leak. Different problem, different fix (Memory Leaks: Growth That Does Not Come Back).normal

Finding the pause in a trace

Runtime-specific · Any stop-the-world collector (JVM, Go, .NET, V8); the gap signature does not appear in reference-counted or unmanaged runtimes.

GC pauses are invisible to application instrumentation by construction: the application is stopped, so it cannot record anything. What you see in a trace is a *gap* — a parent span whose duration exceeds the sum of its children with nothing accounting for the difference. That unexplained gap is the fingerprint, and it is the reason a waterfall is more useful here than a metric.

Confirming it takes one correlation: overlay GC pause events on the latency timeline and check whether the slow requests coincide with collections. Coincidence is not proof — a periodic batch job could produce the same rhythm — but combined with an allocation profile pointing at a hot path, it is usually enough to act on. Keep Correlation Is Not the Root Cause in mind: the honest statement is "requests overlapping a collection pause are slow", and the test is whether reducing allocation reduces the spikes.

One practical note on tail sampling: if your tracing samples traces at a fixed low rate, you will rarely capture the affected requests, because GC-affected requests are by definition a small fraction. Tail-based sampling that keeps slow traces is what makes this diagnosable at all (Sampling Without Throwing Away the Evidence).

The span that does not add up — 180ms unaccounted for
critical pathILLUSTRATIVE
0105210315420
POST /orders (handler)420 ms
auth check8 ms
db insert46 ms
⟨ unaccounted gap — process stopped ⟩180 ms
serialize response178 ms
POST /orders (handler)Children sum to 240ms. The other 180ms is not in any span.
⟨ unaccounted gap — process stopped ⟩No instrumentation ran here. Overlay GC events to confirm a collection.
serialize responseAlso the biggest allocator in the request — the likely cause of the pause it sits next to.

Key points

  • Pause time, throughput and footprint form a triangle; every collector setting trades one against the others, and no flag wins all three.
  • Allocation rate is the input you control — halving it roughly halves collection frequency and GC CPU on the same configuration.
  • GC pauses live entirely in the tail: p50 barely moves while p99 moves a lot, so averages hide the problem completely.
  • In a trace, a GC pause appears as an unexplained gap where children do not sum to the parent, because the process was stopped.
  • Claims about GC behaviour are runtime-specific — reference-counted CPython and unmanaged C++ do not have the same failure mode at all.

Progressive depth

Overview

Managed runtimes reclaim memory automatically. Doing so requires periodically finding what is unreachable, and that work has to happen somewhere — sometimes while your request is waiting.

Practical

Watch allocation rate, GC frequency, pause duration histogram and GC CPU. Pauses show up as p99 spikes with a flat p50, and as unexplained gaps in traces. Reduce allocation in the hot path before touching any flag.

Advanced

The pause/throughput/footprint triangle governs every collector choice. Generational collection exploits the observation that most objects die young, so a cheap young-generation pass reclaims most garbage; objects surviving several passes get promoted, and promotion rate is a better early-warning signal than heap size. Concurrent collectors trade write-barrier overhead on every mutation for shorter stop-the-world phases.

Internals

Tracing collectors mark from a root set (stacks, globals, registers) and must observe a consistent heap, which is why some phase stops the application — reaching a safepoint where every thread can be inspected is itself a source of pause time, and a thread in a long uninterruptible loop delays everyone. Compaction moves surviving objects to eliminate fragmentation, which requires updating every reference and is where large pauses concentrate. This connects to Reading Memory: RSS, Heap, Working Set and the Number on Your Dashboard and to OS-level paging: a heap larger than physical memory turns collection into a paging storm, because marking touches pages across the whole heap in an access pattern with no locality.

Allocation Pressure and Pauses

Change an input and watch which number moves — and which one does not.

Allocation pressure decides how often you pay
ESTIMATED

Modelled on a generational collector where collection frequency follows allocation rate and pause length grows with heap size. Real collectors differ substantially — concurrent and region-based collectors trade throughput for shorter pauses, and some do most of the work off the application thread. The relationship, not the numbers, is the lesson.

collections
14.6/min
pause each
19.5 ms
time paused
0.48%

Comfortable. Collection is not your problem at this allocation rate, and tuning the collector would be premature.

Follow the diagnosis

The causal chain, hop by hop — and the readings that invite the wrong conclusion.

  1. 1
    Request handler → allocator: response serialization allocates several megabytes per request, driving allocation rate to 480 MB/s.
  2. 2
    Allocator → collector: the young generation fills roughly 14 times per second, so collections run at that frequency.
  3. 3
    Collector → process: each collection stops the application briefly; at 14/s a meaningful fraction of in-flight requests overlaps a pause.
  4. 4
    Pause → trace: affected requests show a parent span 180ms longer than the sum of its children, with no span covering the gap.
  5. 5
    Trace → user: p50 is unaffected because most requests miss the pauses, while p99 reaches 410ms because the unlucky ones absorb them.
What this evidence makes people conclude — wrongly
  • "Average latency is fine, so the runtime is fine" — GC pauses are a tail phenomenon and the mean is designed to hide them.
  • "Memory is growing, this is a leak" — check the live set *after* collection; high churn with a flat live set is allocation pressure, not a leak.
  • "Tune the collector flags" — flags redistribute cost around the triangle; only lower allocation reduces total work.
  • "The gap in the trace is a missing instrumentation span" — sometimes, but an unexplained gap correlating with collection events is the classic pause fingerprint.
  • "We switched to a low-pause collector, problem solved" — shorter pauses usually mean more GC CPU, which shows up as reduced throughput at the same load.

Measure, fix, validate

An optimization is not finished until the metric that motivated it has moved.

How to measure it
  • • Allocation rate in bytes/s, and an allocation profile attributing it to call sites — this is the actionable measurement, not the GC log.
  • • GC pause duration as a histogram (not an average) plus collection frequency, both correlated against the latency timeline.
  • • GC CPU as a percentage of total process CPU, which quantifies what collection is costing you in capacity terms.
  • • Heap live set after collection, to separate garbage churn (live set flat) from a genuine leak (live set climbing — see [[memory-leaks]]).
  • • p50 against p99 for the affected endpoint, since the entire effect is in the difference between them.
What actually fixes it
  • • Reduce allocation in the hottest paths identified by an allocation profile — smaller payloads, reused buffers, streaming instead of materializing, fewer defensive copies.
  • • Trim response sizes and avoid serializing data nobody reads, which cuts allocation, CPU and network in one change ([[payload-size]]).
  • • Give the heap headroom if footprint is cheap for you: fewer collections at the cost of memory is often the best available trade for a latency-sensitive service.
  • • Consider a lower-pause collector configuration only after allocation work is done, and measure throughput at fixed load to see what it cost.
  • • Enable tail-based trace sampling so the affected requests are actually captured for diagnosis.
How you know it worked
  • • Allocation rate falls and GC frequency falls roughly in proportion — if frequency does not follow, the allocation you removed was not the dominant source.
  • • p99 latency falls materially while p50 is roughly unchanged; that asymmetry is the signature of a genuine GC fix.
  • • GC CPU percentage drops, confirming reclaimed capacity rather than merely redistributed pauses.
  • • Throughput at fixed load holds or improves — a collector change that reduced pauses but cut throughput has moved the cost, not removed it.
What it costs
  • • Larger heaps reduce collection frequency and increase memory cost per instance, which changes instance sizing and therefore bill.
  • • Low-pause collectors typically cost throughput via write barriers and concurrent work — you serve fewer requests per core for smoother tails.
  • • Object reuse and buffer pooling reduce allocation and introduce lifetime bugs, including use-after-return and cross-request data leakage, which is a correctness and security risk.
  • • Tail-based sampling costs more at the collector, since traces must be buffered until the decision can be made.
Stop it coming back
  • Allocation rate tracked as a first-class metric with an alert on sustained increase, since allocation regressions arrive with ordinary feature work.
  • A p99 (not average) latency SLO on the affected endpoints, which is the only threshold that can see this class of problem.
  • A load test in CI that asserts allocation per request stays under a budget, so a new serialization path cannot silently reintroduce the pressure.
  • GC pause histograms retained across deploys, so a collector or runtime version change is visible as a shape change rather than discovered in an incident.

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • RUNTIME-SPECIFICPause behaviour, generational layout and tuning knobs differ substantially between the JVM, Go, .NET and V8, and change between major versions. Treat the triangle as the transferable idea and verify specifics against your runtime's documentation.
  • ILLUSTRATIVEThe 480 MB/s → 60 MB/s figures and the 410ms p99 are constructed to show the shape of the improvement. The proportional relationship between allocation rate and collection frequency is general; the magnitudes are not.

Misconceptions

Claim
“GC tuning flags are where the wins are.”
Reality
Flags move cost around the pause/throughput/footprint triangle. Allocation rate is the input to the whole system, and halving it improves all three simultaneously — which no flag can do.
Claim
“Rising memory during load means a leak.”
Reality
High allocation churn raises memory usage while the live set after collection stays flat. A leak shows a climbing post-collection live set. The two look identical on a naive memory graph and have completely different fixes.
Claim
“A low-pause collector is strictly better.”
Reality
It buys shorter pauses with more total GC CPU and write-barrier overhead, so the same hardware serves less traffic. That is often the right trade for latency-sensitive services and the wrong one for batch throughput.

Apply it

Where the depth lives

Operating Systems
Paging and the page cache

A heap that exceeds physical memory turns every marking pass into a storm of page faults, which is why GC and swap interact so badly.