API Security, Identity & Zero-TrustPlaybook3 min readUpdated September 2026

Profiling a Memory Leak in Node or Go Before It Pages You

A memory leak rarely announces itself. It shows up as a service that gets slower over days, then starts restarting on its own from an out-of-memory kill, and by the time anyone's looking at it directly, the leak's already been running in production for a while.

Node and Go leak in genuinely different ways, because their memory models are different, which means the profiling approach for each one is different too.

How do you confirm it's a real memory leak before profiling?

Plot memory usage over a long enough window, hours to days, not minutes. A real leak shows steady growth that never comes back down between garbage collection cycles.

A high but stable memory footprint, even one that looks alarming on a dashboard, isn't a leak, it's your actual working set, and profiling for a leak that isn't there wastes time better spent elsewhere. Confirm the trend before opening a profiler.

Node: heap snapshots and the closures that hold on too long

Take two heap snapshots, minutes or hours apart under normal load, and compare them in a heap profiling tool, looking specifically at what grew between the two.

The most common Node-specific leak pattern is a closure or event listener that holds a reference to something it shouldn't, most often an event emitter that accumulates listeners because code adds one on every request instead of once at startup, or a cache with no eviction policy that grows unbounded because nothing ever calls delete on it.

Is growing Go memory usually a goroutine leak?

Go's garbage collector handles most heap leak patterns reasonably well, so a genuinely growing memory footprint in a Go service is more often a goroutine leak: a goroutine started to handle something, blocked forever on a channel nobody's ever going to write to or a context that never gets canceled, holding onto whatever memory it captured in its closure indefinitely.

A goroutine profile, not just a heap profile, is where this pattern shows up: a goroutine count that climbs steadily and never comes back down between requests is a strong signal, even before you look at heap size at all.

A checklist before you conclude it's fixed

  • Does memory usage actually plateau over a multi-hour or multi-day window under normal traffic, not just look better for the first several minutes after a deploy?
  • For Node, did the fix address the specific reference that was being held, event listener, cache entry, closure, not just a general clean-up-more-often change that masks the symptom?
  • For Go, does goroutine count also plateau, not just heap size, since a goroutine leak with small per-goroutine memory can take a long time to show up as a heap problem even while it's a real leak?
  • Has the fix been validated under the same load pattern that originally triggered the leak, not just under light testing traffic?

A worked example: tracing a leak back to a single closure

Say a Node service's memory climbs steadily over several days with no traffic pattern that explains it, and a heap snapshot comparison shows a steadily growing array retained by a single module-level variable. Tracing that variable back through the code shows a request handler pushing an item into it on every call and never removing anything, effectively an unbounded in-memory log nobody intended to keep forever.

The fix is almost always smaller than the investigation: cap the array's size, add a time-based eviction, or move the data to somewhere with an actual expiry, like a cache with a TTL. The time cost here is almost entirely in confirming the trend and narrowing down the reference, not in writing the eventual fix.

Setting a memory ceiling as a safety net, not a fix

A hard memory limit that restarts a process before it exhausts the host's memory entirely is good operational hygiene regardless of whether you have an active leak, since it turns a slow leak into a periodic, low-impact restart instead of a full host-level outage. It is not a substitute for actually finding and fixing the leak: a service restarting every few hours to work around unbounded growth is burning latency and dropped requests on every restart, and that cost compounds even if no single restart looks alarming on its own.

Executive Capability Standard

What Good Looks Like

A good memory leak investigation confirms steady growth over hours or days before profiling anything, and checks the leak pattern specific to the runtime, heap references in Node, goroutine counts in Go, rather than applying a generic fix.

Building The Capability (5-Stage Skill Ladder)

1. Learn:Set up a memory usage dashboard with enough history, hours to days, to actually confirm whether a service is leaking or just running at a stable, high working set.
2. Do Manually:Manually take heap or goroutine snapshots at intervals during a suspected leak and compare them by hand to find what's growing.
3. Delegate:Assign a backend engineer familiar with the specific runtime's memory model to own leak investigations, since Node and Go leaks need genuinely different diagnostic approaches.
4. Automate:Add memory and goroutine count metrics to your standard service dashboards and alert on a sustained upward trend, not just an absolute threshold.
5. Buy:A backend performance specialist is worth bringing in when a leak resists diagnosis with standard profiling tools and is affecting a production service badly enough to need outside eyes.

How to Get Started

Frequently Asked Questions

How do we tell a memory leak from a normal, high working set?

Plot memory usage over hours or days, not minutes. A real leak shows steady growth that never comes back down between garbage collection cycles. A high but stable footprint, even if it looks alarming, is your actual working set, not a leak, and doesn't need the same fix.

What's the most common cause of a Node.js memory leak?

An event emitter accumulating listeners because code adds one on every request instead of once at startup, or a cache with no eviction policy that grows unbounded because nothing ever removes old entries. Comparing two heap snapshots taken under normal load usually surfaces the specific reference that's growing.

What should we check when a Go service's memory keeps growing but the heap profile looks normal?

Check the goroutine profile, not just the heap profile. A goroutine leak, one blocked forever on a channel or an uncanceled context, holds onto its captured memory indefinitely even when each individual goroutine's footprint is small, and it shows up first as a climbing goroutine count.

About the numbers

This guide doesn't quote a sourced benchmark. Figures in it are estimates or general guidance, so check them against your own numbers.

Related Guides