Node.js NotesRohit’s interview study guide
Chapter 16

Review Questions

Test yourself — the interview questions from the notes, with worked answers.

6 topics

#Event loop and async questions

These questions check whether you can explain how one thread serves many requests: what await actually suspends, where libuv does the work, why timer order sometimes varies, and what Promise.all and emit really do. Answer each one out loud before you open it. The first paragraph of each answer is what you'd say in the interview; the walkthrough is the reasoning you should be able to give when they ask "why?". Every predict-the-output question was run on Node 26.7, many times where the order could vary.

A route awaits a database query. What is suspended, and does await create a thread?
JavaScript
app.get('/orders', async (req, res) => {
  const orders = await db.query('SELECT * FROM orders WHERE user_id = $1', [req.user.id]);
  res.json(orders);
});
Show answer

Only this handler's continuation — the rest of the async function after the await — is suspended. No thread is created. The main thread goes back to the event loop and runs other requests while the query is in flight, and the continuation later runs on that same main thread.

What’s happening
  1. Express calls the handler on the main thread. It runs synchronously up to the await, which means db.query(...) is called first: the driver writes the query onto a pooled socket and returns a pending promise.
  2. await registers "resume here" on that promise, and the handler returns its own pending promise to Express. The call stack is empty, so the loop is free to parse and run other requests.
  3. The database does the work on its own machine. Node's thread pool isn't involved: a socket is watched by the OS (epoll, kqueue or IOCP), not by a libuv worker thread.
  4. When the result arrives, the poll phase sees the socket is readable, the driver parses the rows and resolves the promise, and the continuation runs as a microtask: res.json(orders).
  5. The trap is that the awaited expression is evaluated before anything is suspended. await expensiveSyncCalculation() blocks the thread for the whole calculation — in the site's example, handler() only returned after 300 ms. async changes when a result is delivered, not where the work runs.

Learn more: Blocking the event loop and How one process serves many requests.

Who starts fs.readFile, what does the work, and where does its callback run?
JavaScript
fs.readFile('report.csv', (err, data) => {
  // …
});
Show answer

Your JavaScript starts it, on the main thread, by calling into Node's fs binding. The blocking file-system calls run on libuv's thread pool (4 threads by default, set with UV_THREADPOOL_SIZE). When they finish, the completion goes back to the event loop and your callback runs on the main thread, in the poll phase.

What’s happening
  1. fs.readFile is a JavaScript function in Node's lib/fs.js. It validates its arguments, calls the C++ binding, which asks libuv to queue the work, and returns undefined straight away.
  2. A pool thread performs the blocking system calls. readFile is really open, stat, read and close, each a separate pool request. None of your JavaScript runs on that thread.
  3. When the last step finishes, libuv signals the loop. In the poll phase the loop runs the completion, and your callback is called with (err, data) on the main thread.
  4. Not everything uses the pool. Sockets (HTTP, database drivers) are non-blocking and watched by the OS. The pool is for file-system work, dns.lookup, crypto.pbkdf2, scrypt and friends, and async zlib. That's why four slow pbkdf2 calls can delay a fifth readFile: all four threads are busy.
  5. So "single-threaded" means your JavaScript runs on one thread. Node itself uses several.

Learn more: Call stack, libuv and the thread pool.

Can two database requests be in flight while only one JavaScript callback runs at a time?

Two users hit a route that awaits a 300 ms query, one millisecond apart.

Show answer

Yes. That's concurrency without parallelism: both queries are waiting in the database at the same time, but the short JavaScript segments before and after each await take turns on the one main thread.

What’s happening
  1. 0 ms: request A's handler runs about 1 ms of JavaScript, sends query A and suspends at await.
  2. 1 ms: the thread is free, so request B's handler runs, sends query B and suspends. Two queries are now in flight and no JavaScript is running at all.
  3. ~300 ms: query A's result arrives; its continuation runs and sends the response. Then B's does the same. Even if both results arrived in the same instant, the two continuations would run one after the other, never at the same time.
  4. Total ≈ 300 ms for both instead of 600 ms. In the site's three-user demo, three 300 ms requests finished in about 326 ms.
  5. So the limit is main-thread time per request, not the number of open connections. Running JavaScript in parallel needs worker threads or more processes.

Learn more: Concurrency vs parallelism and How one process serves many requests.

What happens to /hello while another request runs a long synchronous loop?
JavaScript
app.get('/bad', (req, res) => {
  const start = Date.now();
  while (Date.now() - start < 2000) {} // 2 s of CPU on the main thread
  res.send('done');
});
app.get('/hello', (req, res) => res.send('hello'));
Show answer

/hello waits until /bad's loop finishes. The OS accepts its connection and buffers the bytes, but reading the socket, parsing the HTTP and calling the handler all need the main thread. Measured in the event-loop chapter: a /hello sent 0.1 s after /bad took 1.89 s instead of 6 ms.

What’s happening
  1. /bad's handler starts its while loop. The thread can't get back to the event loop until the handler returns.
  2. 0.1 s later /hello arrives. The kernel completes the TCP handshake and queues the request bytes in the socket buffer. Nothing in Node runs yet.
  3. Timers, other I/O callbacks and every other request queue up behind the loop as well. Event-loop delay jumps to about 2 s.
  4. At 2 s /bad returns. The next poll phase reads the /hello socket, and its handler takes microseconds — so almost all of its 1.89 s was waiting.
  5. The fixes: keep this work off the request path. Chunk it with setImmediate, move it to a worker thread, or put it on a queue. More processes add capacity, but a request that lands on the blocked process still waits.

Learn more: Blocking the event loop and Strategies for CPU-intensive work.

Predict the output: setTimeout(0) vs setImmediate, at the top level and in an I/O callback
q5.cjs
const fs = require('node:fs');

setTimeout(() => console.log('top: timeout'), 0);
setImmediate(() => console.log('top: immediate'));

fs.readFile(__filename, () => {
  setTimeout(() => console.log('io: timeout'), 0);
  setImmediate(() => console.log('io: immediate'));
});
Show answer

The two top: lines come first, in either order. Then always io: immediate, then io: timeout. In 200 runs with stdout piped, top: immediate came first 172 times and top: timeout 28 times; the io: pair was in the same order every time.

What’s happening
  1. The script queues a timer (setTimeout(fn, 0) really means 1 ms), an immediate, and starts the file read, which needs several thread-pool round trips. Then the script ends and the loop starts.
  2. Top level: the loop's first timers check runs top: timeout only if 1 ms has already passed since setTimeout was called. If it has, the timer runs, then poll, then check runs the immediate. If it hasn't, check runs top: immediate first, and the timer runs in the next timers phase.
  3. Whether that 1 ms has passed depends on startup work, where stdout goes and machine load, which is why the chapter saw anything from 1% to 100% "timeout first" for the same lines. Never rely on it.
  4. Later the read finishes and its callback runs in the poll phase. It queues a new timer and a new immediate.
  5. The phase after poll is always check, so io: immediate runs first. The timer can only run in a later timers phase. No timing is involved: this is the guaranteed case.
  6. The interview answer: "Inside an I/O callback, setImmediate always wins. In the main module the order isn't deterministic. If I need an order, I chain one from the other."

Learn more: setTimeout vs setImmediate and The phases of the event loop.

Predict the output: P2 finishes before P1. What does Promise.all return, and what happens when one input rejects?
q21.cjs
const delay = (ms, value, fail = false) =>
  new Promise((resolve, reject) =>
    setTimeout(() => {
      console.log(`${value} settled`);
      fail ? reject(new Error(`${value} failed`)) : resolve(value);
    }, ms));

async function main() {
  const results = await Promise.all([delay(60, 'P1'), delay(20, 'P2')]);
  console.log('all:', results);

  try {
    await Promise.all([delay(60, 'P3'), delay(20, 'P4', true)]);
  } catch (err) {
    console.log('caught:', err.message);
  }
}
main();
Show answer

Results come back in input order, [ 'P1', 'P2' ], even though P2 settled first. When P4 rejects, Promise.all rejects immediately — but P3 isn't cancelled: it still runs and settles afterwards.

Output
P2 settled
P1 settled
all: [ 'P1', 'P2' ]
P4 settled
caught: P4 failed
P3 settled
What’s happening
  1. The array literal calls both delay functions, so both timers start at once. Promise.all doesn't start anything; it waits for promises whose work has already begun.
  2. At 20 ms P2 settles first (P2 settled), and Promise.all stores 'P2' at index 1. At 60 ms P1 settles and goes in index 0. The array is complete, so it fulfils with [ 'P1', 'P2' ]. That's why array destructuring of the result is safe.
  3. Second call: at 20 ms P4 rejects. Promise.all rejects straight away with that error, and the catch prints caught: P4 failed without waiting for P3.
  4. At 60 ms P3 still logs P3 settled. Fail-fast means "stop waiting", not "stop the work". If P3 were an HTTP call it would still use a socket — cancel it with an AbortSignal if that matters.
  5. Nor does it roll anything back: if P3 had charged a card, the charge stands. Consistency needs transactions, compensation and idempotency.

Learn more: Promise.all and running work concurrently and Cancelling work with AbortController.

Predict the output: why doesn't forEach(async …) wait? And why can Promise.all over a huge array be dangerous?
q22.cjs
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const saved = [];

async function main() {
  [30, 10, 20].forEach(async (ms) => {
    await wait(ms);
    saved.push(ms);
  });
  console.log('after forEach:', saved);

  await Promise.all([30, 10, 20].map(async (ms) => {
    await wait(ms);
    saved.push(ms);
  }));
  console.log('after map + Promise.all:', saved);
}
main();
Show answer

after forEach: [], then after map + Promise.all: [ 10, 10, 20, 20, 30, 30 ] (the same in 50 of 50 runs). forEach ignores the promises its callback returns, so nothing waits for them. And Promise.all over a huge array starts every operation at once — it has no concurrency limit.

