Memory Leaks: How They Happen and How to Find Them


“Memory leak” in a garbage-collected language is a misnomer. The memory isn’t lost - the garbage collector knows where it is. The problem is that your code holds references to objects it no longer needs, preventing the GC from reclaiming them.

The practical effect is the same: memory grows over time, eventually causing out-of-memory crashes, GC pressure that degrades performance, or - in the worst case - slow degradation over days that’s hard to attribute to a specific cause.

What the GC Can and Can’t Free

A garbage collector reclaims memory when an object is no longer reachable - when nothing in your running code holds a reference to it, even transitively. The GC traces all live references starting from roots (global variables, stack frames, active closures) and anything not reachable is garbage.

The key word is reachable. The GC cannot free an object as long as any code holds a reference to it, even if that code never actually uses the object again. If you store objects in a global list and never remove them, the GC sees live references and keeps them in memory forever.

This is the definition of a memory leak in GC’d languages: retaining references longer than necessary.

Common Patterns in Node.js/JavaScript

Event listeners not removed: the most common source of leaks in frontend and Node.js code.

// Leak: adds a new listener on every call, never removes
function setupHandler(element) {
    element.addEventListener('click', handleClick);
}

// Fix: store reference and remove when done
function setupHandler(element) {
    const handler = (e) => handleClick(e);
    element.addEventListener('click', handler);
    return () => element.removeEventListener('click', handler);  // cleanup fn
}

An event emitter holds a reference to each listener function. If the listener function closes over other objects (captures them in scope), those objects are also kept alive.

Closures capturing large objects: a closure captures its surrounding scope. If a closure is long-lived (stored in a cache, attached as an event listener), everything it captured is long-lived too.

function processRequest(req, res) {
    const largeData = loadHugeDataset();  // 50MB

    // This closure captures largeData
    const handleTimeout = () => {
        if (!res.headersSent) res.status(504).send('Timeout');
    };

    setTimeout(handleTimeout, 30000);  // largeData is held for 30 seconds
    // Even after the request completes, largeData stays in memory
}

Fix: copy only what the closure needs, or explicitly null out references.

Growing caches without eviction:

const cache = new Map();

function getUser(id) {
    if (cache.has(id)) return cache.get(id);
    const user = fetchUser(id);
    cache.set(id, user);  // cache grows forever
    return user;
}

A cache with no eviction policy grows without bound. Fix with a max size and LRU eviction, or use WeakMap for object-keyed caches where the GC can collect entries when the key is no longer referenced elsewhere.

Timers not cleared:

// Interval keeps running even after component unmounts
const interval = setInterval(() => {
    updateDashboard();
}, 1000);

// Fix: clear on cleanup
return () => clearInterval(interval);

Common Patterns in Python

Circular references: Python’s reference counter can’t free objects that reference each other, even if nothing outside the cycle references them. The cyclic GC handles this, but it adds overhead and doesn’t run constantly.

class Node:
    def __init__(self, value):
        self.value = value
        self.parent = None
        self.children = []

parent = Node(1)
child = Node(2)
parent.children.append(child)
child.parent = parent  # circular reference

del parent  # ref count doesn't drop to 0 - cyclic GC will eventually free it

Use weakref.ref for back-references (child-to-parent) to break cycles without losing the reference.

Large objects held by long-lived structures:

# Growing list in a global/long-lived object
class EventStore:
    def __init__(self):
        self.events = []  # grows forever

    def record(self, event):
        self.events.append(event)  # never cleaned up

Generator and iterator exhaustion: generators hold a stack frame alive. A generator that’s created but never fully consumed (and not garbage collected) retains its frame.

Detecting Leaks

The primary indicator: memory usage that grows over time and doesn’t stabilize. A healthy long-running process has relatively stable memory after startup. A leaking process has memory that grows, may partially recover during GC, but trends upward.

Node.js: Chrome DevTools can attach to a running Node.js process and take heap snapshots. Compare two snapshots to see what objects were created between them and are still alive. The “allocation timeline” view shows objects allocated over time.

# Start Node.js with inspector
node --inspect app.js

# Then open chrome://inspect and take heap snapshots

The heapdump npm package can also take heap snapshots programmatically from within the application.

Python: tracemalloc is built in. It tracks memory allocations and can show you where memory is being allocated and held:

import tracemalloc

tracemalloc.start()

# ... run some code ...

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

for stat in top_stats[:10]:
    print(stat)

objgraph is useful for finding what’s holding references to specific object types:

import objgraph

objgraph.show_most_common_types(limit=10)
# Shows counts of all live objects by type

# Find what's holding references to MyLeakingClass instances
objgraph.show_backrefs(objgraph.by_type('MyLeakingClass')[0])

Go: pprof heap profiling is built in. Hit the /debug/pprof/heap endpoint and analyze with go tool pprof.

The Profiling Workflow

Memory leaks in production have a consistent diagnostic path. The workflow is the same regardless of language.

Step 1: Establish a baseline. What does normal memory look like? A healthy process has memory that stabilizes after startup and stays relatively flat under steady load. If you don’t have a baseline, you can’t tell if memory growth is a leak or expected behavior.

Step 2: Confirm the pattern. Memory that grows and never stabilizes is a leak. Memory that grows and recovers during GC is likely just high allocation rate under load, not a leak. Memory that grows slowly over days is a slow leak - the hardest to catch.

Step 3: Reproduce under controlled conditions. A load test with a fixed number of requests is more useful than production traffic - you can control the workload and measure memory growth per unit of work.

Step 4: Take snapshots. Two snapshots, before and after the leaky workload. The diff shows what was allocated and is still alive.

// Node.js: take heap snapshot via Chrome DevTools
// Start with: node --inspect app.js
// Then: chrome://inspect -> Memory tab -> Heap snapshot

// Or programmatically with heapdump:
const heapdump = require('heapdump');
heapdump.writeSnapshot('/tmp/before.heapsnapshot');
// ... run leaky workload ...
heapdump.writeSnapshot('/tmp/after.heapsnapshot');

Step 5: Follow the retention chain. The snapshot tells you what objects are alive. The retention chain (in DevTools: right-click an object -> “Show retaining paths”) tells you what’s keeping them alive. This is where you find the event listener, the cache entry, the closure that captured a large object.

Step 6: Fix and verify. Apply the fix. Run the same controlled workload. Confirm memory stays flat.

The tooling exists and is not difficult to use. The barrier is usually the habit: most teams only look for memory leaks after a production incident, by which point the system has been degraded for days. Building a memory monitoring alert (memory > X% over Y minutes) and reviewing heap profiles on a regular cadence catches leaks before they become incidents.



Read more