Engineering Atlas › Problem Solving & Incidents (AI era)

Memory Leaks and Resource Exhaustion

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

What leaks

Memory is the famous one, but anything with a finite pool can leak:

Signatures

A leak looks like a sawtooth that never comes back down, or a straight line up. Normal memory use rises with load and falls after; a leak rises with the number of operations performed and stays. Plot the resource against cumulative requests rather than time: a leak of 2 KB per request is a straight line whose slope is the size of the leak.

The diagnosis loop

  1. Confirm and measure. Graph the resource over several days; estimate the growth rate and the time to exhaustion.
  2. Correlate. Does growth follow request count, a specific endpoint, a cron job, a deploy?
  3. Compare snapshots. Take a heap profile or resource listing now and again after N more operations. Diff by type and allocation site: Go's pprof with -base on the heap profile, plus its goroutine and threadcreate profiles for stuck goroutines and thread growth (Go diagnostics), Python's tracemalloc compare_to between two snapshots (Python docs), Chrome DevTools heap snapshots for Node, lsof -p PID for file descriptors.
  4. Find the retainer. For memory, ask what keeps the objects alive: the retainer path in a heap snapshot shows the chain of references from a root (a global, a cache, an event emitter) to the leaked objects.
  5. Reproduce in a test. Run the suspect operation 10,000 times in a loop and assert the resource count returns to baseline.

Common culprits

The same shape elsewhere

Resource limits can be hit by growth that is not a leak at all. The same curve appears when a fleet grows and every server holds one resource per peer (see In the wild), so alert on the margin to every hard limit, not only on slope.

Resource exhaustion appears outside programs too: a queue whose consumers fell behind, a ticket backlog, an on-call rotation that keeps adding alerts but never removes them. The pattern and the fix are the same: measure the growth, find what keeps adding without removing, and bound it.

Down the stack

Layer 1The contract

A service promises bounded resource use under steady load: memory, descriptors, connections and threads plateau rather than grow with the number of operations. Operationally that means each resource is exported as a metric, has a configured limit, and has an alert on both level and trend, such as "will exhaust within 24 hours at the current rate".

Layer 2The mechanism

Garbage collectors free objects that are unreachable from roots (globals, stacks, registers). Go uses a concurrent mark-and-sweep collector; the JVM and V8 use generational collectors that promote long-lived objects to an old generation, where leaked objects pile up and make each major collection slower. Heap profilers sample allocations: Go's runtime records a stack trace for roughly one allocation per 512 KB allocated by default (MemProfileRate, runtime docs), which is cheap enough to leave on in production; the Go team suggests profiling one randomly chosen replica at a time (Go diagnostics). Python's tracemalloc records every allocation, and storing more frames per trace costs more memory and CPU (Python docs), so it is used for targeted debugging. For descriptors, the kernel keeps a per-process table; /proc/PID/fd lists every open descriptor and what it points to.

Layer 3The machine

When memory grows, the process's resident set size (RSS) grows as the kernel maps physical pages on first touch, in 4 KB pages on most systems. In a container, the cgroup's memory.max caps it: when usage reaches the limit the kernel first tries to reclaim memory, and if it cannot, the OOM killer runs inside that cgroup (kernel cgroup v2 docs). Kubernetes then reports the container as OOMKilled with exit code 137 (128 plus signal 9, SIGKILL) and restarts it (Kubernetes docs). The softer memory.high throttles the cgroup under heavy reclaim instead of killing it, which shows up as latency before it shows up as a restart; the oom_kill counter in memory.events records kills. Before that point, the process may already slow down: garbage collectors run more often as the heap fills, and if swap is enabled, page faults to disk turn microsecond memory accesses into millisecond ones. Descriptor exhaustion is immediate: open() or accept() fails with EMFILE, and a server can stop accepting connections while its CPU looks idle.

Why it's built this way

  1. Every resource is finite, and programs acquire it in many places.
  2. Releasing it depends on every path, including error paths, doing the right thing.
  3. Garbage collection frees only unreachable memory, so forgotten references still leak.
  4. Leaks grow slowly, so they hide behind restarts and deploys until load changes.
  5. Hence: measure each resource, bound it, alert on trends and diff snapshots to find what grew.

Diagram

flowchart TD
  M["Resource metric climbs, never returns"] --> R["Estimate growth rate and time to exhaustion"]
  R --> C["Correlate with requests, endpoints, jobs, deploys"]
  C --> S1["Snapshot A"]
  S1 --> L["Run N more operations"]
  L --> S2["Snapshot B"]
  S2 --> D["Diff by type and allocation site"]
  D --> RET["Find the retainer: cache, listener, blocked goroutine"]
  RET --> T["Loop test: resource returns to baseline"]
  T --> FIX["Bound or release, add alert on trend"]

Worked example

An API service on Kubernetes is OOM-killed about every 30 hours. Each pod has a 2 GiB limit. Memory graphs show RSS climbing from 400 MiB after start by about 55 MiB an hour, regardless of the time of day. Restarts have been "fixing" it for weeks.

Plotted against requests, the growth is a straight line: about 1.1 KB per request at 50,000 requests an hour, which is 55 MiB an hour. Two heap profiles taken an hour apart and diffed with go tool pprof -base show 52 MiB of new memory in map[string]*tenantConfig allocated in loadTenantConfig. The code memoizes tenant configuration in a global map keyed by the request's X-Tenant header. A recent change made the header include a request-scoped suffix for tracing, so every request created a new key.

A loop test calling the handler 100,000 times with distinct headers shows the map growing to 100,000 entries. The fix strips the suffix before caching and replaces the map with a bounded LRU of 10,000 entries. The team also adds an alert: "memory projected to reach the limit within 12 hours". After the fix, memory plateaus at 450 MiB.

In the wild

Trade-offs

TechniqueStrengthsWeaknessesChoose when
Periodic restartsInstant reliefHides the bug, causes churnShort-term mitigation only
Continuous profiling in productionReal data, historySmall overhead, tooling costLong-running services
Heap snapshot diffPinpoints the growing typeLarge snapshots, pausesConfirmed leak, reproducible
Loop test in CICatches regressionsNeeds a representative operationHandlers and libraries with known leak risk
Bounded caches and poolsPrevents unbounded growthEvictions and timeouts to handleAny cache, pool or queue

Pitfalls in production

Leadership lens

Check yourself

  1. Why plot memory against cumulative requests rather than time?

    Show answer

    A per-operation leak becomes a straight line whose slope is the leak size, independent of traffic patterns that make time-based graphs noisy.

  2. A Go service's memory grows, and a goroutine profile shows 200,000 goroutines blocked on a channel receive. What is likely happening?

    Show answer

    Goroutines are started per request and wait for a result that never comes, often because the sender returned early on a timeout; each blocked goroutine and what it references is leaked.

  3. Why does a deleted log file sometimes not free disk space?

    Show answer

    A process still has it open; the kernel frees the blocks only when the last descriptor is closed.

  4. What does exit code 137 in Kubernetes usually mean?

    Show answer

    The container was killed with SIGKILL (128 plus 9), typically by the OOM killer when it exceeded its memory limit.

Sources and further study

Primary

Systems docs

Books

Other curricula

Free resources

Open in the interactive app: practice, quiz, mock interview →