What’s happening
  1. forEach calls the async callback three times. Each call runs until its await, returns a promise, and forEach throws that promise away. forEach returns undefined at once, so saved is still [] when it's logged.
  2. Those three saves keep running in the background. Nothing holds their promises, so if one rejected it would be an unhandled rejection — a crash on Node 15+.
  3. map returns the three promises and Promise.all waits for them. While it waits, the forgotten forEach saves finish too, interleaved by their timers: 10 ms (forEach), 10 ms (map), 20, 20, 30, 30. Hence every value twice — a neat illustration of work you didn't know was still running.
  4. For one-at-a-time in input order, use for (const ms of list) await …; for concurrent, map + Promise.all.
  5. The danger with a huge populated array is unbounded fan-out: Promise.all(ids.map(fetchUser)) over a million ids opens a million requests, holds a million promises and results in memory, and floods the database pool or the remote API. Use a concurrency limit (a pool of N workers) or batches.
  6. Correcting a common snippet: Promise.all(new Array(1_000_000).map(() => fetchUser())) actually does nothing, because new Array(n) is sparse and map skips empty slots. Use Array.from({ length: n }, …).

Learn more: Promise.all and running work concurrently and Limiting concurrency.

Pick the combinator: all, allSettled, any or race?
  1. You need every result, and one failure makes the whole answer useless.
  2. You need every outcome, failures included (a batch report).
  3. You want the first success from three mirrors.
  4. You want whichever settles first, success or failure (a timeout race).
Show answer

1 → Promise.all, 2 → Promise.allSettled, 3 → Promise.any, 4 → Promise.race. None of them cancels the losing or remaining operations, and none rolls back side effects.

What’s happening
  1. Promise.all fulfils with all values in input order, or rejects with the first rejection. Right when the results only make sense together (user + orders + preferences for one page).
  2. Promise.allSettled never rejects. It fulfils with { status: 'fulfilled', value } or { status: 'rejected', reason } for every input, so you can report "48 sent, 2 failed".
  3. Promise.any fulfils with the first fulfilment and ignores rejections. Only if all reject does it reject, with an AggregateError whose errors array holds every reason.
  4. Promise.race settles like the first input to settle, either way. Promise.race([fetchData(), timeout(2000)]) is the classic use — but the slow fetch keeps running unless you abort it; AbortSignal.timeout(2000) is usually better.
  5. Empty input, checked on Node 26: all([]) and allSettled([]) fulfil with [], any([]) rejects with an AggregateError with no errors, and race([]) stays pending forever.

Learn more: Promise.allSettled, any and race.

Predict the output: synchronous listeners around emit
q34.cjs
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();

bus.on('order', (id) => { console.log('listener 1', id); return 'ignored'; });
bus.on('order', (id) => console.log('listener 2', id));

console.log('A');
const had = bus.emit('order', 42);
console.log('B', had);
console.log('C', bus.emit('nobody-listens'));
Show answer

A, listener 1 42, listener 2 42, B true, C false. emit calls the listeners synchronously, in registration order, before it returns, and its return value only says whether the event had any listeners.

What’s happening
  1. Two listeners are registered for 'order'. Nothing runs yet.
  2. A prints. bus.emit('order', 42) loops over a copy of the listener array and calls each one, right now, on the same call stack: listener 1 42, listener 2 42.
  3. Listener 1's return value 'ignored' really is ignored. emit returns true because 'order' had listeners, so B true prints.
  4. 'nobody-listens' has no listeners, so emit returns false: C false.
  5. Consequences: a slow listener blocks the emitter (and the event loop), and a listener that throws throws out of emit, so later listeners don't run. If you need results back, use a function call or a promise, not an event.

Learn more: emit runs listeners synchronously.

Predict the output: an async listener, then an error event with no listener
q35.cjs
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();

bus.on('save', async (id) => {
  console.log('listener start', id);
  await new Promise((r) => setTimeout(r, 10));
  console.log('listener end', id);
});

console.log('emit returned', bus.emit('save', 1));
console.log('after emit');

try {
  bus.emit('error', new Error('disk full'));
} catch (err) {
  console.log('emit threw:', err.message);
}
Show answer

listener start 1, emit returned true, after emit, emit threw: disk full, and 10 ms later listener end 1 (50 of 50 runs). An async listener starts synchronously, but emit doesn't wait for it. And an 'error' event with no 'error' listener throws from emit.

What’s happening
  1. emit('save', 1) calls the async listener synchronously, so listener start 1 prints first. At the await the listener returns a pending promise, which emit ignores, and emit returns true.
  2. after emit prints while the listener is still waiting for its timer. Code after emit can't assume the listener has finished.
  3. emit('error', …) with no 'error' listener is special: Node throws the error (a non-Error value would be wrapped in ERR_UNHANDLED_ERROR). Here a try/catch catches it because the emit is in the same synchronous code. When a stream or socket emits 'error' from inside an event-loop callback, nothing is there to catch it, and the process crashes — so every emitter that can fail needs an 'error' listener.
  4. At 10 ms the listener resumes: listener end 1.
  5. If the async listener had rejected, the rejection would be unhandled (and crash on Node 15+). Catch inside the listener, or create the emitter with { captureRejections: true } so rejections are routed to the 'error' event.

Learn more: Async listeners and The error event.

#Streams and modules questions

Streams and modules are where Node differs most from browser JavaScript, and interviewers like them because the wrong mental model produces confident wrong answers: "write() returned false, so the chunk was lost", "exports = {} exports an object", "ESM is always lazy". The questions below test the precise version of each idea — what is a value and what is a binding, what is cached and for whom, and who is responsible for errors and cleanup.

Buffer, stream, and a stream's internal buffer: what's the difference? Which side of an HTTP server is Readable?
Show answer

A Buffer is bytes in memory (a fixed-length Uint8Array). A stream is data moving over time, delivered in chunks, usually Buffers. A stream's internal buffer is the queue of chunks waiting between producer and consumer, with highWaterMark as its threshold. On a server, req is Readable and res is Writable.

How they fit together
  1. fs.readFile('big.txt') gives you one Buffer with the whole file. For a 200 MiB file, that's 200 MiB in memory before you can start.
  2. fs.createReadStream('big.txt') delivers the same bytes as a stream of 64 KiB Buffer chunks. You process each one and let it go, so memory stays roughly constant whatever the file size.
  3. Between the file and your code sits the internal buffer: chunks read but not yet consumed. When it reaches highWaterMark, a Readable stops reading ahead, and a Writable's write() starts returning false.
  4. In an HTTP server, the request body arrives over time, so you read it: req (IncomingMessage) is Readable — req.on('data'), for await (const chunk of req), or express.json() reading it for you. The response is something you write: res (ServerResponse) is Writable — res.write(), res.end(), pipeline(file, res).
  5. On the client side it's reversed: the outgoing ClientRequest is Writable and the response you receive is Readable. Underneath both is a TCP socket, which is a Duplex.

Learn more: Buffers, What a stream is and The four kinds of streams.

Predict the output: write() returns false. Was the chunk rejected?
q7.cjs
const { Writable } = require('node:stream');

const slow = new Writable({
  highWaterMark: 16, // bytes
  write(chunk, encoding, callback) {
    setTimeout(callback, 100); // 100 ms per chunk
  },
});
slow.on('drain', () => console.log('drain, buffered =', slow.writableLength));

for (let i = 1; i <= 4; i++) {
  const ok = slow.write(Buffer.alloc(6)); // 6 bytes each
  console.log(`write #${i} -> ${ok}, buffered = ${slow.writableLength}`);
}
Show answer
Output
write #1 -> true, buffered = 6
write #2 -> true, buffered = 12
write #3 -> false, buffered = 18
write #4 -> false, buffered = 24
drain, buffered = 0

No — false means the chunk was accepted, but the buffer has reached highWaterMark, so the producer should stop writing until 'drain'. highWaterMark is a threshold, not a memory cap: write #4 was accepted too.

What’s happening
  1. Write #1: the 6 bytes go to write(), which starts its 100 ms timer. Until its callback runs, they count as buffered: 6 < 16, so true.
  2. Write #2: queued behind the first. 12 < 16, so true.
  3. Write #3: 18 ≥ 16, so false. The chunk is in the queue and will be written. false is the signal "I've got this one, now stop".
  4. Write #4 ignores the signal and is still accepted: 24 bytes buffered. That's how ignoring backpressure turns into memory growing with the size of the input.
  5. After about 400 ms all four callbacks have run, the buffer is empty, and 'drain' fires: that's when a well-behaved producer resumes. In real code pipeline, pipe or await once(stream, 'drain') do this for you.

Learn more: Backpressure.

Why is pipeline easier to handle safely than a chain of pipe calls?
JavaScript
// pipe: three streams, three possible 'error' events, no cleanup
fs.createReadStream('app.log').pipe(zlib.createGzip()).pipe(fs.createWriteStream('app.log.gz'));

// pipeline: one place for the error, everything destroyed on failure
await pipeline(fs.createReadStream('app.log'), zlib.createGzip(), fs.createWriteStream('app.log.gz'));
Show answer

Both handle backpressure, but pipe doesn't handle errors or cleanup. pipeline (from node:stream/promises, or the callback version in node:stream) forwards any stream's error to one place, destroys every stream in the chain, and tells you when everything has finished.

What’s happening
  1. With pipe, an error in one stream is emitted only on that stream. The other streams aren't told. Without an 'error' listener on each of the three, a failure crashes the process.
  2. Even with listeners, nothing is destroyed. In the streams chapter's demo, after the source failed mid-way, the destination was neither destroyed nor ended and 'close' never fired — with a file, that's a leaked file descriptor and a half-written file.
  3. pipeline attaches the error handling for you. The same failure destroyed every stream (dest destroyed? true) and the callback ran once with the error.
  4. The promise form makes completion explicit: await pipeline(...) resolves when the last stream has finished, so you know the .gz file is complete before you upload it.
  5. Rule: use pipeline in application code; reach for pipe only for quick scripts or when you deliberately want to keep the destination open.

Learn more: pipe vs pipeline.

Why is "ESM import is always lazy and asynchronous" misleading? What does dynamic import() return?
Show answer

