All articles
Troubleshooting#nodejs#memory leak#performance#debugging

How to find and fix memory leaks in Node.js

Memory that only grows ends in "JavaScript heap out of memory" or an OOM kill. Here is how to confirm a Node.js memory leak, capture and compare heap snapshots, and fix the usual culprits: caches, listeners, closures and timers.

FX

Codestria Engineering

Engineering team

6 min read

Your service runs fine after a deploy, then memory climbs over hours or days until the process dies with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, or the container is OOMKilled without a message at all. Restarts make the problem go away for a while, which is how many teams end up with a nightly restart cron and no root cause.

Memory leaks in Node.js are almost always findable. This guide covers how to confirm you have one, how to capture heap snapshots that point at the leaking objects, and the four patterns responsible for most leaks we encounter.

Why it happens

JavaScript is garbage collected: V8 frees any object that is no longer reachable from a root (global scope, the current stack, active handles). A leak in Node.js is therefore not memory that was forgotten, but memory that is still reachable when you no longer need it. Something holds a reference: a module-level Map, an event emitter's listener array, a closure captured by a timer that never stops.

Because a Node.js server is a single long-lived process, references that grow by a few kilobytes per request add up. A web page leak disappears when the tab closes; a server leak accumulates until the heap limit or the container limit is reached.

Step 1: Confirm it is a leak

Graph process.memoryUsage() over time under steady load. The key number is heapUsed after garbage collection. A healthy service shows a sawtooth that settles at a plateau. A leaking one shows a sawtooth whose low points keep rising. If rss grows but heapUsed does not, the growth is outside the JavaScript heap: large Buffer allocations (counted in external and arrayBuffers), native modules, or memory fragmentation.

src/observability/memory.ts
const MB = 1024 * 1024;

export function startMemoryLogging(intervalMs = 60_000) {
  const timer = setInterval(() => {
    const m = process.memoryUsage();
    console.log(
      JSON.stringify({
        msg: "memory",
        rssMb: Math.round(m.rss / MB),
        heapUsedMb: Math.round(m.heapUsed / MB),
        heapTotalMb: Math.round(m.heapTotal / MB),
        externalMb: Math.round(m.external / MB),
        arrayBuffersMb: Math.round(m.arrayBuffers / MB),
      }),
    );
  }, intervalMs);

  // Don't keep the process alive just for this timer
  timer.unref();
  return () => clearInterval(timer);
}

If you already export metrics with OpenTelemetry or prom-client, the default Node.js runtime metrics include heap usage per space, which is even more useful: a leak usually shows up as steady growth in the old space.

Step 2: Reproduce it and capture heap snapshots

A heap snapshot is a full dump of every object in the JavaScript heap and what references it. One snapshot tells you what is big; comparing snapshots over time tells you what is growing, which is what you actually want. The standard technique is three snapshots: warm up the app, take snapshot 1, run load, take snapshot 2, run the same load again, take snapshot 3. Objects that appear in growing numbers between each pair are your suspects.

Bash
# Start the app with the inspector bound to localhost only
node --inspect=127.0.0.1:9229 dist/server.js

# Open chrome://inspect in Chrome, click "inspect" under the target,
# then Memory -> Heap snapshot -> Take snapshot.

# Or write snapshots on a signal, without DevTools attached
node --heapsnapshot-signal=SIGUSR2 dist/server.js
kill -USR2 <pid>   # writes Heap.<date>.<pid>...heapsnapshot to the cwd

# Generate repeatable load between snapshots
npx autocannon -c 50 -d 60 http://localhost:3000/api/orders

Load the snapshots into Chrome DevTools' Memory tab. Select the last snapshot and switch the view to Comparison against an earlier one, then sort by # Delta or Size Delta. Pick a growing constructor, expand an instance, and read the Retainers panel from bottom to top. The retainer chain shows exactly which variable, map or listener array keeps the object alive, and usually points straight at a line of your code.

Step 3: Fix the common leak sources

