50L · Part 5 · The ₹50L track
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.
- saas
- product
Everything open, top to bottom.
08On this page
- How does V8 execute your JavaScript?
- Garbage collection, and how you find a leak
- The browser rendering pipeline, and what triggers each stage
- Node internals — libuv, event loop lag, and where the threads are
- The network layer — HTTP/2, HTTP/3, and connection cost
- A hot function got ten times slower after a harmless-looking change. How do you prove it was deoptimised?
- Why can a loop of already-resolved promises stop a Node server from answering requests?
- p99 latency spikes every few minutes with no change in traffic. How would you tell whether it is garbage collection?
How does V8 execute your JavaScript?
What they are really testingWhether you know why some JavaScript is 100x slower than other JavaScript that looks identical.
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 back"JavaScript is interpreted." V8 starts in an interpreter and compiles hot code to machine code through several tiers, specialised to the types it has seen. That is exactly why the shape of your objects changes the speed of your code.
V8's pipeline in 2026 has four tiers, not two. Ignition interprets bytecode; Sparkplug compiles it to machine code quickly without optimising; Maglev is a fast optimising compiler that uses the type feedback; TurboFan is the slower, most aggressive top tier. A deopt can drop a function from either optimising tier back to bytecode. Naming Maglev is a small signal that your picture of V8 is current.
The 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.)
Garbage collection, and how you find a leak
What they are really testingDirectly relevant to a long-running Node service, and a real staff-level debugging skill.
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 cleared"JavaScript is garbage collected, so it cannot leak." The collector frees what is unreachable. A JavaScript leak is memory that is still reachable by mistake — a listener, a cache, a closure — and the collector is doing its job correctly by keeping it.
The 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?
The browser rendering pipeline, and what triggers each stage
What they are really testingThe frontend half of internals. It is what separates "I used a CSS transition" from "I know why that one janks".
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 phase"React's virtual DOM avoids reflows." It reduces how many DOM writes you make; it does nothing about which properties you change or when you read layout back. An animation on top janks the same with React or without it.
The 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 internals — libuv, event loop lag, and where the threads are
What they are really testingDeeper than R5.1. This is the version asked when they suspect you actually know.
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 mean"Node uses a thread pool for all I/O." Network I/O goes through the operating system's event notification on the main loop; the thread pool serves file system calls, DNS lookups, compression and crypto. Getting that wrong leads to raising UV_THREADPOOL_SIZE to fix a slow HTTP client, which changes nothing.
This 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?
The network layer — HTTP/2, HTTP/3, and connection cost
What they are really testingPerformance work at this level goes below the framework. Directly relevant to your page-load claims.
- 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.
"HTTP/3 is faster because UDP is faster." UDP is only the transport QUIC runs on. The gains are that one lost packet no longer stalls every stream and that connections set up in fewer round trips — on a clean, low-latency network the difference can be small.
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?
A hot function got ten times slower after a harmless-looking change. How do you prove it was deoptimised?
What they are really testingWhether hidden classes and inline caches are something you can observe and measure, not just describe.
Start with the mechanism, briefly, because the proof depends on it. Every property access site keeps an inline cache of the object shapes — hidden classes — it has seen. One shape is monomorphic and compiles to a direct load at a fixed offset. A handful of shapes is polymorphic: a short chain of checks, still fast. Past that the site goes megamorphic and falls back to a generic lookup, and the optimising compiler can no longer specialise the function around it.
The "harmless" changes that do this: adding an optional property to some objects and not others, building the same object in different key orders on two code paths, or passing a class instance and a plain object literal through the same function. Same fields, different shapes.
Then prove it rather than guess:
# 1. did it deoptimise, and why? look for your function by name
node --trace-opt --trace-deopt app.js 2>&1 | grep -A2 scoreBooking
# 2. where does the time actually go? sample the CPU under load
node --cpu-prof app.js # open the .cpuprofile in DevTools
# 3. in a micro-benchmark, ask V8 directly
node --allow-natives-syntax bench.js
# %PrepareFunctionForOptimization(fn); fn(sample)
# %OptimizeFunctionOnNextCall(fn); fn(sample)
# %GetOptimizationStatus(fn) — a bit field, not a boolean"V8 is clever, it optimises everything." It optimises what it has seen, on the assumption it keeps seeing it. Code that feeds one function many shapes is exactly the code it cannot optimise — and the slowdown never shows up as an error, only in a flame graph.
The deopt trace gives a reason. "Wrong map" means an object arrived with a hidden class the optimised code did not expect, which points straight at the shape change. The fix is usually dull: construct these objects in one place, in one order, with every property initialised — to null if need be — so every caller produces the same shape.
Keep the judgement in the answer too. This matters in a function called millions of times a second — a parser, a scoring loop, the hot path of a library. In an ordinary request handler the database costs a thousand times more, and reshaping objects there is not where the time is.
- What is the difference between polymorphic and megamorphic?
- Why does the order you add properties in change the hidden class?
- Would you ever reshape objects in an ordinary API handler?
- What does "wrong map" mean in a deopt trace?
Why can a loop of already-resolved promises stop a Node server from answering requests?
What they are really testingWhether you know that await does not always yield to I/O, and where the event loop actually gets its turn.
Because await yields to the microtask queue, not to the event loop. Awaiting a promise that is already resolved queues the continuation as a microtask, and the microtask queue is drained completely before the loop moves on to timers, I/O or setImmediate. A loop that awaits resolved values a million times never leaves the microtask queue, so incoming requests wait in the poll phase until it finishes — even though every line says await.
process.nextTick is worse: its queue drains even before promise microtasks, so a function that reschedules itself with nextTick starves promises and I/O alike.
app.get('/recalculate', async (req, res) => {
for (const row of rows) { // a million rows
await cache.get(row.id) // resolved from memory — no real I/O
score(row)
}
res.json({ ok: true })
})
// every other request waits: the loop never reaches the poll phaseimport { setImmediate as yieldToLoop } from 'node:timers/promises'
for (let i = 0; i < rows.length; i++) {
score(rows[i])
if (i % 1000 === 0) await yieldToLoop() // a macrotask: pending I/O is served
}
// for genuinely heavy work: a worker thread, or a job queue"It is async, so it does not block." async describes how a function returns, not where its work runs. The CPU work between awaits runs on the main thread either way, and awaits that resolve immediately never hand control back to I/O.
The diagnostic signature is the one from the Node internals question: event loop lag climbs, one core sits at full CPU, and there is no slow query anywhere. The fix depends on the size of the work. Chunking with setImmediate keeps the server responsive but still spends the main thread; anything that takes more than a few hundred milliseconds in total belongs in a worker thread or on a queue, where it cannot hold the loop at all.
- Why does setImmediate let I/O through when a resolved promise does not?
- What happens if process.nextTick schedules itself recursively?
- When would you use a worker thread instead of chunking?
- How is this different from saturating the libuv thread pool?
p99 latency spikes every few minutes with no change in traffic. How would you tell whether it is garbage collection?
What they are really testingWhether you can connect generational GC to a latency graph, and measure it before tuning anything.
First, the shape that points at GC: the spikes are periodic or grow with heap size, they hit every endpoint on the instance at the same moment, and they do not line up with slow queries or slow dependencies. A pause stops all JavaScript on the process, so every request in flight is late together.
Then measure it directly — Node reports every collection as a performance entry, with its kind and its duration:
const { PerformanceObserver } = require('node:perf_hooks')
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
metrics.histogram('gc.pause_ms', e.duration, { kind: e.detail.kind })
}
}).observe({ entryTypes: ['gc'] })
// or, on a test run: node --trace-gc app.js
// Scavenge lines are the young generation; Mark-Compact lines are the costly onesCalling global.gc() on a timer, or raising --max-old-space-size until the spikes move. The first adds pauses of its own; the second makes each major collection bigger and later — and if the heap can now outgrow the container's memory limit, the kernel kills the process instead, with no stack trace.
Read the result through the generations. Frequent short scavenges are normal and cheap. Long Mark-Compact pauses that grow over time mean the old generation is large and growing — either a leak (the heap after each major GC keeps rising, which is the leak workflow from the GC question) or a lot of medium-lived data being promoted, such as unbounded caches or objects held by slow requests.
The fixes, in the order to try them. Stop keeping what you do not need: bound the caches, stream instead of buffering. If short-lived objects are being promoted because the young generation fills too fast under load, a larger --max-semi-space-size lets them die young. And set --max-old-space-size deliberately below the container's memory limit, leaving room for buffers and native memory outside the V8 heap. Then measure again — GC flags are the last lever, not the first.
- What survives a scavenge, and what happens to it next?
- Why can a bigger heap make pauses worse?
- How do you tell a leak from a large but stable heap?
- How much of a Node process's memory lives outside the V8 heap?
Finished this round?
0 of 27 rounds read in this book