Runtime internals
At ₹20–28L nobody asks how V8 stores an object. At the top of the band they do, because at that level you are expected to debug things the framework cannot explain. This round is finite and learnable — perhaps two weekends of reading — and it is disproportionately impressive because most candidates have never looked.
Source → parser → AST → Ignition, the bytecode interpreter, which starts executing immediately. While running, V8 collects type feedback. Functions that run hot get sent to TurboFan, the optimising compiler, which produces machine code specialised to the types it has observed. If a later call violates those assumptions, the code is deoptimised and falls back to bytecode.
Hidden classes (V8 calls them Maps) are the mechanism underneath. Objects with the same properties added in the same order share a hidden class, which lets property access compile to a fixed memory offset instead of a hash lookup. Inline caches then remember "at this call site, the object had hidden class X, so the property is at offset 4" — and that is what makes property access fast.
// SAME hidden class — monomorphic call site, fast
function P(x, y) { this.x = x; this.y = y }
const a = new P(1, 2), b = new P(3, 4)
// DIFFERENT hidden classes — property order differs
const c = { x: 1 }; c.y = 2
const d = { y: 2 }; d.x = 1
// a function reading .x from both goes polymorphic, then megamorphic,
// and the inline cache stops helping entirely
// also deoptimising: adding a property after construction,
// deleting a property (delete o.x), and mixing types in an array
const arr = [1, 2, 3] // PACKED_SMI — fastest element kind
arr.push(1.5) // → PACKED_DOUBLE
arr.push('x') // → PACKED_ELEMENTS, boxed, slow
arr[100] = 1 // → HOLEY, slower still, and it never goes backThe practical rules that follow, which is what they actually want: initialise all properties in the constructor and in the same order; never delete a property (set it to null or undefined); keep arrays type-homogeneous and hole-free; and prefer monomorphic functions — one that receives four different object shapes is far slower than four specialised ones.
- What is a megamorphic call site?
- Why is delete so expensive?
- How would you actually verify any of this? (--trace-deopt, --allow-natives-syntax.)
V8's heap is generational, on the observation that most objects die young.
- Young generation (nursery). Collected by Scavenger, a copying collector: live objects are copied to the other semi-space and everything else is discarded wholesale. Frequent, very fast, pauses of well under a millisecond. An object that survives two scavenges is promoted.
- Old generation. Collected by mark-sweep-compact: mark what is reachable from the roots, sweep the rest, compact to remove fragmentation. Much less frequent, much more expensive. Modern V8 does most of the marking concurrently and incrementally to keep pauses short, but a major GC on a large heap is still measured in tens of milliseconds.
The key insight: allocating a lot of short-lived objects is cheap — that is what the nursery is for. Allocating objects that survive is expensive, because they get promoted and then cost you major GCs. So the performance problem is rarely "too many allocations"; it is "too many long-lived allocations".
// 1. confirm it is a leak, not just a large heap
node --expose-gc app.js
process.memoryUsage() // heapUsed climbing across forced GCs = a real leak
// 2. three heap snapshots: baseline, after load, after more load
// Chrome DevTools → Memory → Comparison view
// look at "Delta" — what keeps growing between snapshots?
// 3. select the growing constructor → Retainers panel
// the retainer chain names the thing holding it alive.
// In Node it is almost always one of four things:
// - an event listener never removed
// - a Map or array used as a cache with no eviction
// - a closure capturing a request context
// - a timer that was never clearedThe fixes map one to one: remove listeners on cleanup or use once; give every in-memory cache a size bound and a TTL, or use a WeakMap keyed by an object whose lifetime you do not control; clear timers; and in production, expose heap metrics so you see the sawtooth flatten into a ramp before it becomes an out-of-memory crash.
- What is the difference between a memory leak and high memory usage?
- Why is a WeakMap the right cache key sometimes?
- What does --max-old-space-size actually change?
Style → Layout → Paint → Composite. The cost of a change depends entirely on how far up that chain it starts.
| Change | Triggers | Cost |
|---|---|---|
width, height, top, margin, font-size | Layout → Paint → Composite | Most expensive — geometry of other elements may change too |
color, background, box-shadow, border-radius | Paint → Composite | Moderate — repaints the affected area |
transform, opacity | Composite only | Cheapest — handled on the compositor, often off the main thread entirely |
Therefore: animate transform and opacity, never left/top/width. That one rule is the entire practical takeaway, and being able to explain why in terms of the pipeline is what gets scored.
// BAD: read, write, read, write — forces a synchronous layout every iteration
for (const el of els) {
el.style.height = el.offsetHeight + 10 + 'px' // read forces flush of pending writes
}
// GOOD: batch all reads, then all writes
const heights = els.map(el => el.offsetHeight) // read phase
els.forEach((el, i) => el.style.height = heights[i] + 10 + 'px') // write phaseThe properties that force a synchronous layout when read — offsetHeight, getBoundingClientRect, scrollTop, getComputedStyle — are worth memorising, because reading one after a write is what causes the thrash.
will-change promotes an element to its own compositor layer, which makes it cheap to animate — but each layer costs GPU memory, and applying it to everything makes things slower, not faster. Add it just before the animation and remove it after.
And connect it to INP from R4: a long task blocks the main thread, so the next paint after an interaction is delayed. That is why breaking up long tasks (with scheduler.yield() or setTimeout) improves a metric that looks like it should be about rendering.
- What is the compositor thread and what can it do without the main thread?
- Why is a CSS animation often smoother than a JS one?
- How would you find a long task in production?
Node has four thread pools you should be able to distinguish: the single main thread running your JavaScript; the libuv thread pool (default 4) used by file I/O, DNS via getaddrinfo, zlib and crypto; the V8 threads for concurrent GC and TurboFan compilation; and any worker_threads you create.
Network I/O does not use the thread pool — it uses the operating system's event notification (epoll on Linux, kqueue on BSD, IOCP on Windows), which is why Node handles tens of thousands of sockets on one thread but stalls on four concurrent bcrypt calls.
// the crude version, good enough to alert on
let last = process.hrtime.bigint()
setInterval(() => {
const now = process.hrtime.bigint()
const lag = Number(now - last) / 1e6 - 100 // expected 100ms interval
last = now
metrics.gauge('eventloop.lag_ms', lag) // >50ms sustained = trouble
}, 100)
// the proper version, built in:
const { monitorEventLoopDelay } = require('node:perf_hooks')
const h = monitorEventLoopDelay({ resolution: 10 })
h.enable()
// h.mean, h.percentile(99) — alert on the p99, not the meanThis is the single most useful Node production metric and almost nobody instruments it. High event loop lag with low CPU means you are blocked on something synchronous; high lag with high CPU means genuine CPU work that belongs on a worker thread or a queue. Being able to say that diagnostic split is a staff-level answer.
Also worth knowing: process.nextTick has its own queue that drains before promise microtasks and can starve the loop if it recurses; and setImmediate versus setTimeout(fn, 0) is non-deterministic at the top level but deterministic inside an I/O callback, where setImmediate always fires first because the check phase follows poll.
- You see 300ms event loop lag in production. Walk me through the diagnosis.
- When would you reach for worker_threads over a queue?
- What does clustering actually give you and what does it not?
- HTTP/1.1 — one request at a time per connection, so browsers open about six connections per origin. Head-of-line blocking at the application layer, which is why bundling and sprite sheets existed.
- HTTP/2 — multiplexed streams over one connection, header compression (HPACK), server push (now largely deprecated). This is why aggressive bundling became counterproductive: many small cacheable files are now often better than one big one.
- HTTP/3 / QUIC — runs over UDP. Solves the remaining problem: HTTP/2 still suffered TCP-level head-of-line blocking, where one lost packet stalls every multiplexed stream. QUIC makes streams independent, and adds 0-RTT connection resumption.
The costs worth quoting: a TCP handshake is one round trip; TLS 1.3 adds one more (TLS 1.2 added two); so a fresh HTTPS connection to a distant origin costs roughly 2 round trips before a single byte of your content moves. At 150ms cross-continent that is 300ms of nothing.
Which explains the fixes: preconnect for origins you will definitely use, keep-alive and connection reuse, and reducing the number of distinct origins — every third-party domain is another handshake.
Resource hints, in order of aggressiveness: dns-prefetch (resolve only) → preconnect (resolve, connect, TLS) → preload (fetch this now, high priority, I need it this navigation) → prefetch (fetch idly, I will probably need it next navigation). Misusing preload for everything makes things worse by competing with the LCP resource for bandwidth.
- Why did bundling everything become an anti-pattern with HTTP/2?
- What is 0-RTT and what is its security caveat? (Replay attacks.)
- How does a CDN change any of this?