Skip to content
QSWEQB
hardScenarioConceptMidSeniorStaff

Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?

Take a heap profile and compare two taken minutes apart, because the shape of the growth tells you whether it is a leak, a cache with no bound, or simply the collector letting the heap grow. The two real knobs are GOGC and GOMEMLIMIT, and reducing allocation matters more in Go than in a generational runtime.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate distinguish a leak from a heap the collector has simply let grow
  • Whether they reach for a differential heap profile rather than a single snapshot
  • That GOGC is understood as a growth ratio rather than a size
  • Whether GOMEMLIMIT is connected to the container being killed rather than to the collector
  • Does the candidate name a realistic retention cause rather than describing the collector

Answer

First separate three different things

"Memory climbing" is three unrelated situations wearing one symptom, and picking the wrong one wastes a day.

The heap is growing because the collector let it. Go's collector does not run continuously; it starts a cycle when the heap has grown by a set proportion over the live set. Under increasing load a heap that oscillates between 400 MB and 800 MB is behaving correctly, and a graph of allocated bytes looks alarming while nothing is wrong.

Something is retained that should not be. The live set itself grows and never comes down. This is the actual leak, and the collector cannot help, because everything it can see is reachable.

Memory is not the heap at all. Goroutine stacks, mmaped files, cgo allocations and the runtime's own structures live outside it, so a process can grow with a flat heap. A goroutine leak presents this way: each blocked goroutine holds a stack and whatever it captured.

The first question to ask of a graph is therefore whether the troughs are rising. A sawtooth with a flat floor is the collector working. A rising floor is retention.

Get a differential heap profile

A single snapshot tells you what is on the heap, which is usually unsurprising. The diagnostic is two snapshots and the difference between them.

# With net/http/pprof mounted on an internal port.
curl -s localhost:6060/debug/pprof/heap > a.pprof
sleep 300
curl -s localhost:6060/debug/pprof/heap > b.pprof

# What grew between them, by what is still live.
go tool pprof -base a.pprof b.pprof
(pprof) top -cum
(pprof) list <suspect function>

The distinction that matters inside pprof is between the inuse and alloc views. inuse_space is what is live now, which is what you want for a leak. alloc_space is everything allocated since the process started, live or not, which is what you want when the problem is allocation rate rather than retention. Reading the second while hunting a leak sends you to whichever function allocates most, which is frequently innocent.

For growth outside the heap, /debug/pprof/goroutine?debug=1 gives a count and the stacks. A number that only ever rises is the answer on its own.

What actually retains memory

The collector frees what is unreachable, so a leak in Go is always something still reachable that you forgot about. The causes are a short list.

CauseHow it looksWhy it happens
Unbounded cacheLive heap rises in step with distinct keys seenA map with no eviction, often added "temporarily"
Slice of a large arraySmall live set, enormous heapA sub-slice keeps the whole backing array alive
Goroutine leakGoroutine count rises, heap followsBlocked on a channel nobody will write to
Ticker or timer not stoppedSlow, steady growthtime.NewTicker without defer t.Stop()
Appending to a package-level sliceLinear growth with trafficA metrics or audit buffer nobody drains
Finalisers or pooled objects heldHeap flat, resident size highObjects returned to a pool and never reused

The slice case is worth demonstrating because it is uniquely Go-shaped:

// Reads a 50 MB file and returns 20 bytes - and keeps the 50 MB alive,
// because the returned slice shares the original backing array.
func header(path string) []byte {
    data, _ := os.ReadFile(path)
    return data[:20]
}

// Copies out, so the large array becomes unreachable at the return.
func headerCopied(path string) []byte {
    data, _ := os.ReadFile(path)
    out := make([]byte, 20)
    copy(out, data)
    return out
}

The two knobs, and what each is for

GOGC is a ratio, not a size. At its default of 100 the collector aims to start a cycle when the heap has grown to twice the live set. Setting GOGC=50 collects at 1.5 times, which means a smaller heap and more frequent cycles — more CPU spent collecting. Setting it higher trades memory for throughput. It is the knob for the shape of the sawtooth.

GOMEMLIMIT, added in Go 1.19, is a soft ceiling on total memory and it solves a different problem. GOGC alone knows nothing about how much memory exists, so in a container with a hard limit the collector will happily let the heap grow past it and the kernel will kill the process — an out-of-memory kill the collector never saw coming, because from its point of view everything was fine. Setting GOMEMLIMIT a little below the container limit makes the runtime collect harder as it approaches, degrading throughput instead of dying.

The idiomatic modern configuration for a container is both: a GOMEMLIMIT near the container's limit as a backstop, and GOGC left at its default, or disabled entirely with the limit doing all the pacing when the workload's live set is predictable.

One behaviour to expect and not be alarmed by: the process's resident size often stays high after the live heap shrinks, because the runtime returns memory to the operating system lazily. The heap can be small while the process still looks large, which is another reason to trust the profile over the process table.

Why allocation rate matters more here

Go's collector is not generational. Most managed runtimes exploit the observation that objects die young by collecting a cheap nursery frequently and promoting survivors; Go's does not have one, so short-lived garbage costs more here than the equivalent in a JVM.

The practical consequence is that reducing allocations is a first-class optimisation in Go rather than a micro-optimisation, and the tooling supports it directly. The compiler will explain each decision with go build -gcflags='-m', and a benchmark reports allocations per operation:

go test -bench . -benchmem
# BenchmarkEncode-8   250000   4812 ns/op   2048 B/op   14 allocs/op

The allocs/op column is the number to move. The usual wins are unglamorous: preallocate a slice with make([]T, 0, n) when the size is known, reuse buffers through a sync.Pool on a genuinely hot path, avoid the interface boxing that fmt and any parameters cause, and pass large structs by pointer once copying them shows up in a profile.

The discipline that keeps this honest is to measure first. An allocation in a path that runs once at start-up is not worth a line of code.

A rising sawtooth floor is a leak; a rising sawtooth is the collector doing its job. Everything else follows from which of those two you are looking at.

Likely follow-ups

  • What does GOGC=50 actually change, and what does it cost you?
  • Why does the process resident size stay high after the live heap shrinks?
  • Which is worse in Go, a large heap or a high allocation rate, and why?
  • How would a goroutine leak show up differently from a retained cache?

Related questions

Further reading

garbage-collectionpprofmemorygomemlimitgolang