Unbounded caches

The most common leak is a module-level Map or object used as a cache with no eviction. Every unique key, such as a user ID, a URL with query parameters, or a tenant, adds an entry that lives forever. Give every in-process cache a maximum size and a TTL, for example with the lru-cache package, or move it to Redis where eviction is handled for you.

src/cache/pricing.ts
import { LRUCache } from "lru-cache";

// Before: grows forever, one entry per unique key
// const cache = new Map<string, Price>();

type Price = { amount: number; currency: string };

const cache = new LRUCache<string, Price>({
  max: 5_000,          // hard cap on entries
  ttl: 5 * 60 * 1000,  // and a 5-minute lifetime
});

export async function getPrice(sku: string, load: (sku: string) => Promise<Price>) {
  const hit = cache.get(sku);
  if (hit) return hit;
  const price = await load(sku);
  cache.set(sku, price);
  return price;
}

Event listeners that are never removed

Adding a listener inside a request handler, to a long-lived emitter such as a database client, a process event or a shared socket, adds one closure per request. Node warns about this with MaxListenersExceededWarning: Possible EventEmitter memory leak detected. Do not silence the warning by raising setMaxListeners. Remove the listener when the request finishes, use once when you only need one event, or pass an AbortSignal so cleanup happens automatically.

Closures capturing large objects

A closure keeps alive every variable it references. A callback stored in a long-lived structure that mentions the whole req object, a parsed file, or a large array retains all of it. Extract only the fields you need before creating the closure, and be careful with arrays of pending callbacks or promise maps that are never cleaned up when a request errors or times out.

Timers and intervals

A setInterval keeps its callback, and everything that callback references, alive until it is cleared. Intervals created per connection, per job or per tenant without a matching clearInterval are a slow, steady leak. The same applies to retry timers that keep rescheduling themselves after the work they were for has been abandoned.

When snapshots show growth in generic types such as (string), (array) or Object and the retainers are hard to read, record an Allocation instrumentation on timeline in the Memory tab while sending a few requests. It attributes allocations that survive to the function that created them, which is often faster than tracing retainers by hand.

Prevention tips

  • Alert on heap used after GC trending upward over several hours, not just on absolute memory.
  • Ban unbounded module-level collections in code review; every cache needs a size limit.
  • Pair every on, setInterval and subscription with a cleanup path, and test the error and timeout paths too.
  • Set the container memory limit and --max-old-space-size deliberately so the heap limit sits comfortably below the container limit, leaving room for buffers and native memory.
  • Run a soak test (hours of steady load) before major releases and compare memory at the start and end.

Key takeaways

  • A Node.js leak is memory that is still reachable; find the reference holding it.
  • Confirm with heap-used-after-GC trends before chasing a leak; growing RSS alone may be buffers or native memory.
  • Compare multiple heap snapshots and read the retainer chain of the objects that grow.
  • Unbounded caches, forgotten listeners, capturing closures and uncleared timers cause most leaks.

If a service needs regular restarts to stay healthy, our backend engineering team can profile it under realistic load and fix the root cause. Start a conversation.

Frequently asked questions

How do I know if my Node.js app has a memory leak or just uses a lot of memory?

Watch heap usage over hours under steady traffic. Normal usage rises and falls with garbage collection and settles at a plateau. A leak keeps rising: each low point after a GC is higher than the last.

Does increasing --max-old-space-size fix a memory leak?

No. It gives the process more room before it crashes. It is fine as a temporary measure or for apps that genuinely need a bigger heap, but a leak will still eventually exhaust it.

Is it safe to take a heap snapshot in production?

With care. Writing a snapshot pauses the process and can temporarily need roughly as much extra memory as the heap itself. Take it on one instance removed from the load balancer, or reproduce the leak in staging.

Found this useful? Share it with your team.

LinkedInX

Need help with backend & apis?

Secure, scalable backends and APIs with Node.js, Nest.js and Python, built for real traffic.

Learn more