Skip to content
Software Atlas

Garbage Collection

How runtimes decide what's garbage, how mark & sweep, tri-color marking and generations work, what Go's and V8's collectors actually do — and how to tune them, read their logs and find leaks they can't fix.

⏱ ~45 min read14 sections6 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA garbage collector (GC) is the part of the runtime that finds objects your program can no longer reach and recycles their memory — so you never have to free anything yourself.

Every {} in JavaScript and every &User{} or make([]byte, n) in Go that lands on the heap takes some memory. Your program creates millions of these. If nothing ever gave that memory back, even a small server would run out within minutes.

Why not just free memory by hand?

Languages like C make you the busser: you call malloc to get memory and free to give it back. That sounds simple, but you must free every object exactly once, after its last use, on every code path. Get it wrong and you get three classic bugs:

  • Freeing too early: a Use-after-free — you keep using memory that now belongs to something else.
  • Freeing twice: a Double free, which corrupts the bookkeeping of the allocator (the code that hands out and takes back memory).
  • Never freeing: a Memory leak, which slowly eats all the memory until the process is killed.

Step through them, then flip to the Go tab to see what a GC changes. (The C code reads like Go: malloc asks for memory, free gives it back, -> reads a field through a pointer.)

Life without a garbage collector
Step through two classic C bugs, then the same thing in Go.
Code
1User *u = malloc(sizeof(User)); // ask the allocator for memory
2u->name = "Ana";
3free(u); // hand it back…
4Order *o = malloc(sizeof(Order)); // …the allocator reuses the spot
5o->total = 99;
6printf("%s", u->name); // use-after-free!
7free(u); // double free!
your variables
u → 0x10
heap memory
0x10: User{ ? }
result
—
In C you ask the allocator for memory by hand. malloc returns the address of a free spot — here 0x10 — and u stores that address (it's a pointer).
1 / 6

What a GC buys you, and what it costs

1

Safety

No use-after-free or double free — the collector only frees what nobody can reach.
2

Simplicity

You write return &User{} or return {} and never think about who frees it.
3

The bill

The collector uses CPU time, needs spare memory to work well, and may briefly pause your program (a Pause).

Most of this topic is about that bill: how collectors keep pauses tiny (§04, §07), how to tune the trade-off between memory and CPU (§08), and why a GC'd program can still leak memory (§09).

Foundations§02

Garbage = unreachable

In one sentenceAn object is garbage when no chain of references leads to it from the program's starting points — its roots.

The collector can't read your mind. It doesn't know whether you'll use an object again — only whether you could. So it uses a mechanical rule: if your code can still get to the object somehow, it must stay.

Where can your code start from?

Think about what your code can touch at any moment without being handed anything:

  • Local variables of every function that's running right now. Each Goroutine or thread has its own Call stack, and every Stack frame on it holds locals.
  • Global variables (Go's package-level vars, JavaScript's module-level variables and globalThis).
  • CPU registers: values the processor is working with this instant. (A CPU register is a tiny storage slot inside the CPU.)

These are the roots. From a root, follow every reference (in Go a Pointer, in JavaScript any object, array or function value): the variable req points at a Request object, the Request has a user field pointing at a User, and so on. Drawn out, this is the Object graph. Everything you can reach by following arrows is Reachable, or live. Everything else is garbage.

let req = { user: { name: "Ana" } }; // root `req` → Request → User
let user = req.user;                 // a second path to the same User
req = null;                          // Request: unreachable → garbage
                                     // User: still reachable via `user` → live

Two ways to find the garbage

1

Reference counting

Every object keeps a count of arrows pointing at it. When the count drops to 0, free it immediately. Used by Swift, Python (plus a backup collector) and C++'s shared_ptr.
2

Tracing

Every so often, start at the roots, follow every arrow, and mark what you reach. Free everything unmarked. Used by Go, JavaScript (V8), Java, C#.
3

The difference

A cycle — A points at B, B points at A — keeps both counts at 1 forever. Tracing doesn't care: if no root reaches the pair, both go.

Reference counting sounds simpler and frees memory sooner. But try deleting User → Avatar in reference-counting mode below: the Avatar and its Thumb point at each other, so their counts never reach zero. They're unreachable and never freed. A Tracing garbage collection collector frees them without a second thought.

Reachability playground
Click an arrow (or a button below) to delete that reference. Then run the GC and see what's garbage.
reqstack varcfgglobal varRequestConfigUserThemeAvatarThumb
▭ root● marked: reachable◌ garbage
Try: delete Request → User, then run the GC. The Avatar and Thumb still point at each other — are they kept?

A few things to try:

  1. Tracing mode: delete Request → Theme, run the GC. The Theme survives — Config still points at it. One path from a root is enough.
  2. Tracing mode: delete req → Request. Request, User, Avatar and Thumb all go in one collection, cycle and all.
  3. Reference counting: delete User → Avatar. The Avatar ↔ Thumb pair stays forever, in yellow: a leak.
Foundations§03

Mark & sweep

In one sentenceMark everything you can reach from the roots, then sweep the heap and free everything you didn't mark.

Mark & sweep is the oldest tracing algorithm (1960, for Lisp) and still the core of Go's collector and V8's old generation. Every fancier design in this topic is mark & sweep with tricks added to make it faster or less disruptive.

Back to the restaurant: the busser marks every table with someone sitting at it — or with a coat on a chair that belongs to someone sitting elsewhere. Then they sweep the room: every unmarked table gets cleared.

Mark & sweep, step by step
A stop-the-world collector: pause, mark from the roots, sweep the rest.
Collector (pseudo-code)
1function collect() {
2 stopTheWorld(); // pause the program
3 for (const r of roots) mark(r);
4 sweep();
5 resumeTheWorld();
6}
7function mark(obj) {
8 if (obj.marked) return; // seen it
9 obj.marked = true;
10 for (const c of obj.refs) mark(c);
11}
12function sweep() {
13 for (const obj of heap) {
14 if (obj.marked) obj.marked = false;
15 else free(obj); // garbage
16 }
17}
heap (object graph)
stacklocal varglobalpackage varABCDEF
memory, in address order
A
D
B
E
C
F
The heap holds six objects. Your program allocates a seventh, but there's no room left — so the collector starts. Below, the same six objects in address order, the way they actually sit in memory.
1 / 9

The two phases, in plain words

  1. Mark. Start at each root. Mark the object it points at, then everything that object points at, and so on. If you reach an object that's already marked, stop — that's how cycles are handled, and how shared objects get visited only once.
  2. Sweep. Walk the whole heap. Unmarked objects are garbage: their memory goes onto a Free list for future allocations. Marked objects get their mark cleared, ready for the next cycle.

Notice what the collector's work depends on. Marking costs time proportional to the live objects (only they get visited). Sweeping costs time proportional to the whole heap. Hold on to that: it's why the generational trick in §05 is so effective.

Two problems with the simple version

Problem 1: holes. After a sweep, free memory is scattered in gaps between survivors. That's Heap fragmentation: plenty of free bytes in total, but maybe no single gap big enough for a large object. Some collectors fix it with Compaction: slide the survivors together and update every pointer to them. Others, like Go's, avoid it by grouping objects of similar size together on the same memory pages (§07).

Problem 2: the pause. In the stepper the collector began with stopTheWorld(). That's a Stop-the-world pause pause: every thread of your program is frozen while the GC works. Why? If your code kept moving pointers around during marking, the collector could miss a live object and free it (§04 shows exactly how).

The catch is that a full mark & sweep of a big heap can take a long time. For a 10 GB heap that could be hundreds of milliseconds, maybe seconds — and every request in flight waits.

Where the pause goes
Stop-the-world mark & sweep
your code
running
paused: whole GC
running
collector
mark
sweep
Concurrent (Go, V8's old generation)
your code
running
running, a bit slower
running
collector
mark, on other cores
sweep, bit by bit
Striped = your code is paused. A stop-the-world collector pauses for the whole collection; a concurrent one does most of the work beside your code and pauses only briefly at the start and end.

For a batch job, one big pause is fine; for a web server, a 300 ms freeze is a timeout for every user caught in it. So modern collectors do most of the work while your program runs. That's the next section.

Practitioner§04

Marking while the program runs: tri-color and write barriers

In one sentenceTo mark while your program keeps running, the collector tracks its progress with three colors — and the compiler adds a small check, a write barrier, to every pointer write so the program can't hide a live object from it.

Three ways to shrink a pause

GC people use three words that sound alike but mean different things. All three are used by both Go and V8:

WordMeaningKitchen analogy
ParallelThe GC uses several threads for its own work. The program may still be paused.Four bussers clear the room together while the diners wait outside.
IncrementalThe GC's work is cut into small slices between bits of program work.One busser clears two tables, steps aside, clears two more…
ConcurrentThe GC works at the same time as the program, on other threads.Bussers work while diners keep eating.

Incremental and concurrent marking share one big problem. In GC jargon, your program is the Mutator: it changes (mutates) pointers. If the mutator keeps rewiring the object graph while the collector walks it, the collector can miss something. To see how, we need a better way to describe "how far marking has got".

Tri-color marking

Tri-color marking gives every object one of three colors:

White
Not reached yet. Still white when marking ends → garbage.
Grey
Reached, but its own references haven't been checked. The to-do list.
Black
Reached, and all its references checked. Never looked at again.

Marking is just: make the roots' targets grey; then, repeatedly, pick a grey object, make everything it points at grey (if it's still white), and turn it black. When no grey objects are left, every white object is unreachable → garbage. It's the same mark phase as §03, with the to-do list made explicit.

The colors let the collector stop and resume at any point (just keep the grey list), which is what makes incremental and concurrent marking possible. They also let us state the one rule that must never break:

The bug, then the fix

Step through with the barrier off. Your program does two ordinary-looking writes: copy a pointer into A, then clear it from B. Then turn the barrier on and run it again.

Tri-color marking, with and without a write barrier
The marker and your program run at the same time. Watch what one innocent pointer move does.
Mutator (your code)
1// Your program, while the GC marks.
2// Copy B's pointer to C into A:
3a.child = b.child
4// Then remove it from B:
5b.child = nil
heap (object graph)grey to-do list: empty
rootstack varABC
A GC cycle starts. Every object begins white: "not seen yet". At the end, anything still white is garbage.
1 / 7

Without help, the collector would free C while A still uses it — a use-after-free caused by the garbage collector itself. The fix is a Write barrier: while marking is in progress, the compiler-generated code for every pointer write (like a.child = c) also does a tiny extra step. One classic version (Dijkstra's) shades the newly stored object grey, so a black object can never end up pointing at a white one.

a.child = c // what you write
// what runs while the GC is marking (roughly):
//   if gcIsMarking { shade(c) }  // white → grey: "don't forget this one"
//   a.child = c

You never write a barrier yourself. The Go compiler and V8 insert them automatically, and when no GC is running they cost little more than a check of one flag.

Practitioner§05

Most objects die young: generational GC

In one sentenceMost objects die young. So keep new objects in a small nursery you clean often and cheaply, and only bother the big old generation once in a while.

Think of what a web server allocates per request: a parsed JSON body, some strings, a few arrays, a response object. Milliseconds later the response is sent and all of it is garbage. Meanwhile a handful of objects — the config, a connection pool, a cache — live as long as the process.

This pattern is so common it has a name: the Generational hypothesis. Real measurements across many languages agree: typically the large majority of objects (often quoted as 80–98%) die before the next collection.

Split the heap by age

A generational collector splits the heap in two:

  • The Young generation (also called the nursery): small, where every new object starts. Collected often, in a Minor GC.
  • The Old generation: big, for objects that have survived a couple of minor GCs. Collected rarely, in a Major GC that covers the whole heap.

Moving a survivor from young to old is called Promotion. Once promoted, an object stops being looked at on every minor GC.

Why a minor GC is so cheap: copy the survivors

Many young generations (V8's among them) use Semi-space copying. The nursery has two equal halves, from-space and to-space:

  1. New objects are placed one after another in from-space. Allocating is just "bump a pointer to the next free byte" — about as cheap as allocation gets.
  2. When from-space is full, a minor GC finds the live objects (starting from the roots, like any tracing collector) and copies them into to-space, packed tightly. Objects that already survived once are promoted to the old generation instead.
  3. Everything left in from-space is garbage. It isn't freed one by one; the whole half is simply considered empty. Then the two halves swap names.

The work done is proportional to the number of survivors — the dead objects are never touched. If 95% died, you only copy 5%. Run it and watch the counters:

The nursery, live
New objects land in the young generation. When it's full, a minor GC copies only the survivors. Most objects die before that happens.
young · from-spacenew objects go here
·
·
·
·
·
·
·
·
young · to-spaceempty, waiting
·
·
·
·
·
·
·
·
old generationsurvived 2 minor GCs
·
·
·
·
·
·
·
·
·
·
·
·
·
·
■ new■ survived 1 GC■ promoted (old)✕ dead (unreachable, not yet collected)∞ lives forever
minor GCs 0 · copied 0 · dead skipped 0 · promoted 0 · major GCs 0
Press ▶. Each tick allocates one object and “time passes” for the others. Watch how few objects are still alive when the from-space fills up.

Notice that the copying also compacts for free: survivors end up side by side, so the nursery never fragments.

The catch: pointers from old to young

A minor GC wants to scan only the nursery. But what if an old object points at a young one — say, a long-lived cache (old) gets a fresh entry (young)? The cache isn't a root, and the minor GC doesn't scan the old generation, so the new entry would look unreachable.

The fix reuses an idea from §04. A write barrier watches every pointer write; when the program stores a young object into an old one, the barrier records that spot in a Remembered set. A minor GC treats those recorded spots as extra roots. That's why generational collectors need a write barrier even when they aren't concurrent.

const cache = new Map();          // created at startup → long since promoted (old)

function handle(req) {
  const entry = { at: Date.now() }; // brand new → young
  cache.set(req.id, entry);         // old → young pointer: the write barrier
                                    // remembers it, so the next minor GC keeps `entry`
}
Practitioner§06

Node's collector: V8 and Orinoco

In one sentenceV8 is a generational collector: a fast, parallel Scavenger for the young generation and a mostly concurrent Mark-Compact for the old one. Orinoco is the project that moved most of that work off the JavaScript thread.

Everything from §02–§05 shows up in V8, the engine that runs JavaScript in Node and Chrome:

Part of the heapWhat lives thereCollectorTechnique
Young generation ("new space")Every new objectScavenger (minor GC)Semi-space copying (§05), parallel on several threads
Old generation ("old space")Objects that survived two scavengesMark-Compact (major GC)Tri-color marking (§04), concurrent + incremental, then compaction (§03)

Orinoco: keeping the main thread free

JavaScript runs your code on one main thread, so any GC work on that thread delays your callbacks directly. Orinoco applied all three techniques from §04:

  • Scavenges are parallel. JavaScript does pause, but several helper threads copy survivors together, so the pause is short.
  • Old-generation marking is concurrent and incremental. Helper threads mark while your JavaScript keeps running; the main thread does small marking steps now and then. A write barrier keeps it correct.
  • Compaction is parallel; sweeping is concurrent. Only a short final pause remains, to finish marking and move objects.
Orinoco: moving GC work off the JavaScript thread
Minor GC: parallel Scavenger
main (JS)
your JavaScript
scav.
your JavaScript
helper 1
scav.
helper 2
scav.
Major GC: concurrent + incremental marking, parallel compaction
main (JS)
JS
JS
JS
final pause
your JavaScript
helper 1
concurrent marking
compact
sweep
helper 2
concurrent marking
compact
sweep
Striped = JavaScript is paused. Scavenges are short, parallel pauses. Major GCs mark concurrently on helper threads, with small incremental steps on the main thread, then one short final pause with parallel compaction. Sweeping is concurrent.

Watching it: --trace-gc

Run any script with node --trace-gc and V8 prints one line per collection. Here's a real run on this machine (Node 22) of a script that creates 3 million small objects and keeps 1 in 10 forever:

alloc.js
const keep = [];                       // long-lived: grows to 300k objects
const recent = new Array(1000);        // short-lived: each slot is overwritten soon
for (let i = 0; i < 3_000_000; i++) {
  const obj = { id: i, name: "user" + i };
  recent[i % 1000] = obj;              // the old object in this slot becomes garbage
  if (i % 10 === 0) keep.push(obj);    // 1 in 10 survives forever
}
console.log("done", keep.length);
$ node --trace-gc alloc.js
[9723:0xa3a6000]   49 ms: Scavenge 6.1 (8.8) -> 4.7 (8.8) MB, pooled: 0 MB, 0.60 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;
[9723:0xa3a6000]   50 ms: Scavenge 6.1 (8.8) -> 4.9 (9.1) MB, pooled: 0 MB, 0.76 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;
…
[9723:0xa3a6000]  250 ms: Mark-Compact 16.8 (45.7) -> 14.6 (48.0) MB, pooled: 0 MB, 54.50 / 0.00 ms  (+ 1.8 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 65 ms) (average mu = 0.775, current mu = 0.775) finalize incremental marking via stack guard; GC in old space requested
…
[9723:0xa3a6000]  404 ms: Scavenge 47.3 (65.0) -> 34.1 (65.7) MB, pooled: 0 MB, 4.64 / 0.00 ms  (average mu = 0.775, current mu = 0.775) allocation failure;
done 300000

Reading one line, left to right:

PieceMeaning
[9723:0xa3a6000]Process id, and the address of this V8 instance (Node can run several, e.g. one per worker thread)
49 msTime since the process started
Scavenge / Mark-CompactWhich collector ran: minor or major
6.1 (8.8) -> 4.7 (8.8) MBHeap in use before (and memory reserved for the heap) → after
pooled: 0 MBMemory pages V8 keeps aside to reuse instead of returning them to the OS
0.60 / 0.00 msHow long JavaScript was paused, then time spent in Node's own GC callbacks (usually 0)
mu = 0.775Mutator utilization: the share of time your JavaScript got to run, rather than the GC. 1.000 = no GC time yet
allocation failureThe reason. Sounds scary, means "the young generation was full" — the normal trigger for a scavenge

In this run there were 33 scavenges (each 0.6–5 ms) and one Mark-Compact. That's the generational hypothesis at work: almost all objects died in the nursery. The Mark-Compact paused for 54 ms on this machine — rare, but exactly the kind of pause you'll see in tail latency. The walltime since start of marking 65 ms shows marking had been running concurrently for 65 ms before that final pause.

The heap limit, and how Node dies

V8 caps the old generation at a Heap limit. Recent Node versions pick a default based on the machine's memory; check yours with node -e 'console.log(require("v8").getHeapStatistics().heap_size_limit / 2**20)' (on this 16 GB machine: 2096 MB). Set it explicitly with --max-old-space-size (in MB). When live data won't fit, V8 runs Mark-Compact after Mark-Compact, each freeing less, then gives up. Real output, with a deliberately small limit and a Map that's never cleared:

oom.js
const cache = new Map();               // never evicted → grows forever
let i = 0;
while (true) {
  cache.set(i, { id: i, payload: "x".repeat(100) + i });
  i++;
}
$ node --max-old-space-size=64 oom.js
<--- Last few GCs --->
[10239:0x20371000]  425 ms: Mark-Compact 70.0 (87.9) -> 68.8 (103.4) MB, pooled: 0 MB, 112.97 / 0.00 ms  (average mu = 0.419, current mu = 0.193) allocation failure; scavenge might not succeed

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
 1: 0xe40d24 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node]
 …
$ echo $?
134

Read the last GC line: 70.0 → 68.8 MB. The collector freed almost nothing, because almost everything was reachable through cache. And current mu = 0.193: the GC was eating 80% of the time just before the crash. Exit code 134 means the process aborted itself (128 + signal 6, SIGABRT). Raising the limit only delays this; the real fix is to stop holding on to everything (§09).

Checking memory from code

const mb = (n) => (n / 2 ** 20).toFixed(1) + " MB";
const m = process.memoryUsage();
console.log({
  rss: mb(m.rss),             // all memory the OS gave this process
  heapTotal: mb(m.heapTotal), // memory V8 reserved for the JS heap
  heapUsed: mb(m.heapUsed),   // memory used by JS objects right now
  external: mb(m.external),   // memory outside the JS heap tied to JS objects (Buffers…)
});
Practitioner§07

Go's collector: concurrent, non-moving, non-generational

In one sentenceGo runs one kind of collection: a concurrent tri-color mark & sweep of the whole heap, with two stop-the-world pauses that are usually well under a millisecond.

Go's design is the opposite of V8's in two ways, and both are deliberate:

1

Non-generational

No young/old split: every cycle marks the whole heap. Instead, Go's compiler keeps many short-lived values off the heap entirely, using Escape analysis (see Stack vs Heap). Stack memory is freed for free when a function returns.
2

Non-moving

Objects are never moved, so there's no compaction. To avoid fragmentation, Go's allocator sorts objects into ~70 size classes: each memory page (here: a fixed-size block of 8 KB the allocator hands out as a unit) holds objects of one size only, so a freed slot always fits the next object of that size.
3

Concurrent

Marking runs on other cores beside your goroutines, kept correct by the hybrid write barrier (§04). Pauses don't grow with heap size.

Not moving objects keeps things simple: pointers stay valid, and Go code can hand pointers to C code (via cgo) safely. The cost is the work saved by generations: a Go service with a big, stable heap re-marks all of it every cycle.

One cycle, four phases

One Go GC cycle
goroutines
running
running
assist
running
running
GC workers
①
② concurrent mark (~25% CPU)
③
④ sweep (lazy)
① sweep termination: finish last cycle, turn the write barrier on② mark: scan stacks, globals, heap — tri-color③ mark termination: turn the barrier off④ sweep: unmarked memory becomes reusable
① and ③ are the only stop-the-world moments, usually tens of microseconds each. ② runs beside your goroutines on about a quarter of the CPU; a goroutine that allocates fast during ② is drafted to help (a mark assist). ④ happens a bit at a time, as you allocate.

Two details in that picture matter in production:

  • Background marking takes about 25% of the CPU (GOMAXPROCS × 0.25) while it runs. Your goroutines keep going on the rest.
  • Mark assists. If a goroutine allocates faster than the background workers can mark, the runtime makes that goroutine do some marking before its allocation succeeds. This Mark assist stops a fast allocator from outrunning the collector — but it shows up as random extra latency in whichever request happened to allocate at the wrong moment.

When does a cycle start? The pacer

Go doesn't collect on a timer. It collects based on heap growth. After each cycle, it knows the Live heap — how much survived. It then sets a Heap goal:

heap goal = live heap × (1 + GOGC / 100)

With the default GOGC=100, that's live × 2: if 10 MB survived, the next cycle should finish by the time the heap reaches about 20 MB. The GC pacer watches how fast you allocate and how long marking took last time, and starts the next cycle early enough to finish near the goal. (There's also a floor of 4 MB, so tiny programs don't collect constantly, and a 2-minute timer that forces a cycle if nothing else did.)

Watching it: GODEBUG=gctrace=1

Set this environment variable and the runtime prints one line per cycle to stderr. Here's a real run of a small program that keeps about 10 MB of live data and churns out short-lived garbage:

main.go
package main

import "fmt"

type node struct {
	next *node
	data [64]byte
}

var keep []*node // stays reachable: ~10 MB of live data
var sink *node   // a package-level variable forces each temporary onto the heap

func main() {
	for i := 0; i < 100_000; i++ {
		keep = append(keep, &node{})
	}
	for i := 0; i < 5_000_000; i++ {
		sink = &node{} // the previous one becomes garbage
	}
	fmt.Println("done", len(keep))
}
$ go build -o t . && GODEBUG=gctrace=1 ./t
gc 1 @0.002s 4%: 0.049+3.8+0.007 ms clock, 0.19+0/1.1/1.3+0.031 ms cpu, 4->4->3 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 4 P
gc 2 @0.008s 9%: 0.012+3.1+0.040 ms clock, 0.050+0/2.8/2.6+0.16 ms cpu, 6->7->6 MB, 6 MB goal, 0 MB stacks, 0 MB globals, 4 P
gc 3 @0.014s 10%: 0.017+2.3+0.043 ms clock, 0.070+0.58/2.0/2.1+0.17 ms cpu, 11->12->9 MB, 12 MB goal, 0 MB stacks, 0 MB globals, 4 P
…
gc 11 @0.065s 9%: 0.016+2.3+0.055 ms clock, 0.066+0.022/2.0/2.9+0.22 ms cpu, 17->19->10 MB, 20 MB goal, 0 MB stacks, 0 MB globals, 4 P
gc 12 @0.072s 9%: 0.021+2.2+0.050 ms clock, 0.086+0.29/1.8/1.9+0.20 ms cpu, 18->20->11 MB, 21 MB goal, 0 MB stacks, 0 MB globals, 4 P
done 100000

Take gc 11 apart:

PieceMeaning
gc 11The 11th GC cycle since the program started
@0.065sStarted 65 ms after the program started
9%Share of all CPU time so far spent on GC
0.016+2.3+0.055 ms clockWall-clock time of the three phases: STW sweep termination (①) + concurrent mark (②) + STW mark termination (③). Your program was paused for 0.016 + 0.055 = 0.071 ms in total
0.066+0.022/2.0/2.9+0.22 ms cpuCPU time for the same phases; the middle is split into mark assists / background marking / marking on otherwise idle cores
17->19->10 MBHeap when the cycle started → heap when marking finished (the program kept allocating) → live heap (what was marked)
20 MB goalThe heap goal this cycle was aiming for
0 MB stacks, 0 MB globalsGoroutine stacks and globals that had to be scanned (they count toward the goal too)
4 PNumber of processors (GOMAXPROCS) used

Now check the pacer formula yourself: gc 11 found 10 MB live, so the next goal is about 10 × 2 = 20 MB — and gc 12 says 21 MB goal (the live number is rounded down for printing). The whole program ran 36 cycles in about 0.2 s (35–39 across runs). Across three runs on this machine, the longest single pause (① + ③) was between 0.25 and 0.95 ms; most were under 0.1 ms.

Practitioner§08

Tuning Go: GOGC and GOMEMLIMIT

In one sentenceGarbage collection is a trade between memory and CPU: let the heap grow more and the GC runs less often. GOGC sets the ratio; GOMEMLIMIT sets a ceiling.

Here's the trade-off as a worked example. Say your service keeps 300 MB of live data and allocates 400 MB/s of short-lived garbage:

  • With GOGC=100 the heap goal is 300 × 2 = 600 MB. Each cycle, 300 MB of new garbage piles up before the next one: 300 / 400 ≈ 0.75 s between cycles.
  • With GOGC=50 the goal is 300 × 1.5 = 450 MB: only 150 MB of headroom, so a cycle every 0.375 s — twice the GC CPU, 150 MB less memory.
  • With GOGC=200 the goal is 900 MB: a cycle every 1.5 s — half the GC CPU, 300 MB more memory.

Each cycle has to mark the whole 300 MB of live data no matter how much garbage there is. So fewer cycles = less CPU, and the only way to get fewer cycles is more headroom. That's the whole idea of GOGC.

GOMEMLIMIT: a ceiling, not a ratio

GOGC alone has a blind spot: it only knows ratios. If live data grows from 300 MB to 600 MB, the goal silently becomes 1.2 GB — and in a container with a 1 GB limit, the kernel kills the process (OOM-killed, exit code 137) before Go ever thinks the heap is too big.

GOMEMLIMIT (Go 1.19+) fixes that. It's a soft limit on total Go runtime memory (heap, goroutine stacks, runtime bookkeeping). As memory approaches it, the GC runs earlier than GOGC would ask, to stay under. Try the presets in order:

GOGC & GOMEMLIMIT simulator
A toy model of Go's pacer in a container with a 1000 MB memory limit. Assumes marking costs ~1 ms of CPU per MB of live heap on a 4-core machine.
GOGC
GOMEMLIMIT
025050075010000 s5.3 scontainer limit (OOM kill)live heap
GC cycles / s
1.3
peak heap
600 MB
GC CPU (toy)
10%
Fine: the heap cycles between 300 and ≈ 600 MB.

What the presets show:

  1. defaults — 300 MB live, GOGC=100: the classic sawtooth between 300 and 600 MB.
  2. bigger live heap — 600 MB live: the goal is now 1.2 GB and the container dies.
  3. …plus GOMEMLIMIT — same, with GOMEMLIMIT=900MiB: the GC starts earlier, the peak stays under 900 MB, and it costs more GC cycles per second instead of a crash.
  4. GOGC=off + limit — a popular setup for services with a known memory budget: never collect because of growth, only when memory approaches the limit. Very few cycles, all memory used.
  5. live ≈ limit — the danger zone. With only a sliver of headroom, the GC would have to run constantly. Go caps GC CPU at roughly 50% when the memory limit is the reason it's running, and lets the heap go over the limit rather than freeze your program entirely. That's a GC death spiral: the program stays alive but crawls.

The same thing, for real

The program from §07 (10 MB live, lots of garbage), run with different settings on this machine. The numbers are real but small and noisy; the direction is what matters:

$ for g in 50 100 400 off; do echo -n "GOGC=$g: "; GOGC=$g GODEBUG=gctrace=1 ./t 2>&1 | grep -c '^gc'; done
GOGC=50: 77
GOGC=100: 35
GOGC=400: 9
GOGC=off: 0

$ GOGC=off GOMEMLIMIT=40MiB GODEBUG=gctrace=1 ./t 2>&1 | grep -c '^gc'
19

$ GOMEMLIMIT=15MiB GODEBUG=gctrace=1 ./t 2>&1 | tail -2
gc 309 @0.977s 20%: 0.011+3.8+0.007 ms clock, 0.046+2.0/2.2/0.18+0.031 ms cpu, 9->10->8 MB, 10 MB goal, 0 MB stacks, 0 MB globals, 4 P
done 100000

Doubling GOGC roughly halves the number of cycles. GOGC=off with a 40 MiB limit collected only 19 times, each time near the limit. And the last run is the death spiral in miniature: a 15 MiB limit for ~10 MB of live data forced 309 cycles, took 20% of all CPU time, and stretched a 0.2 s program to about 1 s (another run took 2.7 s — the more the GC fights for CPU, the noisier it gets).

Setting them

# Usual way: environment variables, read at startup.
GOGC=100 GOMEMLIMIT=900MiB ./server   # units: B, KiB, MiB, GiB, TiB
GOGC=off GOMEMLIMIT=900MiB ./server   # collect only near the limit

There's no GOGC equivalent in Node; the nearest knobs are --max-old-space-size (§06), which is a hard ceiling, and --max-semi-space-size, which sets the size of the young generation (bigger = fewer scavenges, more memory).

Practitioner§09

Memory leaks in garbage-collected languages

In one sentenceA GC frees what is unreachable, not what is unused. Every leak in Go or JavaScript is something you no longer need that is still reachable from a root.

You can't forget to call free any more — but you can forget to let go. If a global map, a listener list or a running goroutine still points at an object, the collector has to assume you'll use it, and it keeps it forever. In the restaurant: a coat left on a chair keeps the table reserved, even though its owner went home hours ago.

The usual suspects

LeakWhy it stays reachableFix
Cache or map with no evictionIt's a global (a root); every entry is reachable through itCap its size (LRU), expire entries, delete them
Listener / callback never removedThe emitter's listener list points at your callback, and the callback's closure points at whatever it usesUnsubscribe when done (see Observer leaks)
Timer never cleared (setInterval, time.Ticker)The runtime's timer list points at the callbackclearInterval, ticker.Stop()
Goroutine blocked foreverA running (or blocked) goroutine's stack is a root, so everything it references stays aliveGive every goroutine a way out (see Channels: cancellation)
Closure capturing a big objectThe function value keeps every variable it uses aliveCopy out only what you need before creating the closure
Small slice or string of a big oneIt shares the big one's memoryCopy the small part: bytes.Clone, strings.Clone

Three of these deserve a closer look.

1. Listeners that capture big things

const { EventEmitter } = require("node:events");
const bus = new EventEmitter();

function handleRequest(id) {
  const bigBuffer = Buffer.alloc(1024 * 1024);          // 1 MB per request
  bus.on("config-changed", () => bigBuffer.fill(id));   // closure keeps bigBuffer alive…
  // …and nobody ever calls bus.off(), so the listener (and its 1 MB) lives forever
}

for (let i = 0; i < 200; i++) handleRequest(i);

Real output after 200 calls (with --expose-gc so a full GC ran first):

listeners: 200 external: 201 MB
(node:13297) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 config-changed listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit

The function passed to bus.on is a Closure: it remembers bigBuffer, so every buffer stays reachable through bus. Node even warns you at the 11th listener. Don't silence that warning with setMaxListeners() — find the missing bus.off.

2. Goroutines that never finish

func fetchFirst(urls []string) string {
	ch := make(chan string) // unbuffered: a send waits for a receiver
	for _, u := range urls {
		go func() { ch <- get(u) }() // every goroutine but the first blocks here forever
	}
	return <-ch // we only ever receive once
}

Calling this 1000 times with 3 URLs and then printing runtime.NumGoroutine() gives goroutines: 2001 on this machine: 2 leaked goroutines per call, plus main. Each one holds its stack and everything it references, forever. The fix here is make(chan string, len(urls)), so every send can finish; in general, a Goroutine leak is fixed by giving each goroutine a way to stop.

3. A small piece that pins a big whole

A tiny slice can keep a huge array alive
header := data[:16]ptr · len 16 · cap 100 MBbacking array: 100 MB — all kept alive, only 16 bytes used
A Go slice (or string) is a small header pointing into a backing array. The GC keeps the whole array as long as any slice points into it. bytes.Clone / strings.Clone copy just the part you need.

In Go, data[:16] doesn't copy anything: it's a new slice header pointing into the same backing array (see Slice). Strings work the same way: strings.Cut, slicing s[a:b] and similar return views into the original string's memory. Keep the small piece and you keep the big one.

header := data[:16]              // ✗ keeps all of data alive
header := bytes.Clone(data[:16]) // ✓ copies 16 bytes; data can be collected (Go 1.20+)
title := strings.Clone(line)     // ✓ the same for strings (Go 1.20+)

V8 does something similar for strings: slicing a long string may produce a "sliced string" that refers to the original. It's an engine detail you can't control directly, but it explains heap snapshots full of (sliced string) entries.

Finding leaks in Go: compare two heap profiles

The tool is pprof. A heap profile records, for every allocation site (a line of code), how much of its memory is still in use after the last GC. Take one profile, let the program run, take another, and diff them: what grew is your suspect. A real run:

main.go
var sessions = map[int][]byte{} // leak 1: a global map nobody cleans up
var titles []string             // leak 2: tiny strings that pin big ones

func handle(id int) {
	sessions[id] = make([]byte, 4096)                    // line 14
	doc := strings.Repeat("x", 4096) + "\ntitle"         // line 15
	_, title, _ := strings.Cut(doc, "\n") // title = the part AFTER "\n": 5 bytes,
	titles = append(titles, title)         // but it points INTO doc's 4 KB
}

func writeHeapProfile(name string) {
	runtime.GC() // the heap profile describes the state after the last GC
	f, err := os.Create(name)
	if err != nil {
		panic(err)
	}
	defer f.Close()
	pprof.WriteHeapProfile(f)
}

// main: 1 000 requests → writeHeapProfile("before.pprof")
//       10 000 more    → writeHeapProfile("after.pprof")
$ go tool pprof -top -lines -diff_base=before.pprof leak after.pprof
File: leak
…
Type: inuse_space
Showing nodes accounting for 79.38MB, 878.30% of 9.04MB total
      flat  flat%   sum%        cum   cum%
   43.20MB 478.00% 478.00%    43.20MB 478.00%  main.handle main.go:15
   36.18MB 400.29% 878.30%    36.18MB 400.29%  main.handle main.go:14
…

Read flat as "memory still in use that was allocated on this line, added since before.pprof". (The percentages are relative to the first profile, which is why they're above 100%.) Line 14 is the session map: 10 000 × 4 KB. Line 15 is the documents — each 4 KB document is kept alive only by the 5-byte title pointing into it (strings.Cut returns the part before the separator, the part after it, and whether it was found; we keep only the part after). Both leaks found in one command. The exact numbers vary a little from run to run.

In a real server you don't write files by hand: import net/http/pprof and fetch /debug/pprof/heap twice, a few minutes apart. Use /debug/pprof/goroutine to see where leaked goroutines are stuck.

Finding leaks in Node: compare two heap snapshots

A Heap snapshot lists every object on the V8 heap and who points at it.

  1. Start Node with node --inspect server.js, open chrome://inspect in Chrome and click inspect.
  2. In the Memory tab, take a heap snapshot. (Taking one runs a full GC first, so only reachable objects appear.)
  3. Put the suspect code path under load (send a few hundred requests), then take a second snapshot.
  4. Select the second snapshot and switch the view from Summary to Comparison. Sort by Size Delta: the object types that grew are your suspects.
  5. Click one object and read its Retainers pane: its Retainer path, the chain of references from a root to that object. That chain ends at the thing that should have let go — a Map, a listener array, a timer.

On a server without a browser, node --heapsnapshot-signal=SIGUSR2 server.js writes a snapshot whenever you send that signal (kill -USR2 <pid>), and require("node:v8").writeHeapSnapshot() writes one from code. Load the files into the Memory tab later.

Practitioner§10

Giving the GC less work

In one sentenceThe cheapest garbage is garbage you never create. Fewer allocations mean fewer GC cycles; fewer live pointers mean each cycle has less to mark.

From §07–§08, GC cost comes from two numbers:

  • How often a cycle runs — driven by your allocation rate (MB/s). Halve it and cycles happen half as often.
  • How much work each cycle does — driven by the live heap, and especially how many pointers it contains, since marking means following pointers.

So there are two levers: allocate less, and keep less (pointer-heavy) data alive. Before pulling either, measure — most code allocates plenty and that's fine. Optimise the hot paths a Profiler points at.

Step 1: keep things off the heap

The best allocation is the one that never reaches the heap. In Go, a value that doesn't outlive its function can live on the stack, which costs the GC nothing; the compiler decides this with Escape analysis. Returning pointers, storing values in interfaces and capturing them in closures tend to push them to the heap. Stack vs Heap covers this in depth, including go build -gcflags=-m, which prints the compiler's decision for each value.

Step 2: pre-size slices and maps

append on a full slice allocates a bigger backing array and copies everything over — repeatedly, as the slice grows. If you know the final size, say so up front:

build_test.go
func growing(n int) []int {
	var out []int // starts with capacity 0
	for i := 0; i < n; i++ {
		out = append(out, i) // re-allocates and copies whenever it runs out of room
	}
	return out
}

func presized(n int) []int {
	out := make([]int, 0, n) // ← one allocation, exactly big enough
	for i := 0; i < n; i++ {
		out = append(out, i)
	}
	return out
}

func BenchmarkGrowing(b *testing.B) {
	for b.Loop() {
		_ = growing(10_000)
	}
}
// BenchmarkPresized is the same, calling presized.
$ go test -bench . -benchmem
BenchmarkGrowing-4        	    7093	    203762 ns/op	  357625 B/op	      19 allocs/op
BenchmarkPresized-4       	   42262	     35654 ns/op	   81920 B/op	       1 allocs/op

Read the columns as: how many times it ran, time per run, bytes allocated per run, allocations per run. Pre-sizing cut 19 allocations to 1 and allocated about 4× fewer bytes, and on this machine it ran about 5–6× faster. The same goes for maps: make(map[string]int, n).

Step 3: reuse with sync.Pool

When a hot path needs a big temporary buffer per request, keep used buffers around and reuse them. sync.Pool does exactly that, and is safe to use from many goroutines:

var bufPool = sync.Pool{
	New: func() any { return new(bytes.Buffer) }, // called when the pool is empty
}

func render(w io.Writer, items []string) (int, error) {
	buf := bufPool.Get().(*bytes.Buffer) // borrow (or create) a buffer
	buf.Reset()                          // ← always clear what the last user left
	defer bufPool.Put(buf)               // give it back when done
	for _, it := range items {
		buf.WriteString("<li>")
		buf.WriteString(it)
		buf.WriteString("</li>")
	}
	return w.Write(buf.Bytes())
}

In a micro-benchmark on this machine, building a 1.3 KB response in a fresh buffer each time cost 7 allocations and 4080 B per run (2714 ns); with the pool, 0 allocations (412 ns). Two warnings. The pool is only a cache: the GC may empty it at any cycle, so never rely on what's in it. And pooling objects that are cheap to create only adds complexity — pool big buffers on hot paths, not every struct (see Object pools and sync.Pool for the general pattern).

Step 4: fewer pointers, less marking

Marking means following pointers. Go knows which memory can't contain pointers (a []byte, a []int, a struct of only numbers) and doesn't scan it at all — it's noscan memory. So the same data can cost very different amounts of GC work:

type PointerHeavy struct {
	Name *string     // each pointer is one more thing to follow
	Tags []*Tag
}

type PointerFree struct {
	NameID uint32    // an index into a shared table instead of a pointer
	Tags   [4]uint16 // a fixed-size array of small numbers: nothing to scan
}

A cache of 10 million PointerFree values stored in a []PointerFree is nearly invisible to the GC's mark phase (a map[string]PointerFree brings the pointers back: every string key is one); 10 million PointerHeavy values means tens of millions of pointers to follow on every cycle. This is why large in-memory caches in Go often store data as []byte or in big arrays indexed by integers.

And in JavaScript?

In V8, short-lived objects are cheap — that's what the Scavenger is built for (§05). On this machine, a loop allocating a million small temporary objects was no slower than one reusing a single object, because V8's optimizing compiler removed most of those allocations entirely. What costs real time is objects that survive: they get copied, promoted, and then marked on every major GC. So in JS:

  • Don't worry about small temporaries inside a function.
  • Do watch anything that keeps objects alive across requests: module-level arrays and maps, queues, memoized results.
  • For hot numeric code, reuse a preallocated typed array (new Float64Array(n)) instead of building new arrays each call; typed arrays store raw numbers, so there's nothing inside them to scan.
Staff-level§11

Production: latency, containers, weak refs and other designs

In one sentenceAt scale, GC is a budget question: how much CPU, how much memory, and how much tail latency you're willing to spend so nobody has to call free.

Latency vs throughput

Every collector picks a point between two goals:

  • Throughput: the smallest total share of CPU spent on GC. Easiest with big, rare, stop-the-world collections. Great for batch jobs.
  • Latency: the shortest pauses. Needs concurrent and incremental work, write barriers, spare cores — so it usually costs more total CPU.

Go and V8 both lean towards latency. Java lets you choose: the Parallel collector for throughput, G1 (the default) for a balance, ZGC for very short pauses.

How GC shows up in p99

Pauses don't move your average much; they move your tail. A worked example: a Node service handles 1 000 requests/s, and a major GC pauses the main thread for 50 ms once every 10 s.

  • Every request that arrives or is in flight during those 50 ms waits — up to 50 ms, about 25 ms on average: roughly 1 000 × 0.05 = 50 requests per pause.
  • That's 50 out of 10 000 requests per 10 s = 0.5% of requests.
  • Average latency barely moves. Your p99 barely moves either: only 0.5% of requests are hit, so they all fit inside the slowest 1%, and the p99 cut-off still lands on an ordinary request. p99.9 is what jumps, by up to 50 ms.
  • Make the pause happen every 3 s instead, and about 1.7% of requests are hit — now p99 moves too.

That's why GC appears in Tail latency investigations, not average ones. In Go, the stop-the-world pauses are usually too small to matter; what shows up instead is mark assists (§07) — a request that allocates heavily while a cycle is running is made to help mark, and takes longer. Lowering the allocation rate (§10) helps both.

Containers: tell the runtime about the limit

A container's memory limit is enforced by the kernel through cgroups: go over it and the process is killed instantly (OOMKilled, exit 137), with no chance to collect garbage first. (Threads & Processes §11 explains containers and OOM kills.) The runtime's job is to never get there, but it can only do that if it knows the budget.

GoNode
The knobGOMEMLIMIT (soft, total Go-managed memory)--max-old-space-size (hard, V8 old generation only)
Reads the container limit itself?No. Go 1.25+ reads the container's CPU limit for GOMAXPROCS, but not the memory limit (checked against Go 1.26) — set GOMEMLIMIT yourselfDepends on the Node version and setup — don't assume. Check v8.getHeapStatistics().heap_size_limit inside the container, or set the flag
Rule of thumb, 1 GiB containerGOMEMLIMIT=900MiB--max-old-space-size=768 (or ~75%), more headroom if you use many Buffers
What's outside the knobcgo/C memory, the binary itselfYoung generation, Buffers, native addons, code
# Kubernetes: give the runtime ~90% (Go) or ~75% (Node) of the container limit
resources:
  limits:
    memory: 1Gi
env:
  - name: GOMEMLIMIT
    value: "900MiB"
  # for a Node container instead:
  # - name: NODE_OPTIONS
  #   value: "--max-old-space-size=768"

Weak references and finalizers: useful, but not for cleanup

Sometimes you want to point at an object without keeping it alive — for a cache that may be emptied by the GC, or to attach data to objects you don't own. That's a Weak reference. And sometimes you want to know when an object was collected; that's a Finalizer / cleanup. Both languages have them:

weak.go (Go 1.24+)
import (
	"fmt"
	"runtime"
	"time"
	"weak" // standard library since Go 1.24, not a third-party package
)

type Blob struct{ data [1 << 20]byte }

func main() {
	b := &Blob{}
	w := weak.Make(b) // a weak pointer: does NOT keep b alive

	runtime.AddCleanup(b, func(name string) {
		fmt.Println("cleanup ran for", name) // must not use b itself
	}, "blob-1")

	fmt.Println("before:", w.Value() != nil) // true: b is still in use below
	runtime.KeepAlive(b)                      // b is reachable up to here

	runtime.GC()                      // b is now unreachable
	time.Sleep(10 * time.Millisecond) // cleanups run on a separate goroutine
	fmt.Println("after: ", w.Value() == nil)
}
// Output on this machine (Go 1.24):
// before: true
// cleanup ran for blob-1
// after:  true

Now the warnings. The callback runs some time after the object is collected — or never:

  • Timing is unknown. A collection may not happen for minutes on a quiet process. Closing a file or a database connection in a cleanup means it stays open for an unknown time; under load you run out of them first.
  • It may never run. The JavaScript spec explicitly allows engines to skip FinalizationRegistry callbacks, and they don't run if the process exits first. While testing this very example, a slightly different version (with a WeakRef to a created in the same tick) printed nothing at all.
  • Easy to break. In Go, if the cleanup function refers to the object itself (func() { b.Close() }), the object stays reachable through the cleanup and is never collected. That's why AddCleanup passes a separate arg.

Use explicit cleanup — defer f.Close() in Go, try { … } finally { conn.release() } in JS — and treat finalizers only as a safety net that logs "someone forgot to close this". For "data attached to an object", JavaScript's WeakMap is the everyday tool: its keys are held weakly, so entries vanish with their key object, no callbacks involved.

Other designs, briefly

1

ZGC & Shenandoah (Java)

Concurrent compacting collectors: they move objects while the program runs, using a load barrier on every pointer read to fix up references. Pauses stay around a millisecond or less, even for heaps of hundreds of GB. Cost: more CPU and memory overhead. ZGC became generational in JDK 21.
2

ARC (Swift)

The compiler inserts reference-count updates (§02's reference counting, done automatically). No collector thread, no pauses, memory freed immediately — but reference cycles leak unless you mark one side weak, and every reference copy costs a counter update.
3

Ownership (Rust)

No runtime collector at all. The compiler tracks who owns each value and inserts the free at the end of its owner's scope; code that could use-after-free doesn't compile. Shared ownership falls back to reference counting (Rc, Arc).

CPython (the standard Python) uses reference counting plus a tracing cycle collector for exactly the leak you saw in §02's reference-counting mode. Every design is a different answer to the same question: who proves that nobody can use this memory any more — the programmer, the compiler, or the runtime?

Practitioner§12

Pitfalls gallery

#1

A cache with no size limit

A map or Map used as a cache, with entries added per user or per request and never removed, is the most common leak in GC'd code. Everything in it is reachable, so the GC can't help (§09).

✕ Don't
const cache = new Map();
function getUser(id) {
  if (!cache.has(id)) cache.set(id, loadUser(id));
  return cache.get(id);          // grows with every new id, forever
}
✓ Do
const cache = new Map();
const MAX = 10_000;
function getUser(id) {
  if (!cache.has(id)) {
    if (cache.size >= MAX) cache.delete(cache.keys().next().value); // drop the oldest
    cache.set(id, loadUser(id));
  }
  return cache.get(id);
}

A JS Map remembers insertion order, so cache.keys().next().value is the oldest key. For a real LRU cache, use a library (lru-cache in Node, hashicorp/golang-lru in Go).

#2

Calling the GC by hand to 'fix' memory

runtime.GC() in Go and global.gc() in Node (with --expose-gc) force a full, blocking collection. Sprinkled into request handlers "to keep memory down", they add a big pause to that request, and they can't free anything that's still reachable — so they don't fix leaks. Use them in tests, benchmarks and demos only. If memory is the problem, find what's holding it (§09) or set a limit (§08).

#3

Closing resources in a finalizer

Files, sockets and database connections are limited resources. If they're only closed when the GC gets around to collecting their wrapper, you run out of them long before you run out of memory. Close them explicitly (defer f.Close(), finally { … }) and use finalizers, if at all, only to log a warning (§11).

#4

Setting the heap limit equal to the container limit

--max-old-space-size=1024 in a 1 GiB container, or GOMEMLIMIT=1GiB in the same, leaves no room for the memory each knob doesn't count — in Node: Buffers, the young generation, compiled code; in Go: cgo memory and the binary itself. The kernel kills the process before the runtime reacts. Leave 10–25% headroom (§11).

#5

Keeping a small piece of something big

func firstLine(body []byte) []byte {
	i := bytes.IndexByte(body, '\n')
	if i < 0 {
		return body
	}
	return body[:i] // ✗ keeps all of body alive — use bytes.Clone(body[:i])
}

Storing that result keeps the whole request body alive. The same applies to s[a:b] on strings in Go, and to regex matches and substring results of very long strings in JS (§09).

#6

Timers and intervals that are never stopped

✕ Don't
function watch(user) {
  setInterval(() => refresh(user), 1000); // runs forever and keeps `user` alive
}
✓ Do
function watch(user) {
  const id = setInterval(() => refresh(user), 1000);
  return () => clearInterval(id);         // caller stops it when done
}

In Go the same goes for time.NewTicker: call ticker.Stop() (usually with defer) when the goroutine using it returns.

#7

GOGC=off without GOMEMLIMIT

GOGC=off alone disables collection entirely: the heap grows until the OS or container kills the process. It's only safe together with GOMEMLIMIT, and only when the live heap stays well below that limit (§08).

#8

Blaming the GC without evidence

High memory isn't the same as a leak (a GC'd heap is supposed to grow to its goal, e.g. 2× live in Go), and slow requests aren't always GC pauses. Check the live heap after collections and line slow requests up with GC events before tuning anything (§07, §11).

Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §02, Q2 in §07–§08, Q3–Q4 in §09, Q5 in §06 and §11, Q6 in §05.

QUESTION 1 / 6
Two objects that point at each other
let a = { name: "a" };
let b = { name: "b" };
a.other = b;
b.other = a;
a = null;
b = null;
// …a GC runs
QUESTION 2 / 6
The heap goal

After a GC, a Go service has 200 MB of live heap. GOGC is the default (100) and no GOMEMLIMIT is set. Roughly when does the next GC cycle aim to be finished?

QUESTION 3 / 6
A cache with no exit

handle runs once per request, and every request has a new id. Does the GC free the 1 MB slices?

var cache = map[string][]byte{}

func handle(id string) {
    cache[id] = make([]byte, 1<<20) // 1 MB
}
QUESTION 4 / 6
16 bytes, or more?
func readHeader(path string) []byte {
    data, _ := os.ReadFile(path) // each file is 100 MB
    return data[:16]
}

var headers [][]byte // stores 10 results
QUESTION 5 / 6
Node in a 512 MB container

A Node service runs in a container with a 512 MB memory limit and is started with --max-old-space-size=512. What's the risk?

QUESTION 6 / 6
Why are minor GCs cheap?

V8's young generation holds 10 000 objects when the Scavenger runs. Why is that collection still fast?

Answered 0 / 6
Foundations§14

Cheat sheet

🧹
Garbage = unreachableKept: anything reachable from a root (stacks, globals, registers). Not "unused" — reachable.
🔁
Tracing beats counting on cyclesGo and V8 trace from the roots, so cycles are collected. Reference counting (Swift ARC) leaks cycles without weak.
⚪
Tri-color + write barrierWhite = unseen, grey = to do, black = done. A black object must never point at a white one; the write barrier enforces it while your code runs.
👶
Most objects die youngV8 copies only nursery survivors (Scavenger) and collects the old generation rarely (Mark-Compact). Go isn't generational; escape analysis keeps temporaries off the heap.
⚖️
GOGC trades memory for CPUHeap goal = live × (1 + GOGC/100). Bigger GOGC → fewer cycles, more memory.
📦
Tell the runtime the budgetGOMEMLIMIT ≈ 90% of the container; Node --max-old-space-size ≈ 75%. Never 100%.
🧲
Leaks are things you still holdUnbounded maps, forgotten listeners and timers, blocked goroutines, closures, sub-slices.
🔍
Diff, don't guessTwo pprof heap profiles (-diff_base) or two heap snapshots (Comparison view, then Retainers).
I want to…GoNode
See every GCGODEBUG=gctrace=1 ./appnode --trace-gc app.js
Trade memory for less GC CPUGOGC=200--max-semi-space-size=64 (bigger nursery)
Cap memoryGOMEMLIMIT=900MiB (soft)--max-old-space-size=768 (hard)
Find a leak/debug/pprof/heap, twice, go tool pprof -diff_baseDevTools Memory tab: two snapshots, Comparison
Count leaked goroutines / listenersruntime.NumGoroutine(), /debug/pprof/goroutineemitter.listenerCount(name)
Allocate lesspre-size make([]T, 0, n), sync.Pool, fewer pointersreuse typed arrays; don't hold request objects across requests
Measure allocationsgo test -bench . -benchmem--trace-gc, process.memoryUsage()
Hold without keeping aliveweak.Make (Go 1.24+)WeakMap, WeakRef
Free a resourcedefer x.Close() — never a finalizertry … finally — never FinalizationRegistry
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in OS & RuntimeVirtual Memory
Read →