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.
The 30-second version
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.)
1User *u = malloc(sizeof(User)); // ask the allocator for memory2u->name = "Ana";3free(u); // hand it back…4Order *o = malloc(sizeof(Order)); // …the allocator reuses the spot5o->total = 99;6printf("%s", u->name); // use-after-free!7free(u); // double free!
malloc returns the address of a free spot — here 0x10 — and u stores that address (it's a pointer).What a GC buys you, and what it costs
Safety
Simplicity
return &User{} or return {} and never think about who frees it.The bill
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).
Garbage = unreachable
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 andglobalThis). - 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` → liveTwo ways to find the garbage
Reference counting
shared_ptr.Tracing
The difference
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.
A few things to try:
- Tracing mode: delete
Request → Theme, run the GC. The Theme survives —Configstill points at it. One path from a root is enough. - Tracing mode: delete
req → Request. Request, User, Avatar and Thumb all go in one collection, cycle and all. - Reference counting: delete
User → Avatar. The Avatar ↔ Thumb pair stays forever, in yellow: a leak.
Mark & sweep
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.
1function collect() {2 stopTheWorld(); // pause the program3 for (const r of roots) mark(r);4 sweep();5 resumeTheWorld();6}7function mark(obj) {8 if (obj.marked) return; // seen it9 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); // garbage16 }17}
The two phases, in plain words
- 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.
- 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.
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.
Marking while the program runs: tri-color and write barriers
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:
| Word | Meaning | Kitchen analogy |
|---|---|---|
| Parallel | The 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. |
| Incremental | The GC's work is cut into small slices between bits of program work. | One busser clears two tables, steps aside, clears two more… |
| Concurrent | The 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:
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.
1// Your program, while the GC marks.2// Copy B's pointer to C into A:3a.child = b.child4// Then remove it from B:5b.child = nil
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 = cYou 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.
Most objects die young: generational GC
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:
- 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.
- 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.
- 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:
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`
}Node's collector: V8 and Orinoco
Everything from §02–§05 shows up in V8, the engine that runs JavaScript in Node and Chrome:
| Part of the heap | What lives there | Collector | Technique |
|---|---|---|---|
| Young generation ("new space") | Every new object | Scavenger (minor GC) | Semi-space copying (§05), parallel on several threads |
| Old generation ("old space") | Objects that survived two scavenges | Mark-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.
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:
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 300000Reading one line, left to right:
| Piece | Meaning |
|---|---|
[9723:0xa3a6000] | Process id, and the address of this V8 instance (Node can run several, e.g. one per worker thread) |
49 ms | Time since the process started |
Scavenge / Mark-Compact | Which collector ran: minor or major |
6.1 (8.8) -> 4.7 (8.8) MB | Heap in use before (and memory reserved for the heap) → after |
pooled: 0 MB | Memory pages V8 keeps aside to reuse instead of returning them to the OS |
0.60 / 0.00 ms | How long JavaScript was paused, then time spent in Node's own GC callbacks (usually 0) |
mu = 0.775 | Mutator utilization: the share of time your JavaScript got to run, rather than the GC. 1.000 = no GC time yet |
allocation failure | The 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:
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 $?
134Read 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…)
});Go's collector: concurrent, non-moving, non-generational
Go's design is the opposite of V8's in two ways, and both are deliberate:
Non-generational
Non-moving
Concurrent
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
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:
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 100000Take gc 11 apart:
| Piece | Meaning |
|---|---|
gc 11 | The 11th GC cycle since the program started |
@0.065s | Started 65 ms after the program started |
9% | Share of all CPU time so far spent on GC |
0.016+2.3+0.055 ms clock | Wall-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 cpu | CPU time for the same phases; the middle is split into mark assists / background marking / marking on otherwise idle cores |
17->19->10 MB | Heap when the cycle started → heap when marking finished (the program kept allocating) → live heap (what was marked) |
20 MB goal | The heap goal this cycle was aiming for |
0 MB stacks, 0 MB globals | Goroutine stacks and globals that had to be scanned (they count toward the goal too) |
4 P | Number 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.
Tuning Go: GOGC and GOMEMLIMIT
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=100the 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=50the 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=200the 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:
What the presets show:
- defaults — 300 MB live,
GOGC=100: the classic sawtooth between 300 and 600 MB. - bigger live heap — 600 MB live: the goal is now 1.2 GB and the container dies.
- …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. - 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.
- 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 100000Doubling 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 limitThere'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).
Memory leaks in garbage-collected languages
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
| Leak | Why it stays reachable | Fix |
|---|---|---|
| Cache or map with no eviction | It's a global (a root); every entry is reachable through it | Cap its size (LRU), expire entries, delete them |
| Listener / callback never removed | The emitter's listener list points at your callback, and the callback's closure points at whatever it uses | Unsubscribe when done (see Observer leaks) |
Timer never cleared (setInterval, time.Ticker) | The runtime's timer list points at the callback | clearInterval, ticker.Stop() |
| Goroutine blocked forever | A running (or blocked) goroutine's stack is a root, so everything it references stays alive | Give every goroutine a way out (see Channels: cancellation) |
| Closure capturing a big object | The function value keeps every variable it uses alive | Copy out only what you need before creating the closure |
| Small slice or string of a big one | It shares the big one's memory | Copy 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 limitThe 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
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:
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.
- Start Node with
node --inspect server.js, openchrome://inspectin Chrome and click inspect. - In the Memory tab, take a heap snapshot. (Taking one runs a full GC first, so only reachable objects appear.)
- Put the suspect code path under load (send a few hundred requests), then take a second snapshot.
- Select the second snapshot and switch the view from Summary to Comparison. Sort by Size Delta: the object types that grew are your suspects.
- 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.
Giving the GC less work
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:
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/opRead 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.
Production: latency, containers, weak refs and other designs
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.
| Go | Node | |
|---|---|---|
| The knob | GOMEMLIMIT (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 yourself | Depends 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 container | GOMEMLIMIT=900MiB | --max-old-space-size=768 (or ~75%), more headroom if you use many Buffers |
| What's outside the knob | cgo/C memory, the binary itself | Young 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:
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: trueNow 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
FinalizationRegistrycallbacks, and they don't run if the process exits first. While testing this very example, a slightly different version (with aWeakReftoacreated 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 whyAddCleanuppasses a separatearg.
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
ZGC & Shenandoah (Java)
ARC (Swift)
weak, and every reference copy costs a counter update.Ownership (Rust)
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?
Pitfalls gallery
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).
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
}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).
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).
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).
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).
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).
Timers and intervals that are never stopped
function watch(user) {
setInterval(() => refresh(user), 1000); // runs forever and keeps `user` alive
}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.
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).
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).
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.
let a = { name: "a" };
let b = { name: "b" };
a.other = b;
b.other = a;
a = null;
b = null;
// …a GC runsAfter 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?
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
}func readHeader(path string) []byte {
data, _ := os.ReadFile(path) // each file is 100 MB
return data[:16]
}
var headers [][]byte // stores 10 resultsA 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?
V8's young generation holds 10 000 objects when the Scavenger runs. Why is that collection still fast?
Cheat sheet
weak.GOMEMLIMIT ≈ 90% of the container; Node --max-old-space-size ≈ 75%. Never 100%.-diff_base) or two heap snapshots (Comparison view, then Retainers).| I want to… | Go | Node |
|---|---|---|
| See every GC | GODEBUG=gctrace=1 ./app | node --trace-gc app.js |
| Trade memory for less GC CPU | GOGC=200 | --max-semi-space-size=64 (bigger nursery) |
| Cap memory | GOMEMLIMIT=900MiB (soft) | --max-old-space-size=768 (hard) |
| Find a leak | /debug/pprof/heap, twice, go tool pprof -diff_base | DevTools Memory tab: two snapshots, Comparison |
| Count leaked goroutines / listeners | runtime.NumGoroutine(), /debug/pprof/goroutine | emitter.listenerCount(name) |
| Allocate less | pre-size make([]T, 0, n), sync.Pool, fewer pointers | reuse typed arrays; don't hold request objects across requests |
| Measure allocations | go test -bench . -benchmem | --trace-gc, process.memoryUsage() |
| Hold without keeping alive | weak.Make (Go 1.24+) | WeakMap, WeakRef |
| Free a resource | defer x.Close() — never a finalizer | try … finally — never FinalizationRegistry |