Deep Engineering
Advanced·Published·30 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: if the live data outgrows the limit, collection cannot bring memory below it, and a limiter on GC CPU time prefers exceeding the limit to handing the program's work over to collection. That does not protect against an OOM at the container's boundary. 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.

Why that row is there. A median of a small sample on two cores jumps from run to run by more than the rows of the table differ, and without a repeat beside them a direction is easy to read out of such numbers — and to read wrongly. So the sample here is two hundred cycles, the repeat is printed alongside, and the run itself prints whether the difference between rows is resolvable by its own instrument.

Had the measurement printed the maximum instead of the median, it would be worse still: a table of maxima may well show the pause falling as the heap grows. The cause would not be the collector: the maximum of a small sample 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 absolute size 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, collection cannot bring memory below it: there is nothing to collect, everything is reachable. Without a safeguard the runtime would collect almost continuously — the guide calls this a death spiral — and right after that warning the guide describes the safeguard: a limiter on GC CPU time.

implementation detail · Go 1.24The limiter's design and threshold are the go1.24.7 implementation (runtime/mgclimit.go), not a language promise. The one promise is that the limit is soft.

In the go1.24.7 source (runtime/mgclimit.go) the limiter is a "leaky bucket" holding one CPU-second per P: it fills with GC time and drains with program time, and the comment in the source calls the resulting threshold "a very conservative limit of 50%". When the limiter kicks in, the runtime trades memory for CPU: collection stops keeping up with the limit, and the heap goes past it. The debug.SetMemoryLimit documentation of the same version speaks of this regime cautiously: collection may run nearly continuously, "however, the application may still make progress". So the process will not necessarily freeze — but it will not necessarily survive either: having exceeded the soft limit, it can run into the real one.

Two practical recommendations follow, and they are the ones interviewers want to hear. Set the limit below the container's real boundary, with headroom. GOMEMLIMIT accounts only for memory managed by the Go runtime — not the binary itself, not memory of C code, not syscall.Mmap — and the limiter may let the heap step over it; without headroom the external boundary fires and the process is killed by OOM. 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 blocks the calling goroutine until a full collection cycle is complete, and per the documentation may block the entire program. It does not cancel concurrency, though: in go1.24.7 runtime.GC starts an ordinary background cycle (gcBackgroundMode in runtime/mgc.go) and waits for it to finish, so other goroutines keep running during the concurrent phases. What remains is two short stop-the-world pauses, the CPU spent on marking, and the caller's own wait — while no more memory is freed than the next automatic cycle would have freed: only unreachable objects can be collected. So it is not put on a request path without a measured reason.

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 releases the resource itself; that is how the standard library uses it for files: the os.File finalizer closes the descriptor.

Next they ask

What changes if you set GOGC=off?

Short answer

GOGC=off disables the percentage trigger — the one that fires when the heap has grown by GOGC percent over the live data. It does not disable collection as such: the debug.SetGCPercent documentation says a negative percentage disables collection "unless the memory limit is reached", and runtime.GC() works regardless.

The measurement shows both sides (bench/gogc/practice.go, go1.24.7). With no GOMEMLIMIT and no manual calls inside the experiment there was not a single cycle, and after 200,000 one-kilobyte allocations the runtime held 219.4 MB. The same experiment with a 64 MiB limit gave 5 cycles and 60.8 MB. So "no cycles" is an observation of that environment, not a property of the setting.

GOGC=off without a limit has 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. For a service it is not an optimisation: unreachable objects are not collected, their memory is not freed for reuse, and the heap grows with the volume of allocation — in a container with a limit that is a deferred OOM. GOGC=off combined with GOMEMLIMIT is a separate mode: collection starts only near the limit.

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, collection will not bring memory below it. To keep collection from eating all of the program's work (the guide's death spiral), go1.24.7 has a limiter on GC CPU time — and when it kicks in, it lets the heap go past the limit. That does not protect against an OOM at the container's external boundary, so the limit is set below it, with headroom.

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

Only unreachable objects can be collected, and the next automatic cycle would have handled them anyway; if memory is short because of live data, a manual call will not help. And it has a cost: it blocks the calling goroutine until a full cycle completes (per the documentation it may block the entire program). Other goroutines keep running during the concurrent phases, but the stop-the-world pauses and the CPU spent on marking remain. There are two legitimate cases: measurements, and a program moving from a heavy phase into idleness.

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 at GOGC=off (GOMEMLIMIT is not set in this run, so there is no memory limit), 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

6 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
  5. runtime/mgclimit.go — the limiter on GC CPU time (go1.24.7)Go source code. The mechanism the guide introduces right after its death-spiral warning: «gcCPULimiter is a mechanism to limit GC CPU utilization in situations where it might become excessive and inhibit application progress (e.g. a death spiral)». The same comment says the leaky bucket «effectively sets a very conservative limit of 50%». And the description of the /gc/limiter/last-enabled metric in runtime/metrics of the same version names the price: the limiter «trades memory for CPU time». The threshold and the design are implementation, not a language promise.https://github.com/golang/go/blob/go1.24.7/src/runtime/mgclimit.go
  6. runtime/mgc.go — what runtime.GC does (go1.24.7)Go source code. The documentation: «GC runs a garbage collection and blocks the caller until the garbage collection is complete. It may also block the entire program». The body starts a cycle through gcStart, and gcStart picks gcBackgroundMode — the ordinary concurrent mode; a full stop-the-world collection is enabled only by the debugging setting GODEBUG=gcstoptheworld.https://github.com/golang/go/blob/go1.24.7/src/runtime/mgc.go