+1 (726) 227-3745

Debugging a Leaking MEAN API: Node 24 Heap Snapshots, Heap Profiles and Event Loop Delay

Every long-running MEAN application eventually gets the same ticket: "the API gets slower over the day and a restart fixes it." That is almost never a MongoDB problem and almost never an Angular problem. It is a Node.js process that is either leaking heap or blocking its event loop, and the two failure modes look identical from the outside.

This tutorial is the runbook we hand to clients. It covers how to prove which of the two you have, how to capture evidence from a production Express 5 / Mongoose 8 process on Node 24 without taking it down, and how to read the results. Versions: Node.js 24 LTS, Express 5, Mongoose 8, MongoDB 8.

Step 1: decide whether it is memory or the event loop

Before you profile anything, add a cheap always-on vitals endpoint. Node 24 exposes everything you need in core — no agent required.

src/vitals.js:

import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import v8 from 'node:v8';

const loop = monitorEventLoopDelay({ resolution: 10 });
loop.enable();

export function vitals() {
  const mem = process.memoryUsage();
  const heap = v8.getHeapStatistics();
  return {
    uptimeSec: Math.round(process.uptime()),
    rssMb: +(mem.rss / 1e6).toFixed(1),
    heapUsedMb: +(mem.heapUsed / 1e6).toFixed(1),
    heapLimitMb: +(heap.heap_size_limit / 1e6).toFixed(1),
    externalMb: +(mem.external / 1e6).toFixed(1),
    arrayBuffersMb: +(mem.arrayBuffers / 1e6).toFixed(1),
    handles: process._getActiveHandlesInfo?.().length ?? null,
    loopDelayMs: {
      mean: +(loop.mean / 1e6).toFixed(2),
      p99: +(loop.percentile(99) / 1e6).toFixed(2),
      max: +(loop.max / 1e6).toFixed(2),
    },
    eventLoopUtilization: +performance.eventLoopUtilization().utilization.toFixed(3),
  };
}

Mount it behind auth, never publicly:

import express from 'express';
import { vitals } from './vitals.js';

const router = express.Router();
router.get('/internal/vitals', requireOpsToken, (req, res) => res.json(vitals()));
export default router;

Now read the numbers after a few hours of traffic:

SymptomWhat you are looking at
heapUsedMb climbs monotonically across hours and never drops after GCJavaScript heap leak — go to Step 2
rssMb climbs but heapUsedMb is flat; externalMb / arrayBuffersMb climbNative/buffer leak — Step 4
Memory flat, loopDelayMs.p99 in the hundreds, eventLoopUtilization > 0.9Event loop blocking — Step 5
handles climbs foreverLeaked sockets, timers or change streams — Step 6

Log vitals() once a minute to your log pipeline. A leak that takes six hours to matter is invisible in a dashboard that only keeps fifteen minutes.

Step 2: capture heap snapshots from a live process

Node 24 can write a heap snapshot on demand, in-process, with no debugger attached:

import v8 from 'node:v8';

router.post('/internal/heapsnapshot', requireOpsToken, (req, res) => {
  const file = v8.writeHeapSnapshot(`/tmp/heap-${Date.now()}.heapsnapshot`);
  res.json({ file });
});

Two warnings before you use this in production:

  1. writeHeapSnapshot() stops the world for the duration — expect a pause roughly proportional to heap size (hundreds of milliseconds per hundred MB). Take it from one instance you have drained from the load balancer, not from all of them.
  2. The file is as large as your heap. Write it to a disk with room, and ship it off the box before it fills the container.

Take three snapshots: one shortly after boot, one at the midpoint, one when memory is high. One snapshot tells you what is in memory; three tell you what is growing.

If you prefer not to add an endpoint, Node 24 will also do it on a signal:

node --heapsnapshot-signal=SIGUSR2 src/server.js
# later
kill -SIGUSR2 $(pgrep -f src/server.js)

And for a leak that kills the process, get a snapshot at the moment of death:

