Deep Engineering
Advanced·Published·25 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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.
You do not need to know in advance
  • 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:

  1. "What kind of collector does Go have?" — they are checking whether you say the word "concurrent" rather than only "mark and sweep".
  2. "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.
  3. "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.

language contractThe problem statement: this holds for any collector that computes reachability and does not depend on the Go version. How a cycle is built, and the GOGC and GOMEMLIMIT knobs, come below and are tied to a version.

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:

measured observationbench/gogc/internals.go, go1.24.7 linux/amd64, two cores — that is, two executing threads. Both the runtime version and the number of threads allowed to execute code are part of the conditions here: on another version and another core count the absolute microseconds will differ.

A run of bench/gogc/internals.go prints:

live datapause, median90th percentile
16 MB39 µs66
64 MB41 µs73
256 MB48 µs107
16 MB — repeat54 µ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

implementation detail · Go 1.24The numbers below are the behaviour of the current runtime. The target formula itself is in the guide, but the specific values depend on the version and the machine.

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.

A Guide to the Go Garbage Collector

The run confirms the arithmetic and shows the price:

GOGCtarget / livecyclespeak heap
50×1.512424.3 MB
100×2.011231.8 MB
200×3.02647.5 MB
400×5.04378.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 live32.3 MB
GOGC=100, 64 MB live128.3 MB
limit 96 MB, 16 MB live32.3 MB
limit 96 MB, 64 MB live88.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.

A Guide to the Go Garbage Collector

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

implementation detail · Go 1.24Properties of the current collector, not guarantees of the language: the Go specification promises neither that objects stay put nor that there are no generations. Everything listed here has already changed over the runtime's history and can change again.

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:

quantitywhat it iswhere to look
HeapAllocheld by live objects right nowMemStats
NextGCthe target of the next cycleMemStats
HeapReleasedpages returned to the OSMemStats
RSSwhat the process holds as the OS sees ittop, 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

Next they ask

What is tri-colour marking and why is a write barrier needed?

Short answer

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.

Next they ask

Why does runtime.GC() exist if the collector runs on its own?

Short answer

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.

Next they ask

How do you tell that a program is spending its time collecting?

Short answer

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.

Next they ask

Why is a process's RSS larger than what HeapAlloc reports?

Short answer

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.

Next they ask

Can finalizers be relied on?

Short answer

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.

Next they ask

What changes if you set GOGC=off?

Short answer

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

Claim

the bigger the heap, the longer the pauses

Actually

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.

Claim

a higher GOGC makes the program faster

Actually

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.

Claim

GOGC limits how much memory a program will use

Actually

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.

Claim

GOMEMLIMIT guarantees the program will not exceed the limit

Actually

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.

Claim

the collector stops the program for the duration of a collection

Actually

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.

Claim

Go has a generational collector, like the JVM

Actually

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.

Claim

memory is not being released, so there is a leak

Actually

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.

Claim

runtime.GC() helps when memory is short

Actually

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

Five numbers about GOGC arithmetic: what SetGCPercent returns, how many times larger the next cycle's target is than the live data at GOGC=100 and at GOGC=400, how many cycles happen with the collector off, and how many two manual runtime.GC() calls add.
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

The same work: 200,000 kilobyte allocations against 16 MB of live data. How many times fewer collection cycles happen at GOGC=400 than at GOGC=100?
times

Knowledge check

Question 1 of 6

A program's live data grew from 16 to 256 MB. What happens to the collection pauses?

Sources & further reading

4 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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