The garbage collector: why pauses do not grow with the heap, and GOGC speeds up nothing
Go's collector is concurrent and non-moving, and everything else follows from that: pauses are short and independent of heap size, and GOGC is not an accelerator but a knob that trades time for memory. Measured: the heap grew sixteenfold while the median pause grew 1.22×, and even that difference is smaller than the spread between two repeats of the same measurement; GOGC=400 gives four times fewer cycles and a two-and-a-half times larger peak.
Full technical treatment
TL;DR
The collector gives back the memory of objects the program can no longer reach. "Can no longer reach" does not mean "no longer wants" — it means unreachable by references from the roots: the collector counts reachability, not usefulness. Two things follow at once: a forgotten reference to an unwanted object is what a leak in Go is, and the collector's work is proportional to the number of live objects rather than of garbage.
The main consequence breaks the intuition: the pause does not grow with the heap. Measured: live data 16 → 256 MB, the median pause 39 → 48 µs, that is 1.22× for a sixteenfold heap — and even that difference is smaller than the gap between two repeats of one and the same measurement. The program is stopped twice per cycle and briefly; everything else runs alongside it. What does grow with the heap is the work of marking — and it is constantly confused with the pause, after which people tune a quantity that never changed.
Beyond that: the machinery and the knobs, both tied to a Go version. The
current collector is concurrent, tri-colour mark-and-sweep, non-moving and
non-generational; those are properties of an implementation, not promises of the
language. GOGC speeds up nothing: it is a trade knob — GOGC=400 instead of
100 gave four times fewer cycles and a two-and-a-half times larger peak
heap on the very same work. The cycle target is computed from the live data
(GOGC=100 means double, GOGC=400 five times), so GOGC has no ceiling;
the ceiling is GOMEMLIMIT, and it is soft: live data that outgrows the
limit produces not a crash but continuous collection. And the collector does not
return memory to the OS immediately — RSS larger than HeapAlloc is not
evidence of a leak.
- a running program asks for memory to hold new values, and that memory comes from somewhere;
- some values are needed for a long time and some for a few lines, and that is not written down anywhere in advance;
- values refer to one another: one holds a reference to another.
- tri-colour marking, the write barrier, stopping the world, the phases of a collection cycle;
GOGC,GOMEMLIMIT,HeapAlloc,RSS,GODEBUG=gctrace=1.
What is really being asked
There are almost always three questions, and they escalate:
- "What kind of collector does Go have?" — they are checking whether you say the word "concurrent" rather than only "mark and sweep".
- "How long are the pauses and what do they depend on?" — this is where most people break: the pause gets confused with the cost of collection.
- "How do you tune it?" — and the right answer starts with "usually you don't", then goes on to what each knob actually charges you.
The topic has a single spine: Go's collector is optimised for latency, not for throughput. It is willing to spend more CPU time as long as it does not stop the program for long. Everything else is a consequence.
Base: what the collector actually does
Before counting pauses and turning knobs it is worth naming the problem. It is short and contains no Go terminology at all:
Find the objects the program can no longer reach, and give their memory back.
"Can no longer reach" is the key phrase — and it means not "will not use" but unreachable by references:
The collector does not know whether an object is "needed". It only knows whether there is a path to it from the roots. Two consequences follow immediately and explain everything after: an object with one forgotten reference lives forever (that is what a leak in Go is), and the collector's work is proportional to the number of LIVE objects, not of garbage — it never touches the garbage at all.
Notice what the statement does not contain. Not a word about when the collector arrives, how long it works, or whether it stops the program. Those are all questions about machinery, and different collectors — and different versions of one collector — answer them differently.
That is already enough to answer the basic interview question: the collector looks for the unreachable and gives its memory back, rather than tracking what the program "might still want". Everything below is about how it manages to do that while barely stopping the program, and what that costs.
Mechanism 1: a whole collection cycle
Now for that machinery — how the collector walks everything reachable without taking the CPU away from the program for long. A cycle has four phases, and the world stops in only two of them, briefly:
▐ mark setup world stopped, microseconds
█████████ concurrent marking the program RUNS, the walk goes in parallel
▐ mark termination world stopped, microseconds
█████████ sweeping the program runs, memory is returned
Everything the lesson goes on to check by measurement follows from this picture: the pauses are the two narrow strips, the work is the two wide ones, and they grow differently.
For marking to run alongside the program a write barrier is needed: while marking is under way every pointer write goes through it, and the barrier stops the program hiding a not-yet-visited object from the collector. That is the tax of concurrency — a standing surcharge on CPU time in exchange for a short pause.
Mechanism 2: what does not grow with the heap
The central claim of the topic is worth checking first:
A run of bench/gogc/internals.go prints:
| live data | pause, median | 90th percentile |
|---|---|---|
| 16 MB | 39 µs | 66 |
| 64 MB | 41 µs | 73 |
| 256 MB | 48 µs | 107 |
| 16 MB — repeat | 54 µs | — |
The heap grew sixteenfold — the pause did not: ×1.22 against ×16. This is the very property the collector is shaped for: it runs alongside the program and stops it twice per cycle, briefly — to set marking up and to finish it.
It is worth saying separately what exactly was measured. PauseNs from
MemStats is the time the world is stopped, not the duration of a cycle. The
duration of a cycle does grow with the heap — but the program keeps working
through it.
And a caveat about the honesty of the numbers: the measurement ran on two cores and on one particular version of the runtime, so the absolute microseconds mean nothing. Only one thing matters here — that the column does not grow. And that property itself belongs to an implementation rather than to the language: the collector has changed from version to version and will change again, which is why both its behaviour and the number of threads allowed to execute code are quoted together with a Go version rather than as a universal law.
The last row of the table is not a typo but the resolution of the instrument. It is the same measurement at the same 16 MB, taken once more: 54 µs against 39. The gap between two repeats of ONE size turned out to be larger than the difference between 16 and 256 MB — which means the direction of change cannot be discussed from this table at all. It answers exactly one question: does the pause scale with the heap. The answer is no: heap ×16, pause ×1.22.
That row did not appear at once, and it is worth saying why. The measurement printed a median of eighteen cycles, and three consecutive runs on this machine were enough to get 55, 71 and 37 µs at 16 MB: a spread larger than the distance between the rows of the table. While no repeat stood beside them, a direction was easy to read out of such numbers — and it was read wrongly. The sample is now two hundred cycles, the repeat is printed alongside, and the run itself prints whether the difference between rows is resolvable by its own instrument.
Before the median, this measurement printed the maximum, and it gave 755 µs at 16 MB against 34 at 256 — a table from which it would follow that the pause falls as the heap grows. The cause turned out not to be the collector: the maximum of twelve samples on a two-core machine is the worst other process on it. The median answers the question that was asked; the maximum answers a different one.
So what does grow? The work of marking: the more live objects there are, the more pointers must be walked. That shows up in the share of CPU time, not in the pause. And confusing the two is expensive — the program gets tuned for a quantity that never changed.
Mechanism 3: GOGC — not an accelerator but a trade knob
The guide defines it by a formula, and that matters: there is no word "memory" in it, there is a ratio.
The GOGC parameter determines the target heap size after each GC cycle: Target
heap memory = Live heap + (Live heap + GC roots) * GOGC / 100.
The run confirms the arithmetic and shows the price:
| GOGC | target / live | cycles | peak heap |
|---|---|---|---|
| 50 | ×1.51 | 24 | 24.3 MB |
| 100 | ×2.01 | 12 | 31.8 MB |
| 200 | ×3.02 | 6 | 47.5 MB |
| 400 | ×5.04 | 3 | 78.9 MB |
The table has to be read across two columns at once. The work is the same in
every row — two hundred thousand kilobyte allocations against sixteen megabytes
of live data. At GOGC=400 there are eight times fewer cycles than at 50 — and
a peak heap three times higher.
That is not a speed-up but a transfer of the cost from time to memory. Hence
the rule for choosing: raise GOGC when memory is plentiful and a profile shows
time going into collection; lower it when memory is tight. There is no "good
default" here; there is only whichever resource the system has more of.
And here is what GOGC does not have: a ceiling. The target is computed
from the live data, so as the live set grows, so does the target. A program
with a growing cache will eat all the memory without ever violating the setting.
Mechanism 4: GOMEMLIMIT — the ceiling, and why it is soft
The measurement shows the difference directly:
| target of the next cycle | |
|---|---|
GOGC=100, 16 MB live | 32.3 MB |
GOGC=100, 64 MB live | 128.3 MB |
| limit 96 MB, 16 MB live | 32.3 MB |
| limit 96 MB, 64 MB live | 88.4 MB |
The second row is that missing ceiling. The fourth is what GOMEMLIMIT adds: the
target stops rising above the limit, and collection starts running more often to
keep the program under it.
The word "soft" in the documentation is not there for politeness:
the memory limit is a soft limit… the Go runtime makes no guarantees that it
will maintain this memory limit under all circumstances; it only promises some
reasonable amount of effort.
If the live data itself outgrows the limit, the runtime will not kill the program — it will collect almost continuously. The guide calls this a death spiral. The symptom is characteristic and deceptive: the process is alive, the memory metrics look fine, and no useful work is being done.
Two practical recommendations follow, and they are the ones interviewers want to
hear. Set the limit below the container's real boundary — otherwise, instead
of an honest OOM you get a process that is alive by its metrics and doing
nothing. And do not set GOMEMLIMIT in place of GOGC: they do not replace
each other. GOGC governs the normal regime, GOMEMLIMIT insures the edge.
Deeper: why the collector is the way it is
Three decisions, asked about at the more senior levels.
Non-generational. The generational hypothesis ("most objects die young") does hold in Go — but its benefit has already been collected by escape analysis: short-lived objects mostly never reach the heap at all, they stay on the stack. A young generation would be half empty, while its barriers would cost on every pointer write.
Non-moving. Objects do not move, so addresses are stable — which makes
interoperating with C cheap and allows unsafe.Pointer to exist at all. The
price is fragmentation, handled by a size-class allocator instead of by
compaction.
Concurrent, with a write barrier. Marking runs alongside the program, and so that the program cannot hide an object from it, every pointer write during marking goes through a barrier. That is the collector's standing tax on CPU time — the same trade of more CPU for a shorter pause.
And the thing mistaken for a leak. The collector does not return freed memory to the OS immediately: pages go back gradually, in the background.
Four quantities that get merged into one, after which people argue about the readings:
| quantity | what it is | where to look |
|---|---|---|
HeapAlloc | held by live objects right now | MemStats |
NextGC | the target of the next cycle | MemStats |
HeapReleased | pages returned to the OS | MemStats |
RSS | what the process holds as the OS sees it | top, cgroup |
RSS is always larger than HeapAlloc, and that is normal: it includes pages
taken but not yet returned, goroutine stacks, allocator metadata and the code. A
leak looks different — HeapAlloc grows after a full collection.
And the link to the previous lesson. Fewer heap allocations means fewer live objects means less work for marking. But that is not an optimisation rule: escape analysis has already filtered out most of the short-lived, and rewriting code for the stack almost always makes it longer and more brittle. Profile first, then decide — the same order as in the escape-analysis lesson.
How to answer in an interview
The short answer: the collector gives back the memory of objects with no path left to them from the roots, and it does that alongside the program — stopping it twice per cycle and briefly. Which is why the pause is short and does not depend on heap size: 16 and 256 megabytes of live data gave me 39 and 48 microseconds — 1.22× on a sixteenfold heap. What grows is not the pause but the work of marking — the share of CPU time, and that is exactly what gets confused with the pause.
That is enough to answer correctly. Beyond it is what you add when the interviewer digs.
If the interviewer digs deeper
Name the properties rather than the acronym — and tie them to a version. "In current versions of Go it is a concurrent tri-colour mark-and-sweep collector, non-moving and non-generational. Optimised for latency: willing to spend more CPU so as not to stop the program for long. The language promises none of that — they are properties of the implementation."
On GOGC say the word 'trade'. "It sets how far the heap may grow beyond the
live data. GOGC=400 gave me four times fewer cycles and a two-and-a-half times
larger peak heap. Collection does not get cheaper."
Name what GOGC lacks. "A ceiling. The target is computed from the live
data, so a growing cache will eat the memory without violating the setting. The
ceiling is GOMEMLIMIT, and it is soft."
And finish with what not to do. "Turning knobs without a profile is not worth it: on ordinary workloads the collector costs single-digit percentages of CPU. Removing allocations is cheaper than tuning their cleanup."
And one caveat that is easy to overdo: the numbers here are not constants of the
language. The pause microseconds were taken on one runtime version and on two
executing threads, and on another machine they will be different. The precise
wording is this: what you memorise is not the values but which quantity depends
on what — the pause does not depend on heap size, the work of marking does, and
GOGC is paid for in memory.
Next they ask
What is tri-colour marking and why is a write barrier needed?
Objects are split into three sets: white (not yet examined), grey (found, but whose references have not been walked) and black (fully walked). The cycle ends when no grey objects remain; everything still white is garbage.
The problem is that the program runs at the same time as marking and can move a pointer so that a white object becomes reachable only from a black one — and black objects are never revisited. The write barrier catches such writes and shades the object grey. That is the work you pay for concurrency with.
Why does runtime.GC() exist if the collector runs on its own?
It is almost never needed, and that is the answer. A manual call stops the world and performs a full collection in one go — that is, it gives up exactly the advantage the collector was made concurrent for.
There are two legitimate cases: measurements that need a reproducible starting
point, and the moment when a program knowingly moves from a heavy phase into
idleness — after a one-off data load, say. In ordinary code runtime.GC() is
almost always an attempt to treat a symptom.
How do you tell that a program is spending its time collecting?
You start not with a profile but with GODEBUG=gctrace=1: the runtime prints a
line per cycle — how long it took, how large the heap was, how much the phases
cost. If the cycles are many and dense, you go to pprof next, but already
knowing what to look for.
The number "what share of CPU did the collector eat" comes from
MemStats.GCCPUFraction. It has a catch worth mentioning: it is a cumulative
value, counted from the start of the program, so it cannot be used to compare two
regimes inside one process.
Why is a process's RSS larger than what HeapAlloc reports?
Because they are different quantities. HeapAlloc is the objects currently in
use; RSS also includes pages the runtime has already taken from the OS but
not yet returned, goroutine stacks, allocator metadata and the code.
Freed pages go back to the OS gradually, in the background, and that is a
deliberate decision: returning a page is cheap, getting it back is expensive. So
"the memory is not being freed" almost always means exactly this rather than a
leak. A real leak looks different: HeapAlloc grows after a full collection.
Can finalizers be relied on?
No, and this should be said firmly. runtime.SetFinalizer gives no guarantees at
all: a finalizer may never run (the program exited first), it runs at an unknown
moment and in a separate goroutine, and any reference from the finalizer back to
the object resurrects it for another cycle.
Resources are released by defer and Close(), not by the collector. There is
one legitimate use for a finalizer — as a safety net for a forgotten Close
that writes a warning to the log; that is how the standard library uses it for
files.
What changes if you set GOGC=off?
There will be no cycles at all — the measurement confirms it — and the heap will grow until memory runs out. There are two sensible uses, and both are narrow: short-lived batch programs for which dying is simpler than tidying, and measurements where collection interferes with what is being measured.
More importantly, this is not an optimisation for a service. Even with plenty of memory, a disabled collector means every page stays warm and cache misses go up. And in a container with a limit it is simply a deferred OOM.
Common misconceptions
the bigger the heap, the longer the pauses
The measurement shows the opposite: 16 and 256 MB of live data gave a median pause of 39 and 48 µs — 1.22× on a heap that grew sixteenfold. A repeat of the same measurement at 16 MB gave 54 µs, more than either row, so even that growth is not resolvable by the instrument. What grows is not the pause but the work of marking — the share of CPU time. The two get confused, and then the program is tuned for a quantity that never changed.
a higher GOGC makes the program faster
It is a trade, not a speed-up. On the same work, GOGC=400 against 100 gave four times fewer cycles and a two-and-a-half times larger peak heap. Collection does not get cheaper — the cost moves from time to memory.
GOGC limits how much memory a program will use
There is no ceiling in GOGC: the target is computed from the live data by the guide's formula, so as the live set grows, so does the target. A program with a growing cache will eat all the memory without violating the setting. The ceiling is set separately, with GOMEMLIMIT.
GOMEMLIMIT guarantees the program will not exceed the limit
The limit is soft: the documentation promises "reasonable effort", not a guarantee. If the live data itself outgrows the limit, the runtime does not crash — it collects almost continuously, which the guide calls a death spiral. The process is alive, the metrics look fine, and no work is being done.
the collector stops the program for the duration of a collection
It stops the program twice per cycle and briefly — to set marking up and to finish it — and does everything else alongside it. Which is why the duration of a cycle and the pause are different quantities: the first grows with the heap, the second does not.
Go has a generational collector, like the JVM
It is non-generational, and that is a deliberate choice. The benefit of the generational hypothesis has already been collected in Go by escape analysis: short-lived objects mostly never reach the heap. A young generation would be half empty, while its barriers would cost on every pointer write.
memory is not being released, so there is a leak
The collector does not return pages to the OS immediately: they go back gradually, in the background, because returning a page is cheap and getting it back is expensive. Hence RSS larger than HeapAlloc. A leak looks different: HeapAlloc grows after a full collection.
runtime.GC() helps when memory is short
A manual call stops the world and performs a full collection in one go — giving up exactly what the collector was made concurrent for. There are two legitimate cases: measurements, and a program moving from a heavy phase into idleness. In ordinary code it treats a symptom.
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer comes from a run of the script, not from an assertion.
Practice · predict the output
fmt.Println(debug.SetGCPercent(100)) fmt.Println(round(goalOverLive(100))) fmt.Println(round(goalOverLive(400))) fmt.Println(cyclesFor(-1)) debug.SetGCPercent(-1) var a runtime.MemStats runtime.ReadMemStats(&a) runtime.GC() runtime.GC() var b runtime.MemStats runtime.ReadMemStats(&b) fmt.Println(b.NumGC - a.NumGC)
Practice · estimate
Knowledge check
A program's live data grew from 16 to 256 MB. What happens to the collection pauses?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- The collector gives back the memory of objects the program can no longer reach. "Can no longer reach" does not mean "no longer wants" — it means unreachable by references from the roots: the collector counts reachability, not usefulness. Two things follow at once: a forgotten reference to an unwanted object is what a leak in Go is, and the collector's work is proportional to the number of live objects rather than of garbage.
- The main consequence breaks the intuition: the pause does not grow with the heap. Measured: live data 16 → 256 MB, the median pause 39 → 48 µs, that is 1.22× for a sixteenfold heap — and even that difference is smaller than the gap between two repeats of one and the same measurement. The program is stopped twice per cycle and briefly; everything else runs alongside it. What does grow with the heap is the work of marking — and it is constantly confused with the pause, after which people tune a quantity that never changed.
- Beyond that: the machinery and the knobs, both tied to a Go version. The current collector is concurrent, tri-colour mark-and-sweep, non-moving and non-generational; those are properties of an implementation, not promises of the language.
GOGCspeeds up nothing: it is a trade knob —GOGC=400instead of100gave four times fewer cycles and a two-and-a-half times larger peak heap on the very same work. The cycle target is computed from the live data (GOGC=100means double,GOGC=400five times), soGOGChas no ceiling; the ceiling isGOMEMLIMIT, and it is soft: live data that outgrows the limit produces not a crash but continuous collection. And the collector does not return memory to the OS immediately —RSSlarger thanHeapAllocis not evidence of a leak.
In fact
- The measurement shows the opposite: 16 and 256 MB of live data gave a median pause of 39 and 48 µs — 1.22× on a heap that grew sixteenfold. A repeat of the same measurement at 16 MB gave 54 µs, more than either row, so even that growth is not resolvable by the instrument. What grows is not the pause but the work of marking — the share of CPU time. The two get confused, and then the program is tuned for a quantity that never changed.
- It is a trade, not a speed-up. On the same work, GOGC=400 against 100 gave four times fewer cycles and a two-and-a-half times larger peak heap. Collection does not get cheaper — the cost moves from time to memory.
- There is no ceiling in GOGC: the target is computed from the live data by the guide's formula, so as the live set grows, so does the target. A program with a growing cache will eat all the memory without violating the setting. The ceiling is set separately, with
GOMEMLIMIT. - The limit is soft: the documentation promises "reasonable effort", not a guarantee. If the live data itself outgrows the limit, the runtime does not crash — it collects almost continuously, which the guide calls a death spiral. The process is alive, the metrics look fine, and no work is being done.
- It stops the program twice per cycle and briefly — to set marking up and to finish it — and does everything else alongside it. Which is why the duration of a cycle and the pause are different quantities: the first grows with the heap, the second does not.
- It is non-generational, and that is a deliberate choice. The benefit of the generational hypothesis has already been collected in Go by escape analysis: short-lived objects mostly never reach the heap. A young generation would be half empty, while its barriers would cost on every pointer write.
- The collector does not return pages to the OS immediately: they go back gradually, in the background, because returning a page is cheap and getting it back is expensive. Hence RSS larger than
HeapAlloc. A leak looks different:HeapAllocgrows after a full collection. - A manual call stops the world and performs a full collection in one go — giving up exactly what the collector was made concurrent for. There are two legitimate cases: measurements, and a program moving from a heavy phase into idleness. In ordinary code it treats a symptom.
What is covered
- What is really being asked
- Base: what the collector actually does
- Mechanism 1: a whole collection cycle
- Mechanism 2: what does not grow with the heap
- Mechanism 3: GOGC — not an accelerator but a trade knob
- Mechanism 4: GOMEMLIMIT — the ceiling, and why it is soft
- Deeper: why the collector is the way it is
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
4 SOURCES
- A Guide to the Go Garbage CollectorOfficial documentation. The runtime's official guide. GOGC is defined there by a formula rather than by words: «The GOGC parameter determines the target heap size after each GC cycle: Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100.» The key part is that the target is computed FROM THE LIVE HEAP — which means GOGC has no ceiling in it.https://go.dev/doc/gc-guide
- A Guide to the Go Garbage Collector — the memory limitOfficial documentation. On GOMEMLIMIT and why it is soft: «the memory limit is a soft limit… the Go runtime makes no guarantees that it will maintain this memory limit under all circumstances; it only promises some reasonable amount of effort.» And a direct warning about the death spiral: «a death spiral… the Go runtime will constantly run the GC, and program execution will slow down.»https://go.dev/doc/gc-guide#Memory_limit
- Package runtime/debug — SetGCPercent and SetMemoryLimitOfficial documentation. The signatures the practice follows from. «SetGCPercent sets the garbage collection target percentage: a collection is triggered when the ratio of freshly allocated data to live data remaining after the previous collection reaches this percentage. SetGCPercent returns the previous setting.» Returning the previous setting is what makes the «change it temporarily and restore it» idiom possible.https://pkg.go.dev/runtime/debug
- Package runtime — MemStatsOfficial documentation. Where the lesson's numbers come from and why these fields. On pauses: «PauseNs is a circular buffer of recent GC stop-the-world pause times in nanoseconds» — that is, the time the world is STOPPED, not the duration of a cycle. On the target: «NextGC is the target heap size of the next GC cycle.»https://pkg.go.dev/runtime#MemStats