Static import declarations aren't lazy at all: Node finds and loads the whole static graph before your module's first line runs, and evaluates dependencies first. Only import() is lazy. It's an expression that returns a Promise of the module namespace object, so it can run conditionally, at any time, from ESM or CommonJS.

What’s happening
  1. Static imports are declarations, so Node can read them without running code. It parses main.mjs, finds its imports, loads and parses those, and so on, building the whole graph. The ESM loader is promise-based, and evaluation can be asynchronous (that's what allows top-level await), which is where the "asynchronous" idea comes from.
  2. Then it links imports to exports, and evaluates the graph depth-first: every imported module runs before the module that imports it. Nothing about that is lazy — even an import you never use is loaded and executed.
  3. import('./pdf-export.mjs') is different: it does nothing until that line runs. It returns a promise that resolves to the namespace object — named exports as properties, the default export as .default.
  4. That makes it the lazy-loading tool: load a heavy module only on the route that needs it, choose an implementation at runtime, or load ESM from CommonJS.
  5. It's cached like any import: the second import() of the same module returns the same, already-evaluated instance.

Learn more: Lazy loading with dynamic import() and ES modules in Node.

Predict the output: exports = {} vs module.exports = {}
math-a.cjs
exports = { add: (a, b) => a + b };
math-b.cjs
module.exports = { add: (a, b) => a + b };
q10.cjs
const a = require('./math-a.cjs');
const b = require('./math-b.cjs');
console.log(a, typeof a.add);
console.log(b, typeof b.add);
Show answer

{} undefined, then { add: [Function: add] } function. require returns module.exports. exports is just a local variable that starts out pointing at the same object, so reassigning it changes nothing that require can see.

What’s happening
  1. Node wraps each file in a function with parameters (exports, require, module, __filename, __dirname) and calls it with exports === module.exports, both pointing at one empty object.
  2. In math-a.cjs, exports = { … } rebinds the local parameter to a new object. module.exports still points at the original empty object.
  3. When the wrapper returns, require hands back module.exports: {}, so a.add is undefined.
  4. In math-b.cjs, assigning module.exports replaces the value require returns: { add }.
  5. exports.add = … would have worked in math-a.cjs, because that mutates the shared object instead of rebinding the variable. Pick one style per file.

Learn more: CommonJS: require and module.exports.

Predict the output: ESM live bindings vs destructuring from CommonJS
counter.mjs
export let count = 0;
export function increment() { count++; }
main-esm.mjs
import { count, increment } from './counter.mjs';

console.log(count);
increment();
console.log(count);
counter.cjs
let count = 0;
module.exports = {
  count,
  increment() { count++; module.exports.count = count; },
};
main-cjs.cjs
const counter = require('./counter.cjs');
const { count, increment } = counter;

console.log(count);
increment();
console.log(count, counter.count);
Show answer

ESM prints 0 then 1. CommonJS prints 0 then 0 1. An ESM import is a live binding to the exporter's variable; destructuring a number out of a CommonJS object copies the value at that moment.

What’s happening
  1. ESM: import { count } doesn't copy anything. It creates a read-only view of the count variable inside counter.mjs. When increment() changes that variable to 1, the importer's count reads 1.
  2. CommonJS: module.exports.count is a property holding the number 0, copied from the private let count when the object was created.
  3. const { count } = counter copies that number into a new local constant. It's a primitive, so there's no link back to anything.
  4. increment() updates the private variable and, explicitly, module.exports.count. Reading the property at use time, counter.count, gives 1; the destructured local is still 0.
  5. Practical rule: in CommonJS, share objects and read properties when you need them if you want to see updates. In ESM, even export let primitives update — but importers can't assign to them.

Learn more: CommonJS vs ES modules.

Two modules require the same config object and one mutates it. Why does the other see the change? Do other pods see it?
JavaScript
// state.cjs
module.exports = { featureFlag: false };

// admin.cjs
require('./state.cjs').featureFlag = true;

// handler.cjs
const state = require('./state.cjs'); // sees true after admin.cjs ran
Show answer

Both require calls return the same cached object: state.cjs runs once per process, and every later require of that resolved path gets the same module.exports. That's a process-wide singleton — but only within one process. Other pods, cluster workers and worker threads each have their own copy.

What’s happening
  1. The first require('./state.cjs') resolves it to an absolute path, runs the file, and stores the module in require.cache under that path.
  2. Every later require of the same path skips execution and returns the cached module.exports — the same object reference.
  3. admin.cjs sets a property on that object. handler.cjs holds a reference to the same object, so it reads true. Nothing was "sent": there's only one object.
  4. Each Node process (and each worker thread) has its own module cache and heap. With three pods behind a load balancer, flipping the flag on one pod leaves two pods at false, and users see different behaviour depending on where they land.
  5. So module state is fine for per-process things — a DB pool, a compiled regex, a logger. Shared state (sessions, flags, rate-limit counters) belongs in Redis or a database.

Learn more: Shared state and the singleton pattern and Module caching.

In a CommonJS cycle, what does a module see? And why can deleting require.cache create split state?

a.cjs requires b.cjs, and b.cjs requires a.cjs back. Later, someone runs delete require.cache[require.resolve('./config.cjs')] to "reload" config.

Show answer

In a cycle, the module that's required back gets the partially initialised module.exports — whatever had been exported so far, often {}. Deleting a cache entry doesn't update anyone: modules that already required the old instance keep it, while new require calls evaluate a second instance, so the process now has two copies of that state.

What’s happening
  1. a.cjs starts running and is already in the cache. Halfway through, it calls require('./b.cjs').
  2. b.cjs runs and calls require('./a.cjs'). a is in the cache, so Node returns a's current module.exports without running it again: only what a had exported before its require('./b.cjs') line. In the classic example that's {} (In b, a: {}).
  3. If a later replaces module.exports with a new object, b still holds the old, incomplete one, which shows up as "a.fn is not a function". Node 26 warns when you read a missing property from a module still being loaded in a cycle.
  4. delete require.cache[path] only removes the map entry. The old module object lives on in every variable that references it.
  5. The next require runs the file again and creates a new module.exports. Old consumers see the old config, new consumers the new one — split state. To reload, expose an explicit reload() that mutates one shared object, or restart the process.

Learn more: Circular dependencies and Clearing require.cache.

#Express and security questions

An Express app is where untrusted input first meets your code, so these two topics are interviewed together. The first questions check the middleware model — order, next(), error handlers and what changed in Express 5. The rest check whether you can name the precise defence for each attack: a parameterised query for SQL injection, own-property checks for prototype pollution, output encoding for XSS, SameSite cookies and tokens for CSRF — and what CORS really is.

Predict the output: middleware order, and a handler that never responds
q12.cjs
const express = require('express');
const request = require('supertest');

const app = express();

app.get('/admin', (req, res) => res.send('secret report'));

app.use((req, res, next) => {
  if (req.get('authorization') !== 'Bearer ok') return res.status(401).send('no');
  next();
});

app.get('/profile', (req, res) => res.send('your profile'));

app.get('/stuck', (req, res, next) => {
  console.log('stuck handler ran');
  // forgot to respond or call next()
});

(async () => {
  for (const p of ['/admin', '/profile']) {
    const r = await request(app).get(p);
    console.log(p, r.status, r.text);
  }
  try {
    await request(app).get('/stuck').set('authorization', 'Bearer ok').timeout(500);
  } catch (err) {
    console.log('/stuck', err.code, err.message);
  }
})();
Show answer
Output
/admin 200 secret report
/profile 401 no
stuck handler ran
/stuck ECONNABORTED Timeout of 500ms exceeded

Express runs matching middleware and routes in registration order, so the auth check protects only what comes after it. And a handler that neither responds nor calls next() leaves the request hanging until the client gives up.

What’s happening
  1. GET /admin (no token): the first matching layer is the /admin route, registered before the auth middleware. It responds, the chain stops, and the "protected" report goes out with a 200.
  2. GET /profile (no token): /admin doesn't match, so the auth middleware runs, sees no token, returns 401 and doesn't call next(). The /profile route never runs.
  3. GET /stuck (with token): auth calls next(), the handler logs stuck handler ran and returns without sending anything. Express has no way to know you're "done", so the socket stays open.
  4. The test client gives up after 500 ms (ECONNABORTED). In production that's a request hanging until a load-balancer or client timeout — and a socket held open meanwhile.
  5. Every middleware must do exactly one of: send a response, call next(), or call next(err). Put global middleware (body parsing, auth) before the routes it should cover.

Learn more: Middleware and the request pipeline.

What are an error handler's arguments? What's the difference between err.stack and err.cause?
cause.cjs
async function queryDb() {
  throw new Error('ECONNREFUSED 10.0.0.5:5432');
}
async function loadProfile(id) {
  try {
    return await queryDb();
  } catch (err) {
    throw new Error(`Could not load profile ${id}`, { cause: err });
  }
}
loadProfile(7).catch((err) => {
  console.log(err.message);
  console.log(err.cause.message);
  console.log(err.stack.split('\n')[1].trim());
});
Show answer

An error handler takes four arguments, (err, req, res, next) — Express recognises it by the function having four declared parameters. err.stack records where this error was created (the call path); err.cause holds the underlying error that led to it, so you can add context without losing the original.

Output
Could not load profile 7
ECONNREFUSED 10.0.0.5:5432
at loadProfile (/…/cause.cjs:8:11)
What’s happening
  1. queryDb throws the low-level error. loadProfile catches it and throws a new, more meaningful error, passing the original as { cause: err } (ES2022, Node 16.9+).
  2. err.message is the high-level story, for logs and maybe the client: Could not load profile 7.
  3. err.cause.message is the root cause, for the engineer: the refused database connection. Without cause, wrapping would throw that away.
  4. err.stack points at line 8, where the new error was constructed in loadProfile, not where the database failed. Each error has its own stack; cause links them into a chain. Node's console.log(err) and util.inspect print the cause chain too.
  5. In Express, app.use((err, req, res, next) => …) must keep all four parameters even if next is unused. With three, Express treats it as ordinary middleware and skips it for errors.

Learn more: Error-handling middleware and err.stack and err.cause.

Predict the output: an async route throws in Express 5. What would Express 4 have done?
express5-async.cjs
const express = require('express');
const request = require('supertest');

const users = new Map([['1', { id: '1', name: 'Rohit' }]]);
async function findUser(id) {
  await new Promise((r) => setTimeout(r, 5)); // pretend DB call
  const user = users.get(id);
  if (!user) throw Object.assign(new Error(`User ${id} not found`), { status: 404 });
  return user;
}

const app = express();
app.get('/users/:id', async (req, res) => {
  const user = await findUser(req.params.id); // no try/catch
  res.json(user);
});
app.use((err, req, res, next) => {
  res.status(err.status ?? 500).json({ error: err.message });
});

(async () => {
  for (const id of ['1', '2']) {
    const r = await request(app).get(`/users/${id}`);
    console.log(id, r.status, r.text);
  }
})();
Show answer
Output
1 200 {"id":"1","name":"Rohit"}
2 404 {"error":"User 2 not found"}

Express 5 forwards a rejected promise from a handler to the error middleware, so no try/catch is needed. In Express 4 the rejection was ignored: /users/2 would hang, and on Node 15+ the unhandled rejection would crash the process.

What’s happening
  1. /users/1: the handler awaits findUser, gets the user, and res.json sends it.
  2. /users/2: findUser throws after an await, so the throw happens in a later microtask — long after Express's synchronous try/catch around the handler call has finished.
  3. But the async handler returns a promise, and that promise rejects. Express 5 attaches a rejection handler to whatever the handler returns and calls next(err) with the reason.
  4. The four-argument error handler uses err.status and sends 404 {"error":"User 2 not found"}.
  5. Express 4 ignored the returned promise, which is why Express 4 code wraps every async route in an asyncHandler(fn) that does Promise.resolve(fn(req, res, next)).catch(next), or uses express-async-errors. In Express 5 those wrappers can go. Throws from detached callbacks (a setTimeout, an emitter listener) still aren't caught by either version.

Learn more: Error-handling middleware and A higher-order wrapper for async errors.

You upgrade an app from Express 4 to Express 5. What breaks?
JavaScript
app.get('/files/*', serveFile);       // Express 4 wildcard
app.get('/users/:id?', showUser);     // optional parameter
app.use((req, res, next) => { req.query = normalise(req.query); next(); });
app.post('/signup', (req, res) => { const { email } = req.body; /* … */ });
res.json(404, { error: 'not found' });
Show answer

Every line. The route strings throw a PathError at registration, so the app fails at startup; req.query is now a getter; req.body is undefined when nothing was parsed; and the old two-argument response forms are gone. In exchange, async errors are forwarded for you.

What’s happening
  1. /files/* → PathError: Missing parameter name. Express 5 uses path-to-regexp v8: every wildcard needs a name. Write /files/*splat and read req.params.splat, an array of segments; /files{/*splat} also matches /files itself.
  2. /users/:id? → PathError: Unexpected ?. Optional parts use braces now: /users{/:id}. +, ( and ) are reserved too; pass a real RegExp if you need one.
  3. req.query = … — req.query is a getter in Express 5. The assignment is silently ignored in sloppy mode and throws a TypeError in strict mode. Store the normalised value somewhere else, like res.locals. The default query parser is also 'simple', so filter[age]=30 is no longer a nested object.
  4. const { email } = req.body throws Cannot destructure property 'email' of 'req.body' as it is undefined for a request with no body or the wrong Content-Type. Use req.body?.email, or validate first.
  5. res.json(404, obj) and res.send(404) — the status-as-argument forms were removed. Write res.status(404).json(obj); res.send(42) now sends the body 42.
  6. The good part: rejected promises from handlers go to your error middleware, and the app.listen callback receives the listen error (like EADDRINUSE) as its argument, so check it instead of assuming the server started.

Learn more: Routes, params, query and body, Parsing request bodies and Sending responses.

Parameterisation, validation and an identifier allowlist: what problem does each one solve?
JavaScript
// GET /products?category=books&minPrice=10&sort=price
Show answer

They're three different layers. Parameters keep values from changing the query's syntax. Validation decides whether a value is acceptable at all (a price must be a non-negative number). An allowlist is the only safe way to choose identifiers and keywords, like a sort column, which can't be parameters.

What’s happening
  1. WHERE category = ? AND price >= ? with [category, minPrice] bound separately: the database parses the SQL first, then puts the values into typed slots. books' OR '1'='1 is just a strange category name that matches nothing. That's the injection defence.
  2. Parameters don't make a value sensible. minPrice=-5, minPrice=abc or a 10 MB category string are all perfectly safe as parameters and still wrong. Validation (express-validator, a schema) rejects them with a 400 before any query runs.
  3. ORDER BY ? doesn't work: placeholders stand for values, not column names or ASC/DESC. Interpolating req.query.sort would reopen injection.
  4. So map input to fixed SQL fragments: const SORTS = { price: 'price', newest: 'created_at DESC' }, use SORTS[sort] ?? 'name' (with an own-property check), and never put the raw string in the query.
  5. One sentence for the interview: "Parameters protect structure, validation protects meaning, and allowlists cover the parts of SQL that parameters can't."

Learn more: SQL injection and parameterised queries.

Predict the output: a login query built with a template literal
sqli.cjs
const { DatabaseSync } = require('node:sqlite');

const db = new DatabaseSync(':memory:');
db.exec(`
  CREATE TABLE users (name TEXT, password_hash TEXT);
  INSERT INTO users VALUES ('rohit', 'h1'), ('admin', 'h2');
`);

const name = "admin' --";        // typed into the login form
const passwordHash = 'wrong';

const sql = `SELECT name FROM users WHERE name = '${name}' AND password_hash = '${passwordHash}'`;
console.log(sql);
console.log('unsafe:', db.prepare(sql).all().map((r) => r.name));

const login = db.prepare('SELECT name FROM users WHERE name = ? AND password_hash = ?');
console.log('safe:', login.all(name, passwordHash).map((r) => r.name));
Show answer
Output
SELECT name FROM users WHERE name = 'admin' --' AND password_hash = 'wrong'
unsafe: [ 'admin' ]
safe: []

The unsafe query logs the attacker in as admin with a wrong password. The input's quote closes the string literal and -- comments out the password check. The parameterised version treats the whole input as one name, which matches nobody.

What’s happening
  1. The template literal pastes the input into the SQL text. The ' after admin closes the string the developer opened, so the SQL now says name = 'admin'.
  2. -- starts an SQL comment, so everything after it — the stray quote and AND password_hash = 'wrong' — is ignored by the parser.
  3. The query that actually runs is SELECT name FROM users WHERE name = 'admin', and it returns the admin row. A login that checks "did we get a row?" lets the attacker in.
  4. The prepared version is parsed with two ? slots before any input arrives. admin' -- is bound as a 9-character value; no user has that name, so the result is [].
  5. Escaping quotes by hand is a weaker fix: it's easy to miss a context (numbers, LIKE patterns, identifiers). Placeholders separate code from data by construction. (The example uses node:sqlite, built into Node 26 with no flag; real code would also compare a password hash, not a stored string.)

Learn more: SQL injection and parameterised queries.

Predict the output: a deep merge of a JSON body
pollution.cjs
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (!(key in target)) target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

const body = JSON.parse('{"theme": "dark", "__proto__": {"isAdmin": true}}');
const settings = merge({}, body);

console.log(Object.keys(settings));
console.log(settings.isAdmin);
console.log({}.isAdmin);
console.log(Object.hasOwn(settings, 'isAdmin'));

delete Object.prototype.isAdmin; // clean up the demo
Show answer

[ 'theme' ], true, true, false. The merge wrote isAdmin onto Object.prototype, so every object in the process now inherits isAdmin: true — including a brand-new {} — while settings itself doesn't own it.

What’s happening
  1. JSON.parse creates an own property literally named "__proto__". That alone pollutes nothing: it's just data, which is exactly why attackers send it as JSON.
  2. for…in visits theme (copied normally) and then "__proto__", whose value is an object, so the merge recurses.
  3. key in target is true for "__proto__" (every object has the inherited accessor), so no new object is created. target["__proto__"] reads the prototype: Object.prototype. The recursive call then does Object.prototype.isAdmin = true.
  4. Object.keys(settings) shows only theme — the data looks clean when you log it. But settings.isAdmin and {}.isAdmin both find true on the shared prototype. Object.hasOwn reveals it's inherited.
  5. In a server this persists across every later request until restart, and any check like if (user.isAdmin) on an object without its own isAdmin is now bypassed. Fixes: skip __proto__, constructor and prototype keys, only recurse into own properties, validate the body against a schema, use Map or Object.create(null) for dictionaries, and use Object.hasOwn for security flags.

Learn more: Prototype pollution and Mass assignment.

XSS vs CSRF: what's the difference, and why doesn't a CSRF token help against XSS?
Show answer

XSS gets the attacker's script running inside your page, with your origin's privileges: it can read the page, call your API and read the answers. CSRF makes the victim's browser send a request to your site from the attacker's page, with your cookies attached, but the attacker can't read the response. CSRF defences assume there's no XSS: a script running on your origin can simply read the CSRF token and send it.

What’s happening
  1. XSS: a comment containing <img src=x onerror="…"> is stored and rendered without encoding. In every viewer's browser, that script runs as your JavaScript.
  2. From there the script can fetch('/api/me/email', { method: 'PUT', … }). The browser attaches the session cookie (same origin), and because the request is same-origin, the script can read the response and any CSRF token in the page. HttpOnly only stops it reading document.cookie, not using the session.
  3. CSRF: the attacker's site has a hidden form that posts to https://bank.test/transfer. Browsers attach cookies based on the destination, so the request arrives with the victim's session and looks legitimate. The same-origin policy stops the attacker reading the reply, but the transfer already happened.
  4. Defences differ. XSS: contextual output encoding, no innerHTML with user data, sanitising rich text, and a strict CSP as a backstop. CSRF: SameSite=Lax or Strict cookies, a CSRF token bound to the session (synchronizer or signed double-submit) on state-changing routes, and an Origin check.
  5. Memory aid: XSS is "their code in your page"; CSRF is "your cookies on their request". Fix XSS first, because it defeats CSRF protection.

Learn more: Cross-site scripting (XSS), Cross-site request forgery (CSRF) and Content Security Policy.

Does CORS stop curl? Does running on Kubernetes remove the need for rate limiting?
Show answer

No to both. CORS is a browser policy about which pages may read your responses; curl, Postman and other servers ignore it. And Kubernetes doesn't limit how often a client may call you: per-pod in-memory limits multiply with the replica count, so you need a shared store or gateway-level limits.

What’s happening
  1. CORS headers are instructions to a browser: "pages from these origins may read this response". A browser enforces them for its own pages. curl doesn't send a preflight or read the headers; it just makes the request and prints the response.
  2. So CORS protects your users (another site's JavaScript can't read their data using their cookies), not your API. Access control is still authentication and authorisation on the server.
  3. Kubernetes gives you replicas, restarts and autoscaling. None of that rejects a client sending 10,000 login attempts a minute; autoscaling might even add pods to serve the attack.
  4. With an in-memory limit of 100 per minute and 3 pods behind a load balancer, a client gets up to about 300 per minute, and the number changes every time you scale. Use a shared store (express-rate-limit with a Redis store) or enforce limits at the API gateway or ingress.
  5. Behind a load balancer, set trust proxy correctly, or every request appears to come from the balancer's IP and one limit applies to everyone.

Learn more: CORS, Rate limiting and HTTPS, proxies and trust proxy.

Predict the output: a credentialed PUT from an allowed origin, then from evil.test
cors.cjs
const express = require('express');
const cors = require('cors');
const request = require('supertest');

const app = express();
app.use(cors({ origin: ['https://app.example.test'], credentials: true }));
app.put('/profile', (req, res) => res.json({ ok: true }));

(async () => {
  const pre = await request(app)
    .options('/profile')
    .set('Origin', 'https://app.example.test')
    .set('Access-Control-Request-Method', 'PUT')
    .set('Access-Control-Request-Headers', 'content-type');
  console.log(pre.status);
  for (const h of ['access-control-allow-origin', 'access-control-allow-credentials',
                   'access-control-allow-methods', 'access-control-allow-headers', 'vary']) {
    console.log(`  ${h}: ${pre.headers[h]}`);
  }

  const evil = await request(app).put('/profile').set('Origin', 'https://evil.test');
  console.log('evil origin:', evil.status, evil.text, '| allow-origin =', evil.headers['access-control-allow-origin']);
})();
Show answer
Output
204
  access-control-allow-origin: https://app.example.test
  access-control-allow-credentials: true
  access-control-allow-methods: GET,HEAD,PUT,PATCH,POST,DELETE
  access-control-allow-headers: content-type
  vary: Origin, Access-Control-Request-Headers
evil origin: 200 {"ok":true} | allow-origin = undefined

The preflight is answered with the exact origin (never *, because the request carries credentials) plus Allow-Credentials: true. The request from evil.test still runs the handler and gets a 200: CORS only withholds the allow header, so a browser would hide the response from the page.

What’s happening
  1. fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include' }) isn't a "simple" request (PUT, and a JSON content type), so the browser first sends an OPTIONS preflight with Origin, Access-Control-Request-Method and Access-Control-Request-Headers. The supertest call imitates it.
  2. The cors middleware finds the origin in its allowlist and answers 204 with that origin echoed back, the allowed methods and headers, and Access-Control-Allow-Credentials: true. Browsers refuse Access-Control-Allow-Origin: * for credentialed requests, which is why the package echoes the exact origin.
  3. Vary: Origin tells caches that the response differs per origin, so a CDN doesn't serve one origin's CORS headers to another.
  4. The evil.test request isn't blocked by the server: the handler ran and returned {"ok":true}. The middleware just didn't add Access-Control-Allow-Origin. A real browser would have stopped at the failed preflight, but curl or a script would not.
  5. So for anything state-changing, CORS is not your protection. Authentication, authorisation and CSRF defences are.

Learn more: CORS.

#Auth questions

Authentication questions are rarely about syntax. Interviewers want to hear that you separate format from purpose (a JWT is a format; an access token is a role), decoding from verifying, and detecting theft from preventing it. They also want the browser side: where tokens live, which cookie attributes matter, and why OAuth has both state and PKCE. Keep the four OAuth actors and the four kinds of token straight and most of these answer themselves.

Predict the output: can a JWT be an access token, and does decoding it prove anything?
jwt.cjs
const jwt = require('jsonwebtoken');
const secret = 'dev-only-secret-at-least-32-bytes-long!!';

const token = jwt.sign({ sub: 'user-7', role: 'viewer' }, secret, {
  algorithm: 'HS256', expiresIn: '15m', issuer: 'auth.example.test', audience: 'orders-api',
});

const [header, payload, signature] = token.split('.');
const forgedPayload = Buffer.from(JSON.stringify({ ...jwt.decode(token), role: 'admin' })).toString('base64url');
const forged = `${header}.${forgedPayload}.${signature}`;

console.log(jwt.decode(forged).role);
try {
  jwt.verify(forged, secret, { algorithms: ['HS256'], issuer: 'auth.example.test', audience: 'orders-api' });
} catch (err) {
  console.log(err.name, err.message);
}
console.log(jwt.verify(token, secret, { algorithms: ['HS256'], issuer: 'auth.example.test', audience: 'orders-api' }).role);
Show answer

admin, then JsonWebTokenError invalid signature, then viewer. Yes, a JWT can be an access token — JWT is the format, access token is the purpose. But decoding authenticates nothing: anyone can edit the payload and decode will happily show it. Only verify checks the signature.

What’s happening
  1. jwt.sign builds base64url(header).base64url(payload) and signs those two parts with HMAC-SHA256 and the secret. The payload is encoded, not encrypted: anyone holding the token can read it.
  2. The forger decodes the payload, changes role to admin, re-encodes it, and keeps the old signature. They can't compute a new one without the secret.
  3. jwt.decode(forged) only base64url-decodes, so it prints admin. Code that authorises from decode has just made the attacker an admin.
  4. jwt.verify recomputes the HMAC over the forged header and payload, compares it with the signature, and fails: invalid signature. The original token verifies, and its role is viewer.
  5. The same token format serves other purposes too — ID tokens are JWTs, and an access token can be an opaque random string instead. What makes it an access token is that it's issued for an API (its aud) to authorise calls.

Learn more: How a JWT is built and Signing and verifying JWTs.

Beyond a valid signature, what must you check before trusting a JWT?
Show answer

That it was signed by a key you trust for the expected issuer, with an algorithm on your allowlist, for your audience, and that it's within its time window (exp, nbf). Then the claims your application requires — sub, scopes, tenant — and finally your own authorisation rules. A valid signature only proves who issued it, not that it's meant for you or that the caller may do this.

What verify should check, in order
  1. Algorithm: pass algorithms: ['RS256'] (or whatever you use). Without an allowlist, a token can pick its own alg, which is how none and RS/HS key-confusion attacks worked against older libraries.
  2. Key: the key comes from your configuration or the issuer's JWKS, selected by kid — never from a URL or key inside the token itself.
  3. Issuer and audience: iss must be your authorisation server; aud must be this API. A token issued for another service, by the same issuer, must be rejected here.
  4. Time: exp must be in the future and nbf in the past, with a small clock tolerance. Make sure your tokens actually carry exp: jsonwebtoken only enforces it if it's present.
  5. Application claims and policy: required claims exist (sub, scope), the scope covers this endpoint, and the user may act on this resource (ownership, tenant). Optionally a revocation or token-version check for high-risk actions.

Learn more: Signing and verifying JWTs, Key rotation with kid and JWKS and Authentication vs authorization.

Why use refresh tokens? Walk through RT1 → RT2, reuse detection, token families and the attacker-first case.
Show answer

A refresh token lets the client get new short-lived access tokens without the user logging in again, so access tokens can expire in minutes. With rotation each refresh token is single-use: redeeming RT1 consumes it and returns RT2 in the same family. If a consumed token is ever presented again, someone has a copy, so the server revokes the whole family. It detects theft; it doesn't prevent it.

What’s happening
  1. Login: the server issues AT1 (say 15 minutes) and RT1 (days), stores a hash of RT1 as the first member of a new family F, and sends RT1 in an HttpOnly cookie or secure storage.
  2. Refresh: AT1 expires; the client sends RT1. In one atomic transaction the server checks RT1 is current, marks it used, and issues AT2 plus RT2 in family F. Atomic, so two concurrent refreshes with RT1 can't both succeed.
  3. Reuse: RT1 arrives again after it was used. Either a client bug or a stolen copy — the server can't tell, so it revokes every token in F and both parties must log in again.
  4. Attacker first: the attacker steals RT1 and refreshes before the real client does. The attacker gets RT2 and has access. Later the real client sends RT1, which is now used: reuse detected, family revoked, the attacker's RT2 dies. The attacker had a window, which is why access tokens stay short and families get an absolute lifetime.
  5. Store only hashes of refresh tokens, bind them to the client, and keep a per-family record so revocation is one update.

Learn more: Access tokens and refresh tokens and Refresh-token rotation and reuse detection.

Does revoking a refresh-token family instantly invalidate the access JWTs already issued?
Show answer

No. A JWT access token is verified locally from its signature and exp, so revoking the family stops future refreshes, but an already-issued access token stays valid until it expires — unless the API also checks a revocation rule.

What’s happening
  1. The family is revoked at 10:00. The attacker's AT2 was issued at 09:58 with a 15-minute lifetime.
  2. Every API instance verifies AT2 with the public key: signature fine, exp 10:13 still in the future. No database lookup, so nothing tells it about the revocation.
  3. Result: the attacker can call APIs until 10:13. That's the price of stateless validation — and why access tokens should live minutes, not hours.
  4. If that window is unacceptable (payments, admin actions), add a check: a jti denylist in Redis until the token's exp, a per-user "tokens issued before X are invalid" timestamp, or token introspection at the authorisation server for high-risk endpoints.
  5. Each of those adds a lookup per request, which is the trade-off against sessions, where revocation is instant because every request is a lookup anyway.

Learn more: JWT limitations and workarounds and Sessions vs JWTs.

Does HttpOnly solve XSS? What new problem do automatically sent cookies bring?
JavaScript
res.cookie('access_token', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 15 * 60 * 1000 });
Show answer

No. HttpOnly stops JavaScript reading the cookie, so an XSS payload can't steal and exfiltrate the token — but the script can still use it, because the browser attaches the cookie to every request the script makes to your site. And because cookies are sent automatically, cookie-based auth brings back CSRF, which needs its own defence.

What’s happening
  1. With the token in localStorage, one XSS bug lets the script read it and send it to the attacker, who can use it from anywhere until it expires.
  2. With an HttpOnly cookie, document.cookie doesn't show it. That's a real improvement: the token can't leave the browser.
  3. But the injected script runs on your origin. fetch('/api/transfer', { method: 'POST', … }) goes out with the cookie, and the script can read the response. The attack works for as long as the page is open. Only preventing XSS (output encoding, CSP) stops that.
  4. Cookies are attached based on the destination, so a form on evil.test that posts to your site also carries the cookie. That's CSRF. SameSite=Lax (Express's sameSite: 'lax') blocks cross-site POSTs, but add a CSRF token or an Origin check on state-changing routes as well.
  5. Secure keeps it off plain HTTP, and a short maxAge limits how long any leak is useful.

Learn more: Where to store tokens in the browser and Cross-site request forgery (CSRF).

Name the four OAuth roles, then distinguish the authorization code, access token, refresh token and ID token.

"Sign in with Google" for an app that also reads the user's Google Drive files.

Show answer

Resource owner (the user), client (your app), authorization server (Google's accounts and token endpoints) and resource server (the Drive API). The code is a short-lived, one-time voucher the client exchanges for tokens; the access token goes to the resource server to call APIs; the refresh token goes back to the authorization server for new access tokens; the ID token (OpenID Connect) tells the client who logged in.

What’s happening
  1. The user (resource owner) clicks "Sign in". Your app (the client) redirects the browser to Google's authorization server with client_id, redirect_uri, scope=openid email drive.readonly, state and a PKCE challenge.
  2. The user approves. Google redirects back with ?code=…&state=…. The code travels through the browser, so it's single-use, short-lived and useless without the PKCE verifier (and the client secret, for a confidential client).
  3. Your server exchanges the code at the token endpoint and receives an access token (audience: Google APIs, limited to the granted scopes), a refresh token if offline access was requested, and an ID token because of openid.
  4. The access token goes in Authorization: Bearer … to the Drive API (the resource server). The refresh token is only ever sent back to the authorization server. The ID token is a JWT addressed to your client (aud = your client_id): validate it and identify the user by iss + sub.
  5. The classic mistakes: using an ID token to call an API, or treating "has an access token" as "is logged in". OAuth is delegated authorisation; OIDC adds authentication.

Learn more: OAuth 2.0: the four roles and OpenID Connect and ID tokens.

Explain state and PKCE separately, how they overlap, and why both must be bound to the session.
Show answer

state ties the callback to a request this browser's session started: the client generates a random value, stores it in the session, and rejects a callback whose state doesn't match. PKCE ties the code redemption to a secret only this flow knows: the client sends a hash of a random verifier up front and the verifier itself when it exchanges the code. They overlap because PKCE also defeats login CSRF — but only if the verifier and state are stored per attempt in the user's session and checked strictly.

What’s happening
  1. state: before redirecting, the client stores state = random() in the session and sends it. At the callback it checks that the query's state equals the stored one, then consumes it. An attacker who sends a victim to /callback?code=ATTACKER_CODE can't know the victim's state, so the forged callback is rejected (login CSRF).
  2. PKCE: the client creates a code_verifier, sends code_challenge = BASE64URL(SHA256(verifier)) with code_challenge_method=S256, and keeps the verifier. At the token endpoint it sends the verifier; the server hashes it and compares. A stolen code is useless without the verifier.
  3. The overlap (RFC 9700): an injected attacker code was issued against the attacker's challenge, but the victim's session holds a different verifier, so redemption fails. If you're sure the authorization server enforces PKCE, that protects you from CSRF too. state is still useful for correlating responses and carrying per-attempt data.
  4. Binding is what makes either work. A fixed state, one global slot shared by all users, or the check req.query.state !== req.session.oauthState when both are undefined (it's false, so the check passes) gives no protection at all.
  5. One sentence: "state ties the callback to the session, PKCE ties the code to the flow, and nonce ties the ID token to the login attempt."

Learn more: The state parameter and Authorization Code flow with PKCE.

#Memory and performance questions

Performance questions reward people who measure before they fix. Memory questions reward people who know the garbage collector frees what's unreachable, not what's unused — so a leak is always a reference you forgot about. This section also covers the two performance tools most Node services reach for, caching and rate limiting, where the interesting questions are about what happens under concurrency: a thousand simultaneous cache misses, or a burst that straddles a window boundary.

Why can an object you no longer need stay in memory? What do you look for in a heap snapshot?
JavaScript
const requestLog = new Map();
app.use((req, res, next) => {
  requestLog.set(req.id, { url: req.url, startedAt: Date.now(), req });
  next();
});
Show answer

Because V8 frees objects that are unreachable, not ones you've stopped using. Here every request object stays reachable from a module-level Map, so it can never be collected. In heap snapshots, compare two or three taken after repeating the same workload, look for constructors whose count and retained size grow each time, and follow the retainers path back to the root that's holding them.

What’s happening
  1. requestLog is a module-level variable, reachable from the module scope for the life of the process. Everything it references is reachable too.
  2. Each request adds an entry that also references req — its headers, socket, parsed body. Nothing ever deletes it, so memory grows with traffic. That's the leak.
  3. In DevTools (or v8.writeHeapSnapshot()), take a snapshot, send 1,000 requests, force GC and take another, repeat once more. The comparison view shows IncomingMessage and the entry objects growing by about 1,000 each time.
  4. The retainers panel shows the path: IncomingMessage ← entry object ← Map ← requestLog in the module's context. That names the line to fix.
  5. Fix the lifetime: delete the entry on res.on('finish'), store only what you need instead of req, or bound the map (LRU with TTL). Common relatives: listeners never removed, intervals never cleared, closures capturing big objects, unbounded caches.

Learn more: How garbage collection decides what to free, Common causes of memory leaks and Finding a memory leak.

Is a high memory reading, or a MaxListenersExceededWarning, proof of a leak?
Show answer

No to both. High memory can be a heap that V8 simply hasn't needed to shrink, external buffers, or a cache working as designed. A leak is memory that keeps growing under a steady workload, even after GC. The warning is a heuristic — more than 10 listeners for one event on one emitter — and raising the limit only silences it.

What’s happening
  1. RSS includes the V8 heap, Buffers outside the heap, code and native memory. V8 grows the heap lazily and doesn't hurry to return pages, so a busy service sitting at 600 MB may be perfectly healthy.
  2. What to watch is heapUsed after GC over time under constant load. A sawtooth with a flat floor is normal; a floor that rises with every cycle is a leak.
  3. MaxListenersExceededWarning fires when an 11th listener is added to the same event of one emitter. Run with --trace-warnings to get the stack of the on call that crossed the line.
  4. Then ask whether that count is fixed by design (a shared emitter with 15 known subscribers) or grows with requests or connections. If it grows, something subscribes per request and never calls off — that's the leak, and the fix is removing the listener.
  5. emitter.setMaxListeners(n) is right only when the count is intentional and bounded. Calling it to make the warning go away leaves the leak in place.

Learn more: Finding a memory leak and MaxListenersExceededWarning.

A dashboard endpoint has a p99 of 2 seconds. How would you speed it up?
Show answer

Measure first, then fix the biggest cost. Find out whether the time is the main thread working (CPU, event-loop delay) or waiting (database, other services). Usually it's waiting: independent calls running one after another, or a slow query. Then check CPU, then caching, then response size — and re-run the same load test after each change.

A typical order of work
  1. Measure under realistic load: p50/p95/p99 and throughput for the route, plus a trace or timing per step. Look at the p99, not the average.
  2. Overlap independent I/O: three awaits that don't depend on each other (user, orders, notifications) run sequentially by default. Promise.all turns 300 + 400 + 200 ms into about 400 ms.
  3. Fix the slow query: EXPLAIN it, add the missing index, remove N+1 queries, select only the columns you need.
  4. Check the CPU: if event-loop delay rises, take a CPU profile (--cpu-prof) and look for a synchronous hot spot — a big JSON.stringify, a sort, a sync crypto call. Improve the algorithm or move it to a worker pool.
  5. Cache expensive results that tolerate staleness, with a TTL and a size bound, coalescing concurrent misses. Then stream or paginate big responses and turn down logging on the hot path.
  6. Re-measure with the same test and keep only the changes that moved the numbers.

Learn more: What to measure, Measuring event-loop delay, Profiling CPU and Speeding up a slow route, step by step.

Predict the output: 1,000 concurrent requests miss the cache for the same key
stampede.cjs
const { setTimeout: sleep } = require('node:timers/promises');

let dbReads = 0;
async function loadProduct(id) {
  dbReads++;
  await sleep(50); // slow query
  return { id, name: `Product ${id}` };
}

const cache = new Map();    // id -> value
const inFlight = new Map(); // id -> promise

async function getProductNaive(id) {
  if (cache.has(id)) return cache.get(id);
  const value = await loadProduct(id);
  cache.set(id, value);
  return value;
}

function getProductCoalesced(id) {
  if (cache.has(id)) return Promise.resolve(cache.get(id));
  if (inFlight.has(id)) return inFlight.get(id);
  const p = loadProduct(id)
    .then((value) => { cache.set(id, value); return value; })
    .finally(() => inFlight.delete(id));
  inFlight.set(id, p);
  return p;
}

(async () => {
  await Promise.all(Array.from({ length: 1000 }, () => getProductNaive(7)));
  console.log('naive:', dbReads, 'DB reads');

  cache.clear();
  dbReads = 0;
  await Promise.all(Array.from({ length: 1000 }, () => getProductCoalesced(7)));
  console.log('coalesced:', dbReads, 'DB read(s)');
})();
Show answer

naive: 1000 DB reads, then coalesced: 1 DB read(s). That's a cache stampede: every request that misses before the first one has filled the cache goes to the database. Sharing the in-flight promise per key turns 1,000 misses into one query.

What’s happening
  1. Naive: all 1,000 calls run their cache.has(7) check synchronously, one after another, before any query has finished. All of them miss, and all of them call loadProduct: 1,000 identical queries hitting the database at the same moment.
  2. Coalesced: call 1 misses the cache and finds nothing in flight, so it starts the query and stores the promise in inFlight synchronously, before returning.
  3. Calls 2–1,000 miss the cache but find that promise and return it. All 1,000 callers await the same query.
  4. After 50 ms the query resolves: the value goes into the cache, every caller gets it, and finally removes the in-flight entry, so a failure isn't cached forever.
  5. This works within one process. Across many instances, add jittered TTLs so hot keys don't all expire together, serve stale data while one refresh runs (guarded by a short Redis lock like SET key value NX PX 5000), or refresh hot keys ahead of expiry.

Learn more: Cache stampedes and Cache-aside with a TTL.

Limit: 5 requests per 10 seconds. A client sends 5 at 9.0–9.4 s and 5 more at 10.0–10.4 s. What do the algorithms allow?

Compare a fixed window, a sliding window log, and a token bucket with capacity 5 refilling 0.5 tokens per second.

Show answer

Fixed window accepts all 10, because its counter resets at 10 s — twice the limit in 1.4 seconds. The sliding log accepts exactly 5. The token bucket accepts 5 and rejects the second burst, because only 0.5 tokens have come back by 10.0 s.

What’s happening
  1. Fixed window: requests at 9.0–9.4 s fall in window [0, 10 s) and bring its counter to 5. At 10.0 s a new window [10, 20 s) starts at 0, so all five are accepted again. Cheap (one counter per client), but it allows double bursts at boundaries.
  2. Sliding log: at 10.0 s it counts accepted timestamps in the last 10 seconds — still all five — and rejects. Every request in the second burst is rejected. Exact, but it stores up to limit timestamps per client.
  3. Token bucket, full at 9.0 s: each request takes a token while 0.05 tokens drip back per 100 ms: 4.00, 3.05, 2.10, 1.15, 0.20 left after the fifth.
  4. At 10.0 s it holds 0.50 tokens (0.20 + 0.6 s × 0.5): less than one, so rejected; then 0.55, 0.60, 0.65, 0.70 — all rejected. The capacity sets the allowed burst; the refill rate is the sustained limit.
  5. Whichever you choose, rejected requests get 429 Too Many Requests with Retry-After, and with several instances the counters live in a shared store with atomic updates.

Learn more: Rate-limiting algorithms and Rate limiting.

#Scaling and architecture questions

The last group is about running Node beyond one process and one service: choosing between workers, cluster, child processes and queues; shipping safely (lockfiles, graceful shutdown, zero-downtime deploys); testing what matters; and designing services that survive each other's failures. The theme in every answer is that each tool solves one specific problem — and saying which problem is what makes the answer sound senior.

Workers, cluster, streams and queues: what problem does each one solve?
Show answer

Worker threads move CPU-heavy JavaScript off the main thread. Cluster (or more pods) runs more copies of the server to use more cores and add capacity. Streams keep memory bounded while large data flows through. Durable queues separate accepting work from doing it, so work survives crashes and spikes. They're complementary, not alternatives.

Matching tool to problem
  1. One request needs 3 s of image processing in JavaScript → a worker pool. The event loop stays free for other requests. Cluster wouldn't help: the process that gets the request is still blocked for 3 s.
  2. The server is I/O-bound and one core is at 100% from many small requests → more processes: cluster on a VM, or more replicas on Kubernetes. Each process has its own event loop, and the load balancer spreads requests.
  3. Users upload 2 GB files → streams (pipeline(req, transform, upload)), so memory stays at a few chunks instead of the file size. Neither workers nor cluster fix "the whole file is in memory".
  4. A video transcode takes 10 minutes and must not be lost if a pod restarts → return 202 Accepted, put a job on a durable queue, and process it in a separate worker service with retries.
  5. Real systems combine them: an API process with a worker pool, replicated behind a load balancer, streaming uploads into storage and enqueueing jobs.

Learn more: Strategies for CPU-intensive work and Cluster vs worker threads.

Predict the output: what is copied, transferred and shared when you message a worker?
transfer.cjs
const { Worker } = require('node:worker_threads');

const worker = new Worker(
  `const { parentPort } = require('node:worker_threads');
   parentPort.on('message', ({ obj, buf, shared }) => {
     obj.count++;
     new Int32Array(shared)[0] = 99;
     parentPort.postMessage({ seenCount: obj.count, bufBytes: buf.byteLength });
   });`,
  { eval: true },
);

const obj = { count: 1 };
const buf = new ArrayBuffer(1024);
const shared = new SharedArrayBuffer(4);

worker.postMessage({ obj, buf, shared }, [buf]);
console.log('after postMessage: buf.byteLength =', buf.byteLength);

worker.once('message', (msg) => {
  console.log('worker saw', msg);
  console.log('main obj.count =', obj.count, '| shared[0] =', new Int32Array(shared)[0]);
  worker.terminate();
});
Show answer
Output
after postMessage: buf.byteLength = 0
worker saw { seenCount: 2, bufBytes: 1024 }
main obj.count = 1 | shared[0] = 99

Ordinary values are cloned (the worker's change to obj isn't seen by the main thread). Transferred ArrayBuffers move: the worker gets the 1,024 bytes and the sender's buffer becomes empty. A SharedArrayBuffer is shared: both threads see the same memory, so the worker's write shows up in the main thread.

What’s happening
  1. postMessage serialises the message with the structured clone algorithm. obj is copied into the worker's own heap; the two copies are independent.
  2. buf is in the transfer list [buf], so instead of copying 1,024 bytes, ownership of the memory moves. The sender's buf is detached immediately: byteLength is 0 straight after postMessage.
  3. shared is a SharedArrayBuffer: what's passed is a handle to the same memory. No copy, no transfer.
  4. The worker increments its copy of obj (2), writes 99 into the shared memory, and reports the transferred buffer's full size (1024).
  5. Back in the main thread, obj.count is still 1, and shared[0] reads 99. Shared memory needs coordination (Atomics) as soon as both sides write, because now you have real data races.

Learn more: Sharing data with workers: clone, transfer, share and Worker threads.

spawn, exec, execFile or fork?
  1. Stream the output of ffmpeg while it transcodes a long video.
  2. Run a small, fixed shell command with a pipe in it, like git log --oneline | head -5.
  3. Run convert with a user-supplied file name and collect its output.
  4. Start another Node script and exchange messages with it.
Show answer

1 → spawn, 2 → exec, 3 → execFile, 4 → fork. Whatever you use, pass untrusted input only as arguments, never into a shell string, and handle error, exit codes and timeouts.

What’s happening
  1. spawn('ffmpeg', args) starts the program without a shell and gives you stdout/stderr as streams, so you can process output as it arrives and there's no buffer limit.
  2. exec('git log --oneline | head -5') runs the string through /bin/sh, so pipes and globbing work, and buffers all output (1 MB by default) into the callback. Fine for a fixed command you wrote; dangerous with user input.
  3. execFile('convert', [input, output]) runs the executable directly, with arguments as an array and no shell, so photo.jpg; rm -rf / is just a strange file name. It buffers output like exec.
  4. fork('worker.js') is spawn for Node scripts plus an IPC channel: child.send(msg) and process.on('message') in the child. Each child is a full Node process with its own memory and event loop.
  5. If the job is CPU-heavy JavaScript and you don't need process isolation, a worker thread is cheaper than fork.

Learn more: Child processes: spawn, exec, execFile and fork and Command injection and child processes.

EventEmitter is an observer. Why isn't it a durable distributed job system?
JavaScript
orderEvents.on('orderCreated', (order) => warehouse.reserve(order));
await db.orders.insert(order);
orderEvents.emit('orderCreated', order);
Show answer

Observer is the general pattern — subjects notify subscribers — and EventEmitter is Node's in-process implementation of it. It has no persistence, no retries, no acknowledgements and no delivery across processes: if the process crashes after the insert but before the listener finishes, the warehouse never hears about the order.

What’s happening
  1. emit calls the listener synchronously in this process. Other pods, cluster workers and worker threads have their own emitters and never see the event.
  2. Nothing is stored. A crash or deploy between insert and the listener finishing loses the reservation, and nothing will retry it.
  3. There's no acknowledgement: if warehouse.reserve rejects, the error is the listener's problem, and the emitter has no concept of "redeliver later".
  4. Durable work needs a broker or queue (RabbitMQ, SQS, Kafka) that persists the message and redelivers until a consumer acknowledges. To make "order saved" and "message recorded" atomic, write the event to an outbox table in the same transaction and relay it.
  5. Delivery is then at-least-once, so consumers must be idempotent. Keep EventEmitter for local notifications: stream events, metrics, in-process cache invalidation.

Learn more: Local events vs distributed messaging and Alternatives: callbacks, promises, observers and pub/sub.

Why can 100% coverage coexist with broken behaviour? How does changed-code coverage help?
JavaScript
test('applies the discount', () => {
  applyDiscount({ total: 100, code: 'SAVE10' }); // no assertion
});
Show answer

Coverage measures what ran, not what was checked. The test above executes every line of applyDiscount and asserts nothing, so it scores 100% whether the function returns 90 or 9,000. Changed-code coverage looks at the lines changed in this PR and shows which of them no test executed, which points at the new blind spots instead of hiding them in a repo-wide average.

What’s happening
  1. Istanbul (or V8 coverage) instruments the code and counts which statements, branches, functions and lines executed during the tests. It knows nothing about expect.
  2. A test with no assertions, or with weak ones (expect(result).toBeDefined()), gets full credit for every line it passes through. A bug on a covered line is still a bug.
  3. Branch coverage is the more honest number: if (code === 'SAVE10') needs both the true and the false case to count as fully covered.
  4. A repo at 85% can merge a PR whose new payment branch has 0% coverage, because the average barely moves. Changed-code coverage — a PR-level coverage check, or tools like SeaLights that map tests to code — shows exactly those untested new lines.
  5. Use coverage as a map of what's untested, gate CI with thresholds to prevent regressions, and judge test quality by assertions and by whether a deliberately broken implementation makes a test fail.

Learn more: Code coverage with Istanbul and Test impact analysis with SeaLights.

Predict the result: a Jest test that forgets to await
user.test.js
// A bug: fetchUser resolves for unknown ids instead of rejecting.
const fetchUser = (id) => new Promise((resolve) => setTimeout(resolve, 20, { id, name: 'ghost' }));

test('rejects for an unknown id (missing await)', () => {
  expect(fetchUser(99)).rejects.toThrow('not found');
});
Show answer

Jest reports the test as passed (Tests: 1 passed), and then the process crashes with JestAssertionError: … Received promise resolved instead of rejected and exit code 1. Jest only waits for a promise the test returns; this test returns undefined, so it was over before the assertion ran.

What’s happening
  1. The test function calls fetchUser(99), which starts a 20 ms timer, and builds an expect(...).rejects assertion, which is itself a promise. Nothing awaits or returns it.
  2. The function returns undefined immediately. To Jest, a test that returned without throwing has passed, so it records the green tick.
  3. 20 ms later fetchUser resolves, .rejects fails, and its promise rejects with nobody listening: an unhandled rejection. On its own it kills the run after the summary; among other tests it can be blamed on whichever test is running at that moment.
  4. Fix: await expect(fetchUser(99)).rejects.toThrow('not found') in an async test, or return the expectation. With the await, the same test fails properly with the same message.
  5. For assertions inside catch blocks or callbacks, add expect.assertions(n), and turn on the jest/valid-expect and no-floating-promises lint rules.

Learn more: Testing async code and Floating promises and unhandled rejections.

Your pod receives SIGTERM during a deploy. What should the Node process do?
Show answer

Stop taking new work, finish what's in flight, then exit — within a deadline. Mark the instance not ready, give the load balancer a moment to stop routing to it, call server.close(), let in-flight requests complete, close the database pool and flush logs, and exit 0. A timer shorter than the grace period force-exits if something hangs.

The SIGTERM sequence
  1. Kubernetes removes the pod from the Service's endpoints and sends SIGTERM at about the same time. Without a handler, Node exits immediately and drops in-flight requests. As PID 1 in a container without an init, it may ignore SIGTERM entirely until SIGKILL.
  2. process.on('SIGTERM', …): flip a flag so the readiness probe returns 503, and wait a few seconds so every load balancer has stopped sending new requests.
  3. server.close() stops accepting connections and closes idle keep-alive sockets; responses to in-flight requests can send Connection: close so their sockets don't linger. Its callback fires when the last connection ends.
  4. Then close resources in reverse order of creation: the DB pool, Redis, the message consumer (stop consuming first, finish or nack in-flight messages), and flush logs and traces. process.exit(0).
  5. A setTimeout(…).unref() deadline, shorter than terminationGracePeriodSeconds (30 s by default), calls server.closeAllConnections() and exits 1 if draining takes too long, so a stuck request can't block the deploy.

Learn more: Graceful shutdown and Readiness and liveness probes.

npm install or npm ci in CI and Docker? What does each one do with the lockfile?
Show answer

npm ci. npm install resolves: it treats the lockfile as a guide, updates node_modules in place, and rewrites the lockfile if it disagrees with package.json. npm ci reproduces: it deletes node_modules, installs exactly what the lockfile says, and fails if the lockfile is missing or out of sync.

What’s happening
  1. A developer adds a dependency to package.json but forgets to commit the updated lockfile. npm install in CI would quietly pick a version and build something nobody tested. npm ci fails with "can only install packages when your package.json and package-lock.json … are in sync" and names the missing package.
  2. npm ci starts from an empty node_modules, so leftovers from previous builds can't leak into the image.
  3. In Docker, npm ci --omit=dev in the dependency stage installs only runtime packages. NODE_ENV=production has the same effect on npm, which is a trap if you set it before the build stage needs its devDependencies.
  4. Copy package.json and package-lock.json before the source code, so the install layer is cached until dependencies change.
  5. Rule: developers run npm install and commit lockfile changes; CI and Dockerfiles run npm ci. Review lockfile diffs, and remember install scripts run code from every transitive dependency.

Learn more: package.json for production services and A production Dockerfile.

How do you deploy a Node service with zero downtime, including a database change?
Show answer

Overlap old and new versions behind a load balancer: new instances get traffic only once their readiness probe passes, and old ones drain gracefully on SIGTERM. Because both versions run at once, every database migration must work with both — expand and contract.

A rolling update with a column rename
  1. Run at least two replicas. Kubernetes rolling update with maxUnavailable: 0 and maxSurge: 1: start one new pod, wait for readiness, then terminate one old pod, which drains as in the shutdown question. Repeat.
  2. Expand: a migration adds the new column display_name, leaving name in place. Old code still works.
  3. Release 1 writes both columns and reads display_name with a fallback to name; a batched job backfills old rows. Old and new pods can run side by side.
  4. Release 2 reads and writes only display_name. Contract: once no old version runs anywhere, a final migration drops name. Every intermediate state works for the version being replaced and the one replacing it.
  5. Alternatives: blue-green (a full second environment, switch traffic at once, instant rollback) and canary (send 5% of traffic to the new version, promote or roll back on metrics). Sessions and caches live outside the process, so it doesn't matter which instance serves a user.

Learn more: Zero-downtime deployment and Database migrations without downtime.

Predict the output: the broker redelivers a ChargeCustomer message
idempotent.cjs
const processed = new Set(); // in production: a DB table with a unique key, written in the same transaction
let charges = 0;

function handleChargeCustomer(message) {
  if (processed.has(message.id)) {
    console.log(`skip ${message.id} (duplicate)`);
    return;
  }
  charges++;
  processed.add(message.id);
  console.log(`charged for ${message.id}, total charges = ${charges}`);
}

const msg = { id: 'order-42-charge', amount: 500 };
handleChargeCustomer(msg);
handleChargeCustomer(msg); // the broker redelivers after a lost ack
Show answer

charged for order-42-charge, total charges = 1, then skip order-42-charge (duplicate). Real brokers deliver at least once, so the same message can arrive twice. An idempotent consumer records which message IDs it has processed and turns the second delivery into a no-op, so the customer is charged once.

What’s happening
  1. The first delivery isn't in processed, so the handler charges (count 1) and records the ID.
  2. Suppose the consumer crashed, or the ack was lost, after charging. The broker can't know the work was done, so it delivers the same message again. Exactly-once delivery doesn't exist over an unreliable network; you get at-least-once and make processing idempotent.
  3. The second delivery finds the ID and skips: still 1 charge.
  4. In production the Set is a table with a unique constraint on the message ID, inserted in the same transaction as the side effect. Otherwise a crash between "charge" and "record" brings the double charge back, and two concurrent deliveries could both pass the check.
  5. For external side effects (a payment provider), pass an idempotency key derived from the message ID, so the provider de-duplicates too.

Learn more: Delivery guarantees and idempotency.

A downstream service starts timing out. How does a circuit breaker change what happens?

The breaker opens after 5 consecutive failures and waits 30 seconds before probing.

Show answer

Without a breaker, every request waits for the full timeout, retries, and piles up until your own service runs out of sockets and memory — a cascading failure. A circuit breaker fails fast once the dependency is clearly down, serves a fallback, and probes periodically to see when it's back.

The breaker's states
  1. CLOSED: calls go through, each with a timeout. Failures 1 to 4 are counted and passed to the caller as errors.
  2. Failure 5 trips the breaker to OPEN. For the next 30 seconds, calls fail immediately without touching the dependency, and the caller uses a fallback — cached data, a default, or a clear 503. Your resources are freed and the sick service gets room to recover.
  3. After 30 s the breaker goes HALF-OPEN and lets one probe call through.
  4. If the probe succeeds, the breaker closes and traffic resumes. If it fails, the breaker opens again for another 30 seconds.
  5. Breakers work with the other resilience tools: timeouts on every call, a few retries with exponential backoff and jitter for transient errors only, bulkheads (concurrency caps per dependency), and metrics on breaker state so you can see it trip.

Learn more: Circuit breakers and fallbacks and Retries with exponential backoff and jitter.

Kafka or RabbitMQ: which would you pick, and why?
  1. Send a welcome email for each signup, with retries and a dead-letter queue.
  2. Publish OrderPlaced events that billing, analytics and search each consume, and be able to replay last week's events into a new service.
Show answer

1 → RabbitMQ (or SQS), 2 → Kafka. A queue is for jobs each done once by some worker: messages are acknowledged and deleted. Kafka is a retained, partitioned log of events: each consumer group keeps its own position and can re-read history.

What’s happening
  1. Emails: each job should be processed by exactly one of several competing workers. RabbitMQ delivers a message to one consumer, waits for an ack, redelivers on failure, and routes poison messages to a dead-letter queue. Per-message retries, delays and priorities are natural.
  2. Order events: in Kafka, the producer appends to a topic. Billing, analytics and search are separate consumer groups; each reads every event at its own pace and stores its own offset. Adding a fourth consumer doesn't affect the others.
  3. Replay: Kafka keeps events for a retention period (or forever with compaction), so a new service can start from an old offset and rebuild its state. In a queue, a consumed message is gone.
  4. Ordering: Kafka guarantees order within a partition, so key events by orderId to keep one order's events in sequence. Throughput scales with partitions.
  5. In both, delivery is effectively at-least-once, so consumers are idempotent, and you plan for poison messages.

Learn more: Kafka vs RabbitMQ and Synchronous vs asynchronous communication.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.