node --heapsnapshot-near-heap-limit=2 --max-old-space-size=1024 src/server.js

Reading the snapshots

Open Chrome DevTools → Memory → Load, load all three, then select the newest and switch the drop-down to Comparison against the oldest. Sort by Delta. You are looking for a constructor whose object count grows in step with uptime. Then select one instance and read Retainers at the bottom: that chain is the answer — it names the object holding your garbage alive.

In MEAN codebases the retainer chain almost always terminates in one of five things:

  • A module-scope Map, array or plain object used as a cache with no eviction and no TTL. Every request adds a key; nothing ever deletes one.
  • A closure captured by a listener you never removed. emitter.on(...) inside a request handler is the classic: the handler's entire scope, including req and res, is retained forever.
  • req/res captured in a setInterval or in an unsettled promise.
  • Mongoose documents kept in a cache. A hydrated document retains its schema, its $__ internals, and often the whole populated subtree. Cache .lean() results, never documents.
  • A growing logger context — request-scoped child loggers pushed into an array for "debugging" and left in.

The unbounded cache, fixed

// BEFORE: leaks one entry per unique user, forever
const profileCache = new Map();

export async function getProfile(id) {
  if (!profileCache.has(id)) {
    profileCache.set(id, await User.findById(id));   // full Mongoose doc, too
  }
  return profileCache.get(id);
}
// AFTER: bounded, TTL'd, plain objects
import { LRUCache } from 'lru-cache';

const profileCache = new LRUCache({ max: 5_000, ttl: 60_000 });

export async function getProfile(id) {
  const hit = profileCache.get(id);
  if (hit) return hit;
  const doc = await User.findById(id).lean();       // plain object, no schema retained
  if (doc) profileCache.set(id, doc);
  return doc;
}

If the same data is needed by more than one instance, put it in Redis instead of process memory. Per-process caches in a horizontally scaled MEAN deployment are a leak and a consistency bug.

The listener leak, fixed

// BEFORE: one listener per request, never removed
app.get('/api/jobs/:id/stream', (req, res) => {
  jobEvents.on('update', (evt) => res.write(`data: ${JSON.stringify(evt)}\n\n`));
});
// AFTER: removed when the client goes away
app.get('/api/jobs/:id/stream', (req, res) => {
  const onUpdate = (evt) => res.write(`data: ${JSON.stringify(evt)}\n\n`);
  jobEvents.on('update', onUpdate);
  res.on('close', () => jobEvents.off('update', onUpdate));
});

Node will warn you about this one if you listen for it — do it in every service:

process.on('warning', (w) => {
  if (w.name === 'MaxListenersExceededWarning') logger.error({ warning: w.message }, 'listener leak');
});

Step 3: confirm the fix with --heap-prof

A snapshot says what is retained; a heap profile says which code path allocated it. Run a load test against a staging instance started with allocation sampling on:

node --heap-prof --heap-prof-interval=262144 --heap-prof-dir=./prof src/server.js
# drive traffic, then SIGINT to flush

Load the resulting .heapprofile into DevTools → Memory → Load. The flame view attributes retained bytes to the function that allocated them, so the fix is usually one file away. Re-run before and after your change and compare totals; "the graph looks flatter" is not evidence, a number is.

Step 4: when RSS grows but the heap does not

If heapUsedMb is stable while rssMb and externalMb climb, the leak is outside V8's heap. In MEAN apps the usual causes:

  • Buffers held in JS — file uploads read with readFile instead of streamed, base64 images cached, Buffer.concat in a loop. These show as arrayBuffers.
  • Unconsumed streams. An upstream fetch() response whose body you never read or cancel keeps its buffers. Always consume or response.body.cancel().
  • Compression / crypto libraries allocating off-heap per request.
  • Allocator fragmentation, especially under musl (Alpine images). If RSS plateaus high but stable and the heap is fine, try a Debian-slim base image before you chase code.

For off-heap growth, capture with node --track-heap-objects plus an OS-level profiler (heaptrack, or --cpu-prof to see which native binding is hot). Start by proving the tier: process.memoryUsage() splitting heapUsed from external is 90% of the diagnosis.

