V8, memory & frameworks
V8 + GC basics and how memory leaks happen, Express/Fastify/NestJS at a concept level, debugging with --inspect, and the common Node gotchas.
Under your JavaScript sits V8, the same engine that powers Chrome: it compiles your code and manages memory with a garbage collector. You rarely think about it — until a process slowly eats RAM and gets OOM-killed at 3am. This chapter covers how V8 reclaims memory, how leaks sneak in, and the frameworks and tools you’ll reach for around them.
V8 and garbage collection
V8 splits the heap by object age, because most objects die young:
- The young generation (new space) is small and collected very frequently by a fast “scavenger.” Most objects — a request’s temporary variables — are born and die here, cheaply.
- Objects that survive a few scavenges are promoted to the old generation, collected less often by a slower mark-and-sweep/compact pass.
The single concept that explains both GC and leaks is reachability: V8 keeps an object alive as
long as it’s reachable from a “root” (the call stack, globals, closures). The moment nothing
references it, it’s eligible for collection. You never free() — you just drop references.
How memory leaks happen
A leak is not “memory in use” — it’s memory that stays reachable but will never be used again, so GC refuses to free it. The usual suspects:
- Unbounded module-level / global state — a
Mapor array at module scope that you only ever push to. It’s reachable forever, so it grows forever. The classic accidental cache. - Closures capturing large data — a callback that closes over a big object keeps that object alive as long as the callback is referenced (e.g. stored in a timer or listener).
- Listeners and timers never removed — every
emitter.on(...)orsetIntervalretains its callback (and everything it closes over). Re-registering per request without cleanup is a steady leak; theMaxListenersExceededWarningis often the first sign. - Caches with no eviction — a hand-rolled cache with no size/TTL bound is just a leak with good intentions. Use an LRU with a max size.
Frameworks, in one breath each
How does one thread serve high concurrency? Because almost all of a typical web request is I/O
(database, cache, upstream APIs), and Node offloads that, freeing the event loop to interleave
hundreds of in-flight requests on the single thread. Frameworks are thin layers over Node’s http
server that add routing, middleware, and ergonomics:
| Framework | In a sentence |
|---|---|
| Express | The long-time default: minimal, unopinionated, enormous middleware ecosystem. A request flows through a middleware pipeline you assemble with app.use(). |
| Fastify | Express-like but built for speed and schema-based validation/serialization; lower overhead per request, plugin-based architecture. |
| NestJS | Opinionated, structured framework (TypeScript-first) with modules, controllers, providers, and dependency injection — Angular-style architecture for large teams; runs on Express or Fastify underneath. |
The shared concept is middleware: each request passes through an ordered chain of functions, any
of which can inspect or modify it, short-circuit with a response, or call next() to continue. Logging,
auth, body parsing, and error handling are all middleware.
// Express middleware: runs in order; call next() to pass control on
app.use((req, res, next) => {
req.startedAt = Date.now(); // attach something
next(); // forget this and the request hangs forever
});
// Error-handling middleware has FOUR args (err first) — register it LAST
app.use((err, req, res, next) => {
log.error(err);
res.status(500).json({ error: "internal" });
});
Debugging and profiling
When something leaks or runs slow, you don’t guess — you measure:
node --inspect app.jsopens a debugging port; connect Chrome DevTools (chrome://inspect) or VS Code to set breakpoints, step through async code, and view scopes.- In DevTools’ Memory tab, take a heap snapshot, exercise the app, take another, and compare — a growing retained size between snapshots points straight at the leaking object graph.
- The Performance/CPU profiler (built in, or
node --prof) shows where CPU time goes — invaluable for spotting an accidental synchronous hot path that’s blocking the loop. process.memoryUsage()gives a cheap programmatic read (heapUsed,rss) you can log over time to confirm a slow climb.
// Log heapUsed every 30s; a leak shows a monotonic climb that GC never reverses.
setInterval(() => {
const { heapUsed, rss } = process.memoryUsage();
console.log(`heapUsed=${(heapUsed / 1e6).toFixed(0)}MB rss=${(rss / 1e6).toFixed(0)}MB`);
}, 30_000);What healthy vs leaking looks like:
# HEALTHY — sawtooth: climbs, GC drops it back down
heapUsed=120MB ... heapUsed=180MB ... heapUsed=95MB ... heapUsed=160MB
# LEAKING — staircase: every GC leaves more behind, never recovers
heapUsed=120MB ... heapUsed=210MB ... heapUsed=340MB ... heapUsed=520MB → OOMA healthy process sawtooths — memory rises, GC reclaims it, repeat. A leak staircases: each GC
frees less than was allocated, so the floor creeps up until the heap cap throws JavaScript heap out of memory. To find the culprit, take a heap snapshot, run traffic, take a second snapshot, and use
DevTools’ Comparison view: the object type whose retained size and count grew between
snapshots is your leak — follow its retainer chain back to the root holding it (usually a
module-scope Map, an array, or a never-removed listener).
| Symptom | Likely cause | Tool to confirm |
|---|---|---|
| heap staircases, eventual OOM-kill | unbounded cache / global / listener leak | heap-snapshot diff (Comparison view) |
| one endpoint freezes the whole server | synchronous CPU work blocking the loop | CPU profiler / --prof flame graph |
| latency spikes every few seconds | long GC pauses (huge old generation) | --trace-gc, then shrink retained set |
MaxListenersExceededWarning | listeners added in a hot path, never removed | grep for .on( without a matching .off() |
01 Learning objectives
0 / 4 done02 Knowledge check
- 01easy
Express middleware runs in a request/response pipeline, calling next() to pass control on.
- 02medium
Common Node memory-leak sources:
03 Interview questions
browse all ↗What gets asked on this topic — tap a card for how to approach it, the follow-ups, and the trap. Company tags are best-effort & sourced.
-
What are the most common causes of memory leaks in a long-running Node service?
A leak in a GC'd runtime is memory the GC can't reclaim because something still references it. Usual suspects:
- Unbounded caches / maps — a module-level
Mapyou only ever add to; it grows forever. Use an LRU with a size cap or TTLs.
- Forgotten event listeners / timers — adding a listener (orsetInterval) per request/connection and never removing it; the closures pin everything they captured (hence the max-listeners warning).
- Growing module-level (global) state — pushing onto an array that is never trimmed.
- Closures capturing big objects — a long-lived callback that closes over a large buffer keeps it alive.Diagnose by watching RSS/heap trend upward over hours, then take two heap snapshots and diff what grew.
Follow-ups they push on- How do you confirm a leak vs normal heap growth? (Two snapshots, diff retained objects.)
- Why is an unbounded cache the textbook leak?
Red flag Believing garbage collection makes leaks impossible — reachable-but-unused references defeat the GC.
source: Node.js docs — Memory diagnostics ↗ -
Give a rough picture of how V8 manages memory and garbage collection. What's the generational heap?
V8 (the JS engine in Node and Chrome) compiles JS to machine code and manages a generational heap on the generational hypothesis: most objects die young.
- Young generation (new space) — small; new allocations go here. Collected often by a fast Scavenge (copying) collector. Cheap because it touches little memory.
- Old generation (old space) — objects that survive a couple of scavenges are *promoted* here. Collected less often by Mark-Sweep-Compact (mark reachable objects, sweep the rest, compact to fight fragmentation).Much of this runs concurrently/incrementally to keep pauses short. The heap has a default cap (historically ~1.5–2GB for old space) tunable via
--max-old-space-size. The practical takeaway: short-lived allocations are nearly free; long-lived retained objects are what cost you.Follow-ups they push on- Why is collecting the young generation so much cheaper than the old generation?
- What does --max-old-space-size change, and when do you raise it?
Red flag Describing GC as one big stop-the-world sweep — modern V8 is generational and largely incremental/concurrent.
source: Node.js docs — Memory diagnostics ↗ -
How do you debug and profile a Node process — say it's leaking memory or pinning the CPU in production?
Start with the built-in inspector: run with
--inspect(or--inspect-brk) and connect Chrome DevTools (chrome://inspect) or VS Code.- CPU pinned: take a CPU profile (DevTools Profiler, or
--prof/--cpu-prof) and read the flame graph for the hot function. Also watch event-loop lag — high lag means something is blocking the loop.
- Memory leak: take two heap snapshots minutes apart under load and use the Comparison view to see which object types keep growing and what retains them.process.memoryUsage()(RSS/heapUsed) shows the trend.In production, prefer low-overhead options:
--cpu-prof/--heap-profto dump profiles to disk, or APM tools. The first move is almost always: snapshot/profile, then diff.Follow-ups they push on- How do you find what retains a leaked object in a heap snapshot? (Retainers path.)
- What is event-loop lag and how would you measure it?
Red flag Guessing at the hot path or leak instead of taking a profile / two heap snapshots and diffing.
source: Node.js docs — Debugging with --inspect ↗ -
What is Express middleware? Walk through what next() does and how the chain executes.
Express middleware is a function
(req, res, next)that sits in a chain between the incoming request and the route handler. Each request flows through the registered middleware in order; a middleware can read/modifyreq/res, end the response, or callnext()to pass control to the next one.- Call
next()→ continue to the next middleware/handler.
- Callnext(err)→ skip ahead to the error-handling middleware (the special 4-arg form(err, req, res, next)).
- Call neither and don't send a response → the request hangs (a common bug).Uses: logging, body parsing, auth, CORS, and a final centralized error handler. Order matters — auth must run before the protected handler.
Follow-ups they push on- Why must the error-handling middleware have four arguments?
- What happens if a middleware neither sends a response nor calls next()?
Red flag Forgetting to call next() (request hangs) or registering middleware in the wrong order (auth after the handler).
source: Express docs — Using middleware ↗ -
Express vs Fastify vs NestJS — at a concept level, what differentiates them?
- Express — the minimal, unopinionated classic: a thin router + middleware model. Huge ecosystem, you assemble structure yourself. Great default; less guidance on large-app architecture.
- Fastify — Express-like but built for performance and developer ergonomics: a faster router, schema-based validation/serialization (JSON Schema) that also speeds up responses, and a first-class plugin/encapsulation system. Pick when throughput and built-in validation matter.
- NestJS — an opinionated framework (Angular-inspired) layered on top of Express *or* Fastify: TypeScript-first, modules/controllers/providers, dependency injection, decorators. Pick for large, structured teams/codebases that want enforced architecture out of the box.Trade-off axis: Express (minimal, flexible) → Fastify (fast, validated) → Nest (structured, batteries-included).
Follow-ups they push on- What does Fastify's schema-based serialization buy you over plain JSON.stringify?
- What problem does NestJS's dependency injection solve as a codebase grows?
Red flag Calling them interchangeable — they sit at very different points on the minimal-vs-opinionated spectrum.
source: Fastify docs — Benchmarks & overview ↗ -
How does single-threaded Node serve high concurrency, and where does that model fall down?
Node wins at I/O-bound concurrency because the one JS thread never *waits* on I/O — it dispatches the request to the OS/libuv and serves other requests while the bytes are in flight. Thousands of mostly-idle connections (each waiting on a DB or network) cost little: no thread-per-connection overhead, just registered callbacks. That is the sweet spot: APIs, proxies, real-time/websocket servers.
Where it falls down: CPU-bound work. One synchronous heavy computation (image processing, big JSON crunch, sync crypto) blocks the single thread and stalls *every* connection. The fixes are the concurrency tools — worker threads for in-process CPU work, cluster to use all cores for throughput, or offloading to a separate service/queue.
Summary: brilliant for I/O concurrency, weak for CPU parallelism — so keep CPU work off the event-loop thread.
Follow-ups they push on- Why is thread-per-connection (classic blocking servers) less memory-efficient for many idle connections?
- Which workloads should you NOT put on a plain single-process Node server?
Red flag Claiming Node is fast for everything — it shines for I/O concurrency, not CPU parallelism.
source: Node.js docs — Don't block the event loop ↗ -
Name the four classic Node 'gotchas' that bite teams in production, and how each manifests.
The recurring four:
1. Blocking the event loop — synchronous CPU work (or
*Syncfs calls) on the request path freezes the whole server; symptom is rising latency/timeouts across all requests at once. Offload to a worker or chunk withsetImmediate.
2. Unhandled stream errors — a stream emits'error'with no listener and crashes the process. Handle'error'on every stream / usepipeline.
3. Floating promises — an un-awaited async call whose rejection is lost (or now crashes viaunhandledRejection); symptom is silent failures or sudden exits. Always await/return/.catch.
4. Unhandled rejections / uncaught exceptions — treated as last-resort: log and exit, don't swallow and keep serving a corrupted process.These map directly onto the earlier chapters — they are the failure modes of the event loop, streams, and async model.
Follow-ups they push on- Which of these would a linter (no-floating-promises) catch automatically?
- Why is 'log and continue' the wrong response to an uncaughtException?
Red flag Treating these as edge cases — they are the single most common ways production Node services fall over.
source: Node.js docs — Don't block the event loop ↗ -
What's the difference between RSS, heapTotal, and heapUsed in process.memoryUsage(), and which one reveals a leak?
process.memoryUsage()returns several numbers:-
rss(Resident Set Size) — total physical RAM the process holds: V8 heap + native allocations + Buffers (off-heap) + code/stack. The OS-level footprint.
-heapTotal— memory V8 has reserved for its JS object heap.
-heapUsed— the portion of that heap actually in use by live JS objects.
- (external/arrayBuffers) — memory used by C++ objects and ArrayBuffers/Buffers bound to V8, outside the JS heap.For a leak, watch the trend over time, not a single reading. A steadily-climbing
heapUsedthat never drops after GC points to a JS-object leak (caches, listeners). A climbingrsswith flatheapUsedpoints to off-heap/native growth (Buffers, native addons). SoheapUsedfor JS leaks,rss/externalfor off-heap ones.What a strong answer coversrss= total physical RAM (heap + native + Buffers + code) — the OS footprint.heapTotal= V8 heap reserved;heapUsed= live JS objects within it.Climbing
heapUsedthat survives GC → JS-object leak (caches, listeners).Climbing
rss/externalwith flat heapUsed → off-heap/native (Buffer) growth.
Follow-ups they push on- Why might rss grow while heapUsed stays flat? (Off-heap Buffers / native memory.)
- Why look at the trend across snapshots rather than one reading?
Red flag Diagnosing all leaks via heapUsed — off-heap Buffer/native growth shows up in rss/external, not the JS heap.
source: Node.js docs — process.memoryUsage() ↗ -
What does --max-old-space-size control, and why does raising it sometimes hide a leak rather than fix it?
--max-old-space-size=<MB>raises the cap on V8's old-generation heap (where long-lived objects live). When the old space approaches this limit, V8 runs aggressive GC; if memory still can't be reclaimed, the process dies withFATAL ERROR: ... JavaScript heap out of memory. The default is well under modern machine RAM (historically ~2 GB on 64-bit), so legitimately large workloads sometimes need it raised.The trap: bumping it to make OOM crashes "go away" when the real problem is a leak. If memory grows without bound, a bigger cap just postpones the crash — it grows to the new limit and dies again, now with bigger GC pauses along the way. Raise it when working set is genuinely large and bounded; for unbounded growth, profile and fix the leak (heap snapshots, retainer paths) instead.
What a strong answer coversSets V8's old-generation heap cap; hitting it → 'JavaScript heap out of memory' crash.
Default is below machine RAM, so large legitimate workloads may need it raised.
For a real leak, a higher cap just delays the crash (and worsens GC pauses).
Raise for genuinely-large bounded working sets; profile/fix for unbounded growth.
Follow-ups they push on- How do you tell a real leak from a legitimately large working set?
- What's the downside of a very large old-space heap on GC pause times?
Red flag Cranking --max-old-space-size to stop OOM crashes that are actually a leak — it postpones, not fixes.
source: Node.js docs — --max-old-space-size ↗ -
Why does JIT compilation make microbenchmarks misleading, and what does V8 do with 'hot' functions?
V8 runs JS through a tiered pipeline: an interpreter (Ignition) runs bytecode immediately, and an optimizing compiler (TurboFan, with a mid-tier Maglev) recompiles 'hot' functions — ones called often — into fast machine code, using runtime type feedback to specialize them.
This makes naive microbenchmarks misleading two ways: (1) the first runs are slow (cold, interpreted) before optimization kicks in, so timing a few iterations measures warmup, not steady state; (2) if a function later sees an unexpected type, V8 deoptimizes it back to slower code — a benchmark with uniform inputs won't reveal the real-world deopt cost. Also dead-code elimination can delete a benchmark whose result is unused.
Takeaways: warm up before measuring, run many iterations, use a real benchmarking harness, and keep functions monomorphic (consistent argument shapes) so V8 can keep them optimized.
What a strong answer coversV8 tiers: Ignition (interpret) → Maglev/TurboFan (optimize hot functions) using type feedback.
Cold runs are slow; timing few iterations measures warmup, not steady state.
Type changes trigger deoptimization; uniform-input benchmarks hide that cost.
Warm up, run many iterations, keep functions monomorphic; beware dead-code elimination.
Follow-ups they push on- What is a 'deopt' and what kinds of code commonly trigger it?
- Why does keeping object shapes consistent (monomorphic) help V8?
Red flag Trusting a few-iteration microbenchmark — you're measuring cold interpreted code, not optimized steady state.
source: V8 blog — Firing up the Ignition interpreter / TurboFan ↗ -
In Express, why doesn't a thrown error inside an async route handler reach your error-handling middleware (in Express 4)?
In Express 4, the router only catches errors thrown synchronously. An
asynchandler returns a promise; if it rejects (or youawaitsomething that throws), the rejection happens on a later tick after the handler already returned — Express never sees it, so your(err, req, res, next)middleware isn't invoked and the request hangs (and you get anunhandledRejection).Fixes:
- Forward errors explicitly:try { ... } catch (e) { next(e); }.
- Wrap handlers in an async helper that catches and callsnext(or useexpress-async-errors).Express 5 fixes this: it automatically forwards a rejected promise from a handler to the error middleware, so a plain
throw/rejection in an async handler is caught. Know which major version you're on — this is a very common production gotcha.What a strong answer coversExpress 4's router only catches synchronous throws; a rejected async handler escapes it.
Result: error middleware isn't called, the request hangs, and you get unhandledRejection.
Fix in v4:
try/catch+next(err), an async wrapper, orexpress-async-errors.Express 5 auto-forwards rejected promises to the error handler.
Follow-ups they push on- How does an async-handler wrapper forward rejections to next()?
- What changed in Express 5 around async error handling?
Red flag Assuming Express 4 catches async/await errors automatically — it doesn't; the request hangs.
source: Express docs — Error handling (async) ↗ -
What does dependency injection in NestJS actually solve as a codebase grows, compared to manually constructing services?
Without DI you wire dependencies by hand: each class
news the things it needs, which hardcodes concrete implementations and threads constructor arguments through the whole tree. As the app grows this becomes brittle — changing a service's dependencies means editing every call site, and substituting a fake for tests is painful.NestJS DI inverts that: you declare a class
@Injectable()and ask for its dependencies in the constructor; an IoC container constructs and caches them (singletons by default) and injects them where declared. Benefits:- Decoupling — depend on an abstraction/token, swap the concrete provider in one place.
- Testability — override a provider with a mock in the test module; no monkey-patching.
- Lifecycle/scoping — the container manages singletons (and request-scoped instances) consistently.The payoff is at scale: wiring lives in module metadata, not scattered
newcalls, so large teams can reason about and replace pieces independently.What a strong answer coversManual wiring hardcodes concretes and threads constructor args through the tree.
Nest's IoC container constructs, caches (singleton by default), and injects dependencies.
Decouples via tokens/abstractions — swap a provider in one place.
Makes testing easy (override providers with mocks) and centralizes lifecycle/scoping.
Follow-ups they push on- How would you inject a mock repository in a Nest unit test?
- What's the difference between a singleton and a request-scoped provider?
Red flag Dismissing DI as ceremony — its payoff (decoupling, testability) shows up as the dependency graph grows.
source: NestJS docs — Providers / Dependency injection ↗ -
What does Fastify's schema-based serialization buy you over returning a plain object that gets JSON.stringify'd?
When you attach a response JSON Schema to a Fastify route, Fastify compiles a specialized serializer (via
fast-json-stringify) tailored to that exact shape. Instead of the genericJSON.stringifyreflecting over the object at runtime, it runs straight-line code that knows the fields and types ahead of time — measurably faster serialization, the main reason for the speedup on JSON-heavy endpoints.Two more wins: the schema acts as an output contract — fields not in the schema are stripped, which prevents accidentally leaking internal/sensitive properties — and combined with request schemas you get validation at the boundary. So: faster responses, an explicit contract, and a safety filter against over-exposure.
Trade-off: you must keep the schema in sync with the response, and a field you forget to declare silently disappears from the output.
What a strong answer coversCompiles a shape-specific serializer (fast-json-stringify) — faster than generic JSON.stringify.
Strips fields not in the schema → prevents leaking internal/sensitive properties.
Pairs with request schemas for boundary validation and an explicit contract.
Trade-off: undeclared fields silently vanish; the schema must stay in sync.
Follow-ups they push on- How does schema-based serialization prevent accidentally leaking a password field?
- What's the risk of forgetting to add a field to the response schema?
Red flag Forgetting that fields absent from the response schema are silently dropped from the output.
source: Fastify docs — Validation and Serialization ↗ -
WeakMap and WeakRef exist partly to avoid memory leaks. How does a WeakMap-keyed cache differ from a Map-keyed one?
A
Mapholds strong references to its keys. If you use objects as keys in a long-livedMapcache and neverdeletethem, those keys (and their values) can never be garbage-collected — the Map itself keeps them alive. That's the textbook unbounded-cache leak.A
WeakMapholds its keys weakly: an entry does not prevent its key object from being collected. Once nothing else references the key, the GC can reclaim the key and its associated value, and the entry vanishes automatically. So aWeakMapkeyed by an object (e.g. caching per-request or per-element metadata) cleans itself up when the key dies — no manual eviction.Caveats:
WeakMapkeys must be objects, it's not enumerable (no.size, no iteration — because collection timing is non-deterministic), and it's a tool for associating data with object lifetimes, not a general size-bounded cache (use an LRU for that).WeakRef/FinalizationRegistryare the lower-level primitives for individual weak references.What a strong answer coversMapkeys are strong references → object keys live as long as the Map (leak risk).WeakMapkeys are weak → key + value are GC'd once nothing else references the key.WeakMap keys must be objects; it's not iterable and has no
.size.Great for per-object metadata tied to lifetime; use an LRU for size-bounded caches.
Quick self-checkWhy can a WeakMap-keyed cache avoid a leak that a Map-keyed one causes?
-
Wrong: WeakMap has no size limit or LRU eviction; collection is tied to key reachability.
-
Correct — weak keys let the GC reclaim entries automatically when the key object dies.
-
Wrong: it's a normal heap structure; the difference is reference strength, not location.
-
Wrong: there's no compression; the mechanism is weak references to keys.
Follow-ups they push on- Why can't a WeakMap be iterated or report its size?
- When is a WeakMap the wrong choice and an LRU cache the right one?
Red flag Using a plain Map with object keys as a long-lived cache and never evicting — it pins keys/values forever.
source: MDN — WeakMap ↗