> cs·fundamentals
interview 0% 22m read
4.6 [J][A] 14 interview Q's

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.

GC ROOTScall stackglobalsclosuresreachable = aliveYOUNG (new space)small · scavenger · fast · frequentmost objects die hereOLD generationsurvivors · mark-sweep-compact · slowpromoteLEAKmodule-scope Map, add-onlyreachable from a global root →GC can never free itnever released
FIG 1 · the heap The generational heap and what a leak looks like. New objects are born in the small young generation and collected cheaply by the scavenger; the few that survive several rounds get PROMOTED into the old generation, swept less often. GC roots — the call stack, globals, and live closures — are where reachability starts: anything traceable from a root survives. A leak (red) is an object that stays reachable from a root that never lets go, like a module-scope Map you only ever add to, so GC can never collect it.

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 Map or 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(...) or setInterval retains its callback (and everything it closes over). Re-registering per request without cleanup is a steady leak; the MaxListenersExceededWarning is 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:

FrameworkIn a sentence
ExpressThe long-time default: minimal, unopinionated, enormous middleware ecosystem. A request flows through a middleware pipeline you assemble with app.use().
FastifyExpress-like but built for speed and schema-based validation/serialization; lower overhead per request, plugin-based architecture.
NestJSOpinionated, structured framework (TypeScript-first) with modules, controllers, providers, and dependency injection — Angular-style architecture for large teams; runs on Express or Fastify underneath.
Express for simplicity/ubiquity, Fastify for performance, NestJS for structure at scale.

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.js opens 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.
Spotting a leak: the memoryUsage climb and the snapshot diff
// 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 → OOM

A 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).

SymptomLikely causeTool to confirm
heap staircases, eventual OOM-killunbounded cache / global / listener leakheap-snapshot diff (Comparison view)
one endpoint freezes the whole serversynchronous CPU work blocking the loopCPU profiler / --prof flame graph
latency spikes every few secondslong GC pauses (huge old generation)--trace-gc, then shrink retained set
MaxListenersExceededWarninglisteners added in a hot path, never removedgrep for .on( without a matching .off()
Match the symptom to the tool — measure before you change anything.

01 Learning objectives

0 / 4 done

02 Knowledge check

knowledge check2 questions · pass ≥ 70%
  1. 01easy

    Express middleware runs in a request/response pipeline, calling next() to pass control on.

  2. 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.

  • Commonly asked mid concept common 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 Map you only ever add to; it grows forever. Use an LRU with a size cap or TTLs.
    - Forgotten event listeners / timers — adding a listener (or setInterval) 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.

    Red flag Believing garbage collection makes leaks impossible — reachable-but-unused references defeat the GC.

    source: Node.js docs — Memory diagnostics ↗
  • Commonly asked senior concept occasional 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.

    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 ↗
  • Commonly asked mid concept common 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-prof to dump profiles to disk, or APM tools. The first move is almost always: snapshot/profile, then diff.

    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 ↗
  • Commonly asked junior concept common 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/modify req/res, end the response, or call next() to pass control to the next one.

    - Call next() → continue to the next middleware/handler.
    - Call next(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.

    Red flag Forgetting to call next() (request hangs) or registering middleware in the wrong order (auth after the handler).

    source: Express docs — Using middleware ↗
  • Commonly asked mid concept occasional 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).

    Red flag Calling them interchangeable — they sit at very different points on the minimal-vs-opinionated spectrum.

    source: Fastify docs — Benchmarks & overview ↗
  • Commonly asked mid concept common 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.

    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 ↗
  • Commonly asked mid concept common 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 *Sync fs 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 with setImmediate.
    2. Unhandled stream errors — a stream emits 'error' with no listener and crashes the process. Handle 'error' on every stream / use pipeline.
    3. Floating promises — an un-awaited async call whose rejection is lost (or now crashes via unhandledRejection); 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.

    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 ↗
  • Commonly asked senior concept occasional 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 heapUsed that never drops after GC points to a JS-object leak (caches, listeners). A climbing rss with flat heapUsed points to off-heap/native growth (Buffers, native addons). So heapUsed for JS leaks, rss/external for off-heap ones.

    What a strong answer covers
    • rss = total physical RAM (heap + native + Buffers + code) — the OS footprint.

    • heapTotal = V8 heap reserved; heapUsed = live JS objects within it.

    • Climbing heapUsed that survives GC → JS-object leak (caches, listeners).

    • Climbing rss/external with flat heapUsed → off-heap/native (Buffer) growth.

    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() ↗
  • Commonly asked senior concept occasional 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 with FATAL 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 covers
    • Sets 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.

    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 ↗
  • Commonly asked senior concept occasional 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 covers
    • V8 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.

    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 ↗
  • Commonly asked mid debug common 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 async handler returns a promise; if it rejects (or you await something 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 an unhandledRejection).

    Fixes:
    - Forward errors explicitly: try { ... } catch (e) { next(e); }.
    - Wrap handlers in an async helper that catches and calls next (or use express-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 covers
    • Express 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, or express-async-errors.

    • Express 5 auto-forwards rejected promises to the error handler.

    Red flag Assuming Express 4 catches async/await errors automatically — it doesn't; the request hangs.

    source: Express docs — Error handling (async) ↗
  • Commonly asked mid concept occasional 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 new calls, so large teams can reason about and replace pieces independently.

    What a strong answer covers
    • Manual 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.

    Red flag Dismissing DI as ceremony — its payoff (decoupling, testability) shows up as the dependency graph grows.

    source: NestJS docs — Providers / Dependency injection ↗
  • Commonly asked senior concept occasional 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 generic JSON.stringify reflecting 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 covers
    • Compiles 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.

    Red flag Forgetting that fields absent from the response schema are silently dropped from the output.

    source: Fastify docs — Validation and Serialization ↗
  • Commonly asked senior concept occasional WeakMap and WeakRef exist partly to avoid memory leaks. How does a WeakMap-keyed cache differ from a Map-keyed one?

    A Map holds strong references to its keys. If you use objects as keys in a long-lived Map cache and never delete them, those keys (and their values) can never be garbage-collected — the Map itself keeps them alive. That's the textbook unbounded-cache leak.

    A WeakMap holds 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 a WeakMap keyed by an object (e.g. caching per-request or per-element metadata) cleans itself up when the key dies — no manual eviction.

    Caveats: WeakMap keys 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/FinalizationRegistry are the lower-level primitives for individual weak references.

    What a strong answer covers
    • Map keys are strong references → object keys live as long as the Map (leak risk).

    • WeakMap keys 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-check

    Why can a WeakMap-keyed cache avoid a leak that a Map-keyed one causes?

    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 ↗