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:
| Symptom | What you are looking at |
|---|---|
heapUsedMb climbs monotonically across hours and never drops after GC | JavaScript heap leak — go to Step 2 |
rssMb climbs but heapUsedMb is flat; externalMb / arrayBuffersMb climb | Native/buffer leak — Step 4 |
Memory flat, loopDelayMs.p99 in the hundreds, eventLoopUtilization > 0.9 | Event loop blocking — Step 5 |
handles climbs forever | Leaked 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:
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.- 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, includingreqandres, is retained forever. req/rescaptured in asetIntervalor 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
readFileinstead of streamed, base64 images cached,Buffer.concatin a loop. These show asarrayBuffers. - Unconsumed streams. An upstream
fetch()response whose body you never read or cancel keeps its buffers. Always consume orresponse.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.stringifyon multi-megabyte Mongo documents. Project fewer fields, paginate, or stream withJSONStream.argon2/bcryptwith the sync API in a login route. Use the async variants; they run on the thread pool.- Big
Array.prototype.sort/.mapchains over query results that MongoDB should have done in an aggregation pipeline. - Synchronous
fscalls (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. Logmongoose.connection.readyStatein your vitals payload.- Change streams or cursors opened per request and never closed.
await cursor.close()in afinally, and close change streams onres.on('close'). setIntervalwithout.unref()in code that reloads.- Outbound
fetch/HTTP without a timeout, so sockets stack up when an upstream hangs. UseAbortSignal.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-sizeexplicitly 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/vitalsand fails whenheapUsedMb / heapLimitMb > 0.9. - Drain connections on
SIGTERM—server.close(), thenmongoose.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
- Add the vitals endpoint and log it every minute.
- Wait for one full traffic cycle. Classify: heap, external, loop, or handles.
- Heap → three snapshots, comparison view, read the retainers.
- External → check
arrayBuffers, hunt unstreamed buffers and unconsumed response bodies. - Loop →
--cpu-profunder load, find the synchronous frame over 50ms. - Handles → audit connections, cursors, timers and outbound calls for missing close/timeout.
- 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.