Step 5: event loop blocking

Blocking looks like a leak to users — latency climbs, restarts "help" — but memory is flat. Node 24's --cpu-prof is the shortest route to a culprit:

node --cpu-prof --cpu-prof-dir=./prof src/server.js

Load the .cpuprofile in DevTools → Performance. Any single synchronous frame over ~50ms is a bug in a server process. The repeat offenders we find in MEAN APIs:

  • JSON.parse / JSON.stringify on multi-megabyte Mongo documents. Project fewer fields, paginate, or stream with JSONStream.
  • argon2/bcrypt with the sync API in a login route. Use the async variants; they run on the thread pool.
  • Big Array.prototype.sort / .map chains over query results that MongoDB should have done in an aggregation pipeline.
  • Synchronous fs calls (readFileSync, existsSync) inside handlers rather than at boot.
  • Regular expressions with catastrophic backtracking on user input (ReDoS) — one request pins a core.

To catch these in staging automatically, alert on loop delay rather than eyeballing it:

setInterval(() => {
  const p99 = loop.percentile(99) / 1e6;
  if (p99 > 100) logger.warn({ p99 }, 'event loop delay high');
  loop.reset();
}, 60_000).unref();

Genuinely CPU-bound work (PDF rendering, image resizing, large CSV exports) does not belong on the request path at all. Move it to a worker thread, or better, to a queue — our background jobs with BullMQ tutorial covers the pattern we use.

Step 6: leaked handles, Mongoose edition

If the active handle count grows forever, something is opening resources per request. In Mongoose 8 apps, look for:

  • mongoose.connect() called more than once — for example inside a serverless handler or a per-request module. One connection pool per process, created at boot. Log mongoose.connection.readyState in your vitals payload.
  • Change streams or cursors opened per request and never closed. await cursor.close() in a finally, and close change streams on res.on('close').
  • setInterval without .unref() in code that reloads.
  • Outbound fetch/HTTP without a timeout, so sockets stack up when an upstream hangs. Use AbortSignal.timeout(5_000).

Mongoose's own instrumentation helps: mongoose.set('debug', true) in staging will show a query storm that is really an N+1 in disguise.

Step 7: contain the blast radius

Even a clean service should fail safely:

  • Set --max-old-space-size explicitly to roughly 75% of the container memory limit, so V8 GCs hard before the kernel OOM-kills you and you get a snapshot instead of a SIGKILL.
  • Run under a supervisor (systemd, Kubernetes, PM2) with a liveness probe that checks /internal/vitals and fails when heapUsedMb / heapLimitMb > 0.9.
  • Drain connections on SIGTERMserver.close(), then mongoose.connection.close() — so restarts do not drop in-flight requests.
  • Keep the vitals numbers in your APM. If you already run OpenTelemetry, the tracing setup we published exports Node runtime metrics including heap and loop lag with no extra code.

Restart-on-a-timer is not a fix. It is a way to pay for the leak in latency forever, and it hides the next one.

A 30-minute triage checklist

  1. Add the vitals endpoint and log it every minute.
  2. Wait for one full traffic cycle. Classify: heap, external, loop, or handles.
  3. Heap → three snapshots, comparison view, read the retainers.
  4. External → check arrayBuffers, hunt unstreamed buffers and unconsumed response bodies.
  5. Loop → --cpu-prof under load, find the synchronous frame over 50ms.
  6. Handles → audit connections, cursors, timers and outbound calls for missing close/timeout.
  7. Fix one thing, re-measure the same number, and only then ship.

Most "mysterious" MEAN memory problems are one unbounded Map, one listener that is never removed, or one JSON.stringify over a document that should have been projected. The tooling to prove which is already in Node 24 — you just have to turn it on before the incident, not during it.

If your Node API is drifting upward every day and you would rather not spend the sprint on it, get in touch: production triage of this kind is what our MEAN Stack performance tuning engagements do, usually inside a week.