← Back to Design & Development
Interview Prep · JavaScript & Node.js

JavaScript & Node.js
Interview — Concept Reference

A detailed, concept-by-concept walkthrough of the questions actually asked in JS and Node.js rounds — from hoisting to the event loop, from closures to streams. Each topic is explained directly, with the meaning, mechanics, and gotchas spelled out.

Foundation · Mental Model

1 · How JavaScript Actually Runs

Before any specific question makes sense, the interviewer wants to know if you have the right mental picture of what is even happening when JS runs your code. Get this picture right and half the trick questions answer themselves.

Concept

JavaScript is a single-threaded language: there is exactly one call stack, one heap, and one thread executing your code at any moment. It cannot run two functions in parallel. What gives JS its illusion of concurrency is the runtime around the engine — browsers expose Web APIs (timers, network, DOM events) and Node exposes libuv (file system, sockets, DNS). Those APIs do slow work outside the JS thread and post their results back as callbacks.

The pieces are:

  • Call Stack — the active execution context. Every function call pushes a frame; every return pops one. When the stack is empty, JS is idle.
  • Heap — where objects live. Garbage-collected by the engine.
  • Web/Node APIs — non-JS code that handles timers, I/O, network. These run outside the JS thread.
  • Task (Macrotask) Queue — holds callbacks from setTimeout, I/O, UI events, setImmediate.
  • Microtask Queue — holds Promise .then/.catch/.finally callbacks, queueMicrotask, MutationObserver.
  • Event Loop — the scheduler. When the stack is empty, it drains all microtasks, then picks one macrotask, runs it, drains microtasks again, and repeats.

"Non-blocking I/O" means JS never waits for slow operations on its own thread — it hands the work to the runtime and continues. The result is delivered later via a callback in one of the queues.

flowchart LR subgraph JS["JS Engine (one thread)"] CS[Call Stack
currently running code] end subgraph RT["Runtime APIs (browser / Node)"] T[setTimeout
Network · FS · Timers] end MQ[Macrotask Queue
setTimeout, I/O, UI events] MIC[Microtask Queue
Promise.then, queueMicrotask] CS -->|"async call"| T T -->|"done"| MQ T -->|"resolved promise"| MIC MIC -->|"drained first"| CS MQ -->|"one task per loop tick"| CS style CS fill:#e8743b,stroke:#e8743b,color:#fff style MIC fill:#9b72cf,stroke:#9b72cf,color:#fff style MQ fill:#4a90d9,stroke:#4a90d9,color:#fff style T fill:#38b265,stroke:#38b265,color:#fff
So what? JavaScript is single-threaded, but the runtime around it is not. The event loop is the rule that says "drain the microtask queue completely, then take one macrotask, then drain microtasks again, then repeat." Almost every async question is testing whether you know that order.
Variables · Hoisting

2 · var, let, const & Hoisting

Hoisting is the feature that surprises every beginner: variables seem to "exist" before they were declared. The interview question is rarely "what is hoisting?" — it is a code snippet where the answer depends on whether you used var, let, or const.

Concept

Hoisting is the JavaScript engine's two-phase processing of a scope. Before any code runs, the engine scans the scope and registers every variable and function declaration in memory. Only after that does it execute statements top to bottom.

The three declaration keywords behave differently during this scan:

  • var — registered AND initialized with undefined. Reading it before the assignment line returns undefined (no error).
  • let / const — registered but NOT initialized. The binding exists but is in the Temporal Dead Zone (TDZ). Any read or write before the declaration line throws a ReferenceError.
  • function declarations — fully hoisted (both name and body), so you can call them above their declaration.

Scope rules: var is function-scoped (it leaks out of if / for blocks). let and const are block-scoped (limited to the nearest { }). const additionally forbids reassignment of the binding — but the value it points to (if an object) remains mutable.

Why TDZ exists: it forces developers to declare variables before use, eliminating an entire class of bugs that var's "silent undefined" used to mask.

classic-trick.js
// var is hoisted with value undefined
console.log(a); // undefined  (no error!)
var a = 10;

// let / const are hoisted but in TDZ
console.log(b); // ❌ ReferenceError: Cannot access 'b' before initialization
let b = 20;

var

Function-scoped. Hoisted & initialised to undefined. Re-declaration allowed. Avoid in modern code.

let

Block-scoped. Hoisted but in TDZ until the line runs. Re-declaration in the same scope throws. Re-assignment allowed.

const

Block-scoped. TDZ. Cannot be re-assigned. The binding is constant — but if it points to an object, the object's contents can still change.

What is the Temporal Dead Zone?
The window between when a let/const variable is hoisted (it exists in the scope) and when its declaration line is actually executed. Touching it in that window throws a ReferenceError. The TDZ exists so that modern variables behave predictably — you can't accidentally read or write them before they are properly defined.
Is const truly immutable?
No. Only the binding is immutable. const arr = [1,2]; arr.push(3); works fine — the array's identity (the reference) didn't change. To freeze contents use Object.freeze(arr) (shallow) or a deep-freeze utility for nested objects.
Scope · Memory

3 · Scope & Closures

Closures are the single most-asked JS concept. They are also the foundation of currying, memoization, module patterns, and most of the React Hook tricks you'll see later.

Concept

A closure is a function bundled together with references to the variables of the lexical scope in which it was defined. When a function is created, it captures the surrounding environment — and that environment persists for as long as the function itself does.

The key behavior: even after the outer function returns and its local variables would normally be discarded, those variables remain alive in memory because the inner function still references them. The closure does NOT capture values by copy — it captures live references, so changes are reflected if the variable is mutated.

Two ingredients make a closure:

  • Lexical scoping — a function's accessible variables are determined by where it is written in the source code, not by where it is called from.
  • First-class functions — functions can be returned, stored in variables, and passed around. When they travel, their scope chain travels with them.

Closures are how JavaScript implements private state, partial application, and stateful callbacks without classes.

closure-counter.js
function makeCounter() {
  let count = 0;            // lives in the backpack
  return function() {
    count++;
    return count;
  };
}

const c = makeCounter();
c(); // 1
c(); // 2
c(); // 3 — count survives between calls
What is a closure, in one sentence?
A function bundled together with the variables of the lexical scope in which it was defined — so it can still read (and write) them even after the outer scope has finished executing.
Classic loop trick: what does this print?
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// prints 3, 3, 3 — all three callbacks share the SAME `i` (var is function-scoped)

Replace var with let and you get 0, 1, 2 — because let creates a fresh binding for each loop iteration, so each callback gets its own backpack with its own i.

Real-world uses of closures?
  • Data privacy: emulate private fields by keeping state in the closure and only exposing methods.
  • Once / memoize: cache results in a closure variable so repeated calls are free.
  • Currying / partial application: capture early arguments, return a function waiting for the rest.
  • Event handlers: remember which item was clicked without storing it on the DOM.
Memory caveat. Closures hold references to their backpack — that variable cannot be garbage-collected as long as the closure is reachable. Forgetting old closures (especially attached to DOM nodes) is the most common cause of memory leaks in long-running JS apps.
Binding

4 · The this Keyword

"What is this?" is JavaScript's most-asked, most-mistaken question. The trick is to remember that this isn't decided when the function is written — it's decided when the function is called.

Concept

this is a special identifier whose value is determined at call time, not at function definition. It is part of the function's execution context — every time you invoke a function, JS sets up a fresh this based on the call site syntax.

There are four binding rules, checked in order of precedence (highest to lowest):

  1. new bindingnew Fn() creates a fresh empty object and sets this to it.
  2. Explicit bindingfn.call(obj), fn.apply(obj), or fn.bind(obj) directly specify this.
  3. Implicit / method bindingobj.fn() sets this to the object before the dot.
  4. Default binding — a plain fn() call sets this to globalThis in non-strict mode, or undefined in strict mode / ES modules.

Arrow functions are the exception: they do not have their own this. They inherit it from the enclosing lexical scope at the time of definition, and that binding cannot be changed by call/apply/bind. This makes arrow functions safe to use as callbacks without losing context.

Common pitfall: assigning a method to a variable (const g = obj.method) strips the implicit binding. When you call g() later, it falls back to the default binding and this becomes global / undefined.

Call styleExamplethis is…
1. Method callobj.fn()obj
2. Plain callfn()undefined in strict mode, else globalThis
3. new callnew Fn()the freshly created object
4. Explicit bindfn.call(x) · fn.apply(x) · fn.bind(x)x

Arrow functions break the rule (on purpose)

Arrow functions don't have their own this. They borrow it from the surrounding scope at the moment they were defined. That is exactly why we love them inside callbacks — no more const self = this; dance.

this-trap.js
const user = {
  name: 'Sarah',
  greet() { console.log('Hi ' + this.name); }
};

user.greet();             // "Hi Sarah" — method call
const g = user.greet;
g();                      // "Hi undefined" — plain call, this is global
g.call(user);             // "Hi Sarah" — explicit bind
Difference between call, apply, and bind?
  • call(thisArg, a, b, c) — invokes the function immediately, args passed individually.
  • apply(thisArg, [a,b,c]) — same, but args as an array.
  • bind(thisArg, ...) — does NOT invoke; returns a new function with this permanently bound. Useful for event handlers that you'll attach later.
Why does setTimeout(this.fn, 1000) sometimes lose this?
Because passing this.fn strips the method off the object — it becomes a plain function reference. When the timer fires, JS calls it as a plain call (rule 2), so this becomes global / undefined. Fix it by binding (this.fn.bind(this)) or wrapping in an arrow (() => this.fn()).
OOP

5 · Prototypes & Inheritance

JavaScript doesn't have classes the way Java does — even the class keyword is sugar over prototypes. Once you understand the prototype chain, every "why does arr.map exist?" question answers itself.

Concept

JavaScript uses prototypal inheritance, not classical class-based inheritance. Every object has an internal slot called [[Prototype]] (accessible via Object.getPrototypeOf(obj) or the legacy __proto__) that points to another object — its prototype.

Property lookup algorithm: when you access obj.foo, the engine:

  1. Checks if foo is an own property of obj. If yes, returns it.
  2. Otherwise follows [[Prototype]] to the parent object and repeats step 1.
  3. Continues walking the chain until it finds foo or reaches null (the end of every chain). If null is reached, returns undefined.

Two related but distinct concepts:

  • prototype — a property that exists only on constructor functions. It is the object that will become the [[Prototype]] of instances created with new.
  • __proto__ / [[Prototype]] — exists on every object. It is the actual link used for lookups.

The relationship: new Foo().__proto__ === Foo.prototype.

The class keyword is syntactic sugar. Under the hood it still creates a constructor function and attaches methods to its prototype. extends sets up the prototype chain between two constructors. There is no real "class" in JavaScript — only objects linking to other objects.

proto-chain.js
const arr = [1, 2, 3];

// arr ─→ Array.prototype ─→ Object.prototype ─→ null
arr.map(...);     // found on Array.prototype
arr.hasOwnProperty(0); // found on Object.prototype

class is just sugar

class-vs-proto.js
class Animal {
  constructor(name) { this.name = name; }
  speak() { console.log(this.name + ' makes a noise'); }
}

// is roughly equivalent to:
function Animal(name) { this.name = name; }
Animal.prototype.speak = function() {
  console.log(this.name + ' makes a noise');
};
Difference between __proto__ and prototype?

prototype lives on functions (specifically constructor functions). It's the object that becomes the prototype of instances created with new.

__proto__ (or Object.getPrototypeOf(obj)) lives on every object. It's the actual link the prototype chain walks. So new Animal().__proto__ === Animal.prototype.

How would you implement classical inheritance without class?
function Dog(name) {
  Animal.call(this, name);          // 1. inherit fields
}
Dog.prototype = Object.create(Animal.prototype); // 2. inherit methods
Dog.prototype.constructor = Dog;             // 3. fix constructor pointer
Async · Concurrency

6 · The Event Loop & Microtasks

Every async question is the event loop wearing a different hat. If you only memorise one diagram for your interview, memorise this one.

Concept

The event loop is the runtime mechanism that decides which queued callback to run next, now that the JS call stack is empty. It enforces a strict ordering rule based on two distinct queues:

  • Microtask queue — high priority. Holds Promise .then / .catch / .finally callbacks, queueMicrotask(), and MutationObserver callbacks.
  • Macrotask (task) queue — lower priority. Holds setTimeout, setInterval, I/O completion callbacks, UI events (click, scroll), and setImmediate in Node.

The algorithm per loop iteration:

  1. Run the currently executing synchronous code to completion (the call stack must be empty).
  2. Drain the entire microtask queue. If draining microtasks schedules more microtasks, those run too — before any macrotask.
  3. Pick exactly one macrotask from the queue and run it.
  4. Go back to step 2.

Consequence: a Promise that resolves "immediately" still runs after all synchronous code in the current execution, but before any setTimeout(..., 0) that was scheduled earlier. This single ordering rule explains the output of nearly every async puzzle.

Starvation risk: because microtasks fully drain before macrotasks, a chain of microtasks that schedules more microtasks forever will prevent the loop from ever processing I/O, timers, or rendering. The page or server freezes.

order-puzzle.js
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');

// Output: A, D, C, B
// 1. A and D run synchronously (call stack)
// 2. Stack empties → drain microtasks → C
// 3. Then take one macrotask → B
What lands in the microtask queue?
Promise .then / .catch / .finally callbacks, queueMicrotask(), and MutationObserver callbacks. They run before the next macrotask, after the current one finishes.
What lands in the macrotask queue?
setTimeout, setInterval, I/O callbacks, UI events (click, scroll), setImmediate in Node.
Can a microtask flood starve macrotasks?
Yes. If a microtask schedules another microtask, which schedules another… the loop never reaches the macrotask phase and the page can freeze. This is why infinite-promise-chains are dangerous.
Async · Promises

7 · Promises & async / await

Promises gave us a cleaner way out of "callback hell". async/await then gave us a cleaner way out of .then chains. Underneath, it's all still the event loop.

Concept

A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation. It is a placeholder for a value that does not exist yet but will exist in the future.

Three states:

  • pending — initial state. Operation is still in flight.
  • fulfilled — operation succeeded. Promise now has a value.
  • rejected — operation failed. Promise now has a reason (typically an Error).

A promise transitions from pending to either fulfilled or rejected exactly once. After that, it is settled and immutable — its state and value cannot change again.

Consumers attach handlers with .then(onFulfilled, onRejected), .catch(onRejected), and .finally(onSettled). These handlers always run asynchronously, as microtasks. Each .then returns a new Promise, enabling chaining where the next handler receives the previous handler's return value.

async / await is syntactic sugar over Promises. An async function always returns a Promise. await expr pauses execution of that function until expr settles, then resumes with the resolved value (or throws on rejection). The engine rewrites the function into a state machine of .then calls internally. Errors can be caught with regular try / catch.

The four Promise combinators

Promise.all

Waits for ALL to fulfil. If any one rejects → the whole thing rejects immediately. Use when every result is required (e.g. fetch user + their posts + their settings).

Promise.allSettled

Waits for ALL to finish, regardless of success or failure. Returns an array of {status, value/reason}. Use when you want a partial result (e.g. analytics dashboard with optional widgets).

Promise.race

Settles with whichever promise settles first — fulfilled OR rejected. Useful for timeouts: race a fetch against a 5-second timer.

Promise.any

Resolves with the first FULFILLED one. Only rejects if ALL reject (with an AggregateError). Use for fallback mirrors — "give me whichever CDN responds first."

async / await vs .then chains — what's the real difference?

Behaviour-wise: nothing. Under the hood, await x is exactly x.then(result => ...rest of the function). The compiler rewrites your linear-looking function into a state machine of .then calls.

Readability-wise: huge. async/await keeps the code linear, lets you use try/catch for errors, and works naturally with loops (for...of + await).

Common mistake: awaiting in a forEach loop
// ❌ does NOT wait — forEach ignores the returned promise
users.forEach(async u => { await save(u); });

// ✅ runs sequentially
for (const u of users) await save(u);

// ✅ runs in parallel
await Promise.all(users.map(save));
What happens if you don't .catch a rejected promise?
In Node.js it triggers an unhandledRejection event. Since Node 15 the default is to crash the process (good — surfaces bugs early). In browsers it shows up in DevTools console. Always either .catch or wrap your await in try/catch.
Quirks

8 · Equality, Coercion & Truthiness

JavaScript's loose equality is the source of the language's worst-known memes. Interviewers love it because it forces you to demonstrate that you actually understand type coercion.

Concept

JavaScript provides two equality operators:

  • === (strict equality) — returns true only if both operands have the same type AND the same value. No conversion happens.
  • == (loose equality) — applies the Abstract Equality Comparison algorithm from the ECMAScript spec, which performs type coercion before comparing. Strings are converted to numbers, booleans are converted to numbers, objects are converted to primitives, and so on.

Type coercion is the implicit conversion of a value from one type to another. JS coerces in several places:

  • Comparison operators (==, <, >)
  • Arithmetic operators (+, -, etc.) — note + with any string becomes string concatenation
  • Boolean contexts (if, ternary, &&, ||, !)
  • Template literals — values are converted via String()

Truthy and falsy describe how values behave in boolean contexts. A value is falsy if it coerces to false. The complete list of falsy values is fixed and short: false, 0, -0, 0n (BigInt zero), "" (empty string), null, undefined, NaN. Everything else is truthy — including empty arrays [] and empty objects {}, which trips up developers coming from Python or Ruby.

Rule of thumb: always use === in production code. The only legitimate use of == is the idiom x == null, which is true for both null and undefined — a deliberate spec exception.

coercion-traps.js
[] == ![]              // true   — !! both sides become 0
0 == ''                // true   — '' becomes 0
0 == '0'               // true   — '0' becomes 0
'' == '0'              // false  — string comparison, no coercion
null == undefined      // true   — by spec
null == 0             // false  — null only equals undefined
NaN == NaN              // false  — NaN is never equal to anything

Truthy / Falsy

Only seven values are falsy: false, 0, -0, 0n, "", null, undefined, NaN. Everything else (including [] and {}!) is truthy.

Difference between ?? and ||?

|| falls back on any falsy value. ?? falls back ONLY on null or undefined.

const port = config.port || 3000; // 0 → 3000  ❌ (user wanted 0)
const port = config.port ?? 3000; // 0 → 0     ✅
How do you correctly check for NaN?
Use Number.isNaN(x) (strict, only true for the actual NaN value). Avoid the older global isNaN() which coerces — isNaN('hello') returns true because it tries to convert the string first.
References · Mutation

9 · Shallow vs Deep Copy

The bug story behind this question is identical at every company: "I changed one item in userB and somehow userA changed too."

Concept

In JavaScript, primitive values (numbers, strings, booleans, null, undefined, symbol, bigint) are passed and copied by value. Objects (including arrays and functions) are passed and copied by reference — what gets copied is the pointer to the same memory, not the contents.

Three distinct copy levels:

  • Reference copyconst b = a. Both variables point to the same object. Any mutation through one is visible through the other.
  • Shallow copy — duplicates only the top-level properties. Nested objects are still shared by reference. Made with Object.assign({}, obj), the spread operator {...obj}, or Array.from(arr).
  • Deep copy — recursively duplicates every level of the structure. The result shares no memory with the original. Made with structuredClone(obj) (the modern standard, built into Node ≥17 and all current browsers).

Why structuredClone over JSON.parse(JSON.stringify(obj)):

  • The JSON round-trip silently drops undefined, functions, and symbols.
  • It converts Date objects to strings and Map / Set to empty {}.
  • It throws on circular references.
  • structuredClone handles all of the above correctly — Dates, Maps, Sets, RegExp, typed arrays, and circular references.

Immutability is separate from copying. A shallow copy followed by Object.freeze() prevents adding/removing top-level keys but does NOT freeze nested objects. For deep immutability, freeze recursively or use a library like Immer.

copy-tradeoffs.js
const a = { name: 'Raj', addr: { city: 'Pune' } };

// SHALLOW — only top-level keys are duplicated
const b = { ...a };
b.addr.city = 'Delhi';
console.log(a.addr.city); // 'Delhi' — leaked!

// DEEP — modern, built-in, handles cycles, Maps, Sets, Dates
const c = structuredClone(a);
c.addr.city = 'Delhi';
console.log(a.addr.city); // 'Pune' — safe
Why is JSON.parse(JSON.stringify(obj)) a bad deep clone?
It quietly drops undefined, functions, symbols. It turns Date into a string, Map/Set into {}. It throws on circular references. Use structuredClone() instead (built into modern Node and browsers).
Performance

10 · Debounce vs Throttle

Both limit how often a function runs. They're cousins but solve different problems. Interviewers love asking you to implement both from scratch.

Concept

Both are rate-limiting wrappers for functions called too frequently. They differ in when they allow the underlying function to actually execute.

Debounce — collapses a burst of calls into a single delayed call. Every new invocation cancels the pending timer and starts a fresh one. The wrapped function only executes once the caller has gone quiet for the configured delay. Use when you only care about the final state after activity stops: search-as-you-type (wait until typing pauses, then query), window-resize handlers (compute layout after resize finishes), autosave-on-edit.

Throttle — enforces a maximum execution rate. Allows the wrapped function to run at most once per interval, regardless of how many times it is invoked in between. The first call typically goes through immediately; subsequent calls within the interval are dropped (or queued for the next slot). Use when you need continuous sampled updates: scroll position trackers, mousemove handlers, progress reports, animation frame coordination.

Both rely on closures — the wrapper captures a timer reference or a "last called" timestamp in its closure, so calls share state across invocations.

Variants worth knowing: debounce can be configured with a leading edge (fire immediately, then ignore) or trailing edge (default — fire after quiet period). Throttle implementations differ in whether they drop late calls or queue them — production libraries like Lodash let you pick.

debounce-throttle.js
function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

function throttle(fn, limit) {
  let waiting = false;
  return function(...args) {
    if (waiting) return;
    fn.apply(this, args);
    waiting = true;
    setTimeout(() => waiting = false, limit);
  };
}
When to use which?
  • Debounce → search-as-you-type, window resize end, autosave-on-stop-typing.
  • Throttle → scroll listener, mouse-move tracker, button-spam protection, drag handler.
Functional

11 · Currying & Memoization

Currying — one argument at a time

Currying is the transformation of a function that takes N arguments into a chain of N functions, each taking exactly one argument and returning the next function in the chain. Only when the final argument is supplied does the original function actually execute and return a value.

Formally: f(a, b, c) becomes f(a)(b)(c). A practical "smart curry" implementation often relaxes this rule — it accepts any number of arguments at a time but keeps returning a partially-applied function until enough arguments have been collected.

Why it matters:

  • Partial application — fix some arguments early and reuse the resulting function (e.g., const addTen = add(10)).
  • Function composition — curried functions are easier to plug into pipelines and compose/pipe chains.
  • Configuration — separate "configuration" arguments from "data" arguments, useful in functional libraries (Ramda, Lodash/fp).

Currying is implemented purely with closures — each returned function captures the previously supplied arguments.

curry.js
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) return fn.apply(this, args);
    return (...next) => curried(...args, ...next);
  };
}

const add = curry((a, b, c) => a + b + c);
add(1)(2)(3);     // 6
add(1, 2)(3);     // 6

Memoization — remember past answers

Memoization is an optimization technique where a function caches its results keyed by its arguments. The first call with a given set of arguments performs the actual computation and stores the result; subsequent calls with the same arguments return the cached result without recomputing.

It is a specific case of caching, applied to pure functions. It trades memory for compute time — appropriate when the function is expensive (recursive Fibonacci, parsing, expensive DOM measurements) and called repeatedly with overlapping inputs.

Requirements for safe memoization:

  • The function must be pure — same inputs always produce the same output, no side effects, no dependency on external state (time, random, network).
  • Arguments must be hashable into a cache key. Primitives serialize cleanly; complex objects require JSON.stringify (which fails on cycles) or a structural hash.
  • The cache should be bounded — an unbounded cache becomes a memory leak. Use an LRU (Least Recently Used) cache or set a TTL.

Like currying, memoization is built on closures — the cache lives in the wrapper's closure scope, hidden from external code but persistent across calls.

memoize.js
function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn.apply(this, args));
    return cache.get(key);
  };
}
Where is memoization risky?
When the function is impure (depends on time, network, user) — same arguments may not always give the same answer. Also when args are large objects — JSON-keying gets expensive. And the cache grows forever unless you cap it (LRU).
Modern Syntax

12 · ES6+ Goodies — the One-Liners You'll Be Asked About

Spread & Rest

const arr2 = [...arr1, 4];      // spread
function sum(...nums) { ... } // rest

Same syntax, opposite meaning. Spread expands, rest collects.

Destructuring

const { name, age = 18 } = user;
const [first, ...rest] = arr;

Pull values out of objects/arrays in one line. Default values fill in for undefined.

Optional chaining

user?.address?.city
api?.fetch?.()

Returns undefined instead of throwing when a link in the chain is null/undefined.

Nullish coalescing

value ?? 'fallback'

Falls back ONLY on null/undefined, not on 0 or "".

Map & Set

Map keeps insertion order, accepts any key type, has .size. Set stores unique values — handy one-liner: [...new Set(arr)] dedupes an array.

Generators

function* range(n) {
  for (let i = 0; i < n; i++) yield i;
}

Functions that pause at yield and resume on .next(). Foundation of async iterators.

Node.js · Foundation

13 · Why Node.js Exists

Node.js is JavaScript's V8 engine ripped out of Chrome and stitched onto a C library called libuv. The marriage gave the language two things it never had: file-system access and a way to do async I/O at scale.

Concept

Node.js is a server-side JavaScript runtime. It takes the V8 engine (the same engine that runs JS inside Chrome) and embeds it into a C++ host program that provides everything a server needs but a browser does not — a file system API, sockets, child processes, native modules, and an async I/O system.

The defining architectural choice is the single-threaded, non-blocking I/O model:

  • Your JavaScript runs on exactly one thread (the main thread).
  • Any operation that would block — reading a file, waiting for a TCP packet, computing a hash — is delegated to either the OS kernel (for network I/O) or to libuv's thread pool (for file system, DNS, crypto, zlib).
  • While those operations run in the background, the main thread is free to handle other requests. When the background work finishes, a callback is queued on the event loop.

Why this matters: a traditional thread-per-request server allocates ~1MB of stack per connection. At 10,000 connections, that is 10GB of RAM plus context-switching overhead, and most threads sit idle waiting on I/O. Node serves 10,000 concurrent connections on a single thread because the thread never waits — it only services completed callbacks.

The trade-off: any CPU-bound work on the main thread blocks every other request. A 200ms JSON parse or password hash freezes the entire server. CPU-heavy tasks must be offloaded to worker threads or the cluster module to scale.

flowchart LR subgraph N["Node.js Process"] JS[① Your JS Code] V8[② V8 Engine
parses + runs JS] EL[③ Event Loop
libuv-managed] TP[④ Thread Pool
4 threads default
fs · crypto · dns] end OS[(⑤ OS Kernel
epoll · kqueue · IOCP)] JS --> V8 --> EL EL -->|"non-blocking I/O"| OS EL -->|"blocking ops"| TP TP -->|"work done"| EL OS -->|"I/O ready"| EL style JS fill:#e8743b,stroke:#e8743b,color:#fff style V8 fill:#4a90d9,stroke:#4a90d9,color:#fff style EL fill:#38b265,stroke:#38b265,color:#fff style TP fill:#9b72cf,stroke:#9b72cf,color:#fff style OS fill:#d4a838,stroke:#d4a838,color:#000

The five parts that make Node tick

Your JS Code

Your app.js, your routes, your business logic. This is the part you write — everything below is the runtime carrying you.

Why it exists: well, it's the app. Nothing happens without it.

V8 Engine

Google's open-source JavaScript engine — same one in Chrome. It parses your JS, JIT-compiles it to machine code, runs it.

Why it exists: JS is a language, not a runtime. You need an engine to actually execute it. V8 was chosen because it's fast and embeddable.

Event Loop

The traffic cop that decides which callback runs next. Implemented in libuv (C). It cycles through phases — timers, I/O callbacks, idle, poll, check, close — picking up work in order.

Why it exists: with one thread, you need a strict scheduling rule for callbacks, or chaos. The event loop IS that rule.

Thread Pool

libuv keeps a pool of background threads (4 by default, configurable via UV_THREADPOOL_SIZE) for operations the OS can't do non-blocking — file I/O, DNS lookups, crypto, zlib.

Why it exists: not every operation has a non-blocking OS API. Without the pool, fs.readFile would block the event loop and freeze the server.

OS Kernel

The actual non-blocking I/O facilities of the operating system: epoll on Linux, kqueue on macOS/BSD, IOCP on Windows. libuv hides the differences.

Why it exists: network I/O genuinely is async at the OS level — Node didn't invent it, libuv just exposes it cleanly.

So what? "Node is single-threaded" is half the truth. Your JavaScript runs on one thread. But behind the curtain, libuv keeps 4+ background threads working on file/crypto operations, and the OS handles network I/O entirely on its own. That's how a single Node process serves 10,000 concurrent connections without breaking a sweat.
Async

14 · The Node Event Loop — Phases

The browser event loop has two queues. The Node event loop has SIX phases. Interviewers ask about the order because most race-condition bugs in Node trace back to "I thought my callback ran before that other callback."

flowchart LR T[① Timers
setTimeout · setInterval] --> P[② Pending
callbacks deferred from prev tick] P --> I[③ Idle / Prepare
internal use] I --> PO[④ Poll
fetch new I/O · execute callbacks] PO --> CH[⑤ Check
setImmediate] CH --> CL[⑥ Close
socket.on close] CL --> T style T fill:#e8743b,stroke:#e8743b,color:#fff style P fill:#d4a838,stroke:#d4a838,color:#000 style I fill:#7b8599,stroke:#7b8599,color:#fff style PO fill:#4a90d9,stroke:#4a90d9,color:#fff style CH fill:#38b265,stroke:#38b265,color:#fff style CL fill:#e05252,stroke:#e05252,color:#fff

Concept

The Node event loop is not a single queue — it is a state machine that cycles through six distinct phases in a fixed order. Each phase has its own callback queue, and the loop processes one phase before moving to the next.

The six phases (in order):

  1. Timers — executes callbacks scheduled by setTimeout and setInterval whose threshold has elapsed. Note that "5ms" means "at least 5ms" — the callback fires as soon as the timer expires AND this phase is reached.
  2. Pending callbacks — runs I/O callbacks deferred from the previous loop tick (mostly TCP error callbacks). Rarely your code.
  3. Idle / Prepare — internal use by libuv, not exposed to user code.
  4. Poll — the largest phase. Two responsibilities: (a) compute how long to block waiting for new I/O, then (b) process I/O callbacks (incoming network data, completed file reads). If the poll queue is empty and a setImmediate is pending, it moves on to Check; otherwise it may block here waiting for I/O.
  5. Check — runs setImmediate callbacks. This is the only phase that executes them.
  6. Close callbacks — runs callbacks registered for close events (e.g., socket.on('close', ...)).

After the Close phase, the loop returns to Timers and starts again.

Microtasks run between every callback within and between phases, not just between full loop ticks. Microtasks come in two queues processed in order:

  • process.nextTick queue — highest priority, drained first.
  • Promise microtask queue — drained after nextTick.

Why six phases matter: if you schedule a setTimeout(fn, 0) from inside an I/O callback, it runs on the next loop iteration's Timers phase. A setImmediate from the same I/O callback runs in the same iteration's Check phase. That guarantee is why setImmediate always beats setTimeout(0) from inside I/O — and why race-condition bugs trace back to "wrong phase".

Timers

Runs setTimeout and setInterval callbacks whose threshold has elapsed. "5ms timeout" doesn't mean exactly 5ms — it means at least 5ms.

Pending

Internal — TCP error callbacks deferred from the previous loop tick. Rarely your code.

Poll

The big one. Picks up new I/O events (incoming requests, completed reads) and runs their callbacks. If the poll queue is empty, the loop may block here waiting for I/O.

Check

Runs setImmediate callbacks. The ONLY phase that runs them. Use setImmediate when you want "after the current poll cycle".

Close

Runs close-event callbacks like socket.on('close').

Microtasks (every gap)

process.nextTick queue first, then Promise queue. Drained between every operation, not just between phases. nextTick beats Promises.

Async · Ordering

15 · process.nextTick · setImmediate · setTimeout(0)

This is the trick question every Node interviewer keeps in their pocket. The three look like "do it later" but they're scheduled in different phases.

APIWhen it runsBeats
process.nextTick(cb)Before any other I/O / timer — drained right after the current opEverything below
Promise .then(cb)Microtask queue, after nextTickTimers & Immediate
setTimeout(cb, 0)Timers phase, ≥1ms later
setImmediate(cb)Check phase, after Poll
order-puzzle-node.js
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));

// nextTick → promise → (timeout or immediate, order varies in main module)
// Inside an I/O callback, setImmediate ALWAYS beats setTimeout(0).
When should I prefer setImmediate over setTimeout(0)?
Inside an I/O callback when you want to "yield and continue after the current poll cycle". setImmediate is the documented contract for that. setTimeout(0) can drift to ≥1ms and depends on system clock.
Why is process.nextTick dangerous?
It runs BEFORE the loop continues. A recursive nextTick can starve I/O — your server stops accepting connections because the loop never reaches the Poll phase. Use it carefully; setImmediate is usually safer.
I/O

16 · Streams & Buffers

Streams are how Node processes data that doesn't fit in memory — like a 5GB log file or a video upload. Instead of loading the whole thing, you process it in chunks as it flows past.

Concept

A stream is an abstraction for sequentially processing data that arrives or departs in chunks over time, without loading the entire payload into memory. Streams operate on small fixed-size buffers (typically 16 KB or 64 KB) that flow through a pipeline of producers, transformers, and consumers.

Streams are built on EventEmitter — they emit events like data, end, error, and close as chunks become available or the stream finishes.

Four stream types in Node:

  • Readable — produces data. Consumers read chunks from it. Examples: fs.createReadStream, HTTP request, process.stdin.
  • Writable — consumes data. Producers write chunks into it. Examples: fs.createWriteStream, HTTP response, process.stdout.
  • Duplex — both readable and writable, with two independent channels. Example: TCP sockets — you read incoming bytes and write outgoing bytes separately.
  • Transform — a Duplex where the output is computed from the input. Example: zlib.createGzip(), crypto.createCipheriv().

Backpressure is the central problem streams solve. If the consumer is slower than the producer, chunks pile up in memory and eventually crash the process. Node streams handle this automatically: when a Writable's internal buffer fills, write() returns false, signaling the producer to pause. When the buffer drains, the drain event fires and the producer resumes.

Always use pipeline() (from stream/promises) instead of raw .pipe() — it correctly propagates errors, destroys all streams on failure, and resolves only when the entire chain finishes successfully. Raw .pipe() silently leaks streams on error.

A Buffer is the underlying data type streams carry. It is a fixed-length region of raw memory outside V8's heap (so it does not count against the JS heap size limit). Buffers handle binary data — files, network packets, encoded text. Allocate with Buffer.alloc(n) (zero-filled, safe) or Buffer.from(data).

The four stream types

Readable

You can read FROM it. Examples: fs.createReadStream(), http.IncomingMessage (the request).

Writable

You can write TO it. Examples: fs.createWriteStream(), http.ServerResponse.

Duplex

Both readable and writable, independent channels. Example: TCP sockets.

Transform

Duplex where output is computed from input. Examples: zlib.createGzip(), crypto.createCipher().

stream-pipeline.js
const { pipeline } = require('stream/promises');
const fs = require('fs');
const zlib = require('zlib');

// gzip a 5GB file using ~64KB of RAM
await pipeline(
  fs.createReadStream('huge.log'),
  zlib.createGzip(),
  fs.createWriteStream('huge.log.gz')
);
What is backpressure?
When the writable side is slower than the readable side, data piles up in memory. Streams handle this automatically: readable.pipe(writable) pauses the source when the destination's internal buffer fills up. Use pipeline() instead of raw .pipe() — it propagates errors and cleans up streams properly.
What is a Buffer?
A fixed-size chunk of raw binary memory outside V8's heap. Used to handle bytes — files, network packets, images. Created with Buffer.alloc(n) (zero-filled, safe) or Buffer.from(data). Avoid the deprecated new Buffer() — it allocates uninitialized memory and was a known security hole.
Events

17 · EventEmitter — The Pub/Sub at Node's Core

Half of Node is built on EventEmitter — streams, HTTP servers, child processes, sockets all extend it. Knowing the API also signals you understand the observer pattern.

emitter.js
const { EventEmitter } = require('events');
const bus = new EventEmitter();

bus.on('order:placed', (id) => console.log('email ' + id));
bus.on('order:placed', (id) => console.log('metric ' + id));
bus.emit('order:placed', 42);

// 'once' — listener auto-removed after first fire
// 'off' / 'removeListener' — detach
// default max listeners = 10 (warning above)
Why does Node print "MaxListenersExceededWarning"?
Each emitter has a soft limit (default 10) of listeners per event. Going over is usually a leak — typically you forgot to off() a listener inside a handler that re-runs. Bump the limit with setMaxListeners(20) only if you genuinely need more — otherwise fix the leak.
Scaling

18 · Cluster vs Worker Threads

One Node process uses one CPU core. But your server has 8 cores. How do you use them all?

Concept

A single Node process runs your JavaScript on one thread, which means it can only saturate one CPU core. To use multiple cores, Node provides two distinct mechanisms — and the choice depends on what kind of work you are scaling.

Cluster — spawns multiple independent Node processes, each a full OS-level process with its own V8 instance, its own heap, and its own event loop. A "primary" process forks N "worker" processes (typically one per CPU core). All workers share the same listening socket, and the OS distributes incoming connections among them. Processes do not share memory; they communicate through IPC messages.

Worker Threads — spawns multiple threads inside the same Node process. Each thread has its own V8 isolate and event loop, but they live inside one OS process. Threads can share memory through SharedArrayBuffer and synchronize via Atomics. They communicate through structured-clone messages on a MessagePort.

When to use which:

  • Cluster — for I/O-bound HTTP servers. Each worker handles its own connections; if one process crashes, the others continue serving. Operationally simpler — the same code runs in each worker. This is what PM2 and Kubernetes replica scaling rely on.
  • Worker Threads — for CPU-bound work inside a single request. If an endpoint must do something heavy (image resize, hash, parse, PDF generation), running it on the main thread freezes the server for every other client. Offloading to a worker thread keeps the main thread responsive.

Trade-offs: processes have higher startup cost and more memory overhead but stronger isolation (one crash does not take down the others). Threads are cheaper to create and can share memory but a fatal error in one thread can destabilize the whole process.

ClusterWorker Threads
UnitOS processThread inside one process
MemorySeparate (no shared state)Shared via SharedArrayBuffer
Best forScaling HTTP servers across coresCPU-heavy tasks (image processing, parsing, hashing)
Crash blast radiusOne process — others surviveOne thread — but a bad process.exit() kills all
ToolingBuilt-in cluster module · PM2 · K8sBuilt-in worker_threads
When should I prefer worker threads over cluster?
When the bottleneck is CPU on a single request — e.g., an endpoint that compresses a 10MB payload and blocks the event loop for 200ms. A cluster process would still freeze for 200ms on that one request; a worker thread offloads the compression and the main thread keeps serving others.
Process

19 · Child Processes — spawn vs exec vs fork

spawn

Launch any binary, stream its stdout/stderr. Best for long-running or large-output processes — never buffers, won't OOM.

spawn('ffmpeg', ['-i', ...])

exec

Run a shell command, get the FULL stdout/stderr in a callback once it's done. Buffered — careful with large output (default 1MB cap).

exec('ls -la', cb)

fork

Special-case spawn that runs another Node script with an IPC channel for message passing. Used for cluster.

fork('./worker.js')
Security warning. Never pass user input to exec — it goes through a shell, opening you to command injection. Use spawn with an args array, where arguments are passed safely without shell parsing.
Modules

20 · CommonJS vs ES Modules

Node started life with CommonJS (require / module.exports) because ES modules didn't exist when Node was born. Today both work, and the difference matters in interviews.

CommonJSES Modules
Syntaxconst x = require('x')import x from 'x'
LoadingSynchronousAsynchronous
ResolutionAt runtime, dynamicStatic, at parse time
Tree-shakingNoYes (bundlers can drop unused exports)
top-level awaitNoYes
TriggerDefault for .js if no "type""type":"module" in package.json or .mjs
Can I require() an ESM module?
Not directly with the classic require in older Node. Use dynamic import('./esm-mod.js') which returns a Promise and works from CJS. Newer Node (≥22) added experimental require(esm) for synchronous interop.
What is the module wrapper in CJS?
Before running your file, Node wraps it in a function: (function(exports, require, module, __filename, __dirname) { /* your code */ }). That's why those five identifiers are "magically available" inside every CJS file — they're just function arguments.
Express

21 · Express & the Middleware Pipeline

Express is a thin layer over Node's http module. Its core idea is the middleware pipeline — a function chain that each request walks through.

Concept

Middleware is a function that sits in the request-handling pipeline. Each middleware has the signature (req, res, next) — it receives the request and response objects, and a next callback that passes control to the next middleware in the chain.

Express maintains an ordered list of middleware. When a request arrives, Express walks the list in registration order, calling each middleware. Each middleware has three options:

  1. Pass control forward by calling next() (optionally after mutating req or res).
  2. End the response by calling res.send(), res.json(), res.end(), etc. Subsequent middleware in the chain is skipped.
  3. Forward an error by calling next(err). Express then skips all normal middleware and jumps to the next error-handling middleware (a function with four arguments: (err, req, res, next)).

Two key types of middleware:

  • Application-level — registered with app.use(...). Runs for every request that matches its path prefix.
  • Route-level — registered with app.get, app.post, etc. Only runs for that HTTP method on that exact path.

Order matters. Body parsers must be registered before any handler that reads req.body. Authentication middleware must be registered before protected routes. Error-handling middleware must be registered last.

Error detection by arity: Express distinguishes error-handling middleware from normal middleware by inspecting fn.length. A function with four parameters is treated as an error handler and only runs when next(err) was called upstream. This is convention, not configuration.

Express itself is a thin wrapper over Node's built-in http module. The middleware pattern, the router, and helpers like res.json() are all that Express adds — the actual networking is pure Node.

express-pipeline.js
const app = express();

app.use(express.json());                         // 1. parse body
app.use((req, res, next) => { req.id = crypto.randomUUID(); next(); }); // 2. tag
app.use(authMiddleware);                          // 3. auth or 401
app.get('/users/:id', getUser);                  // 4. handler

// 5. error-handling middleware — 4 args is the magic signature
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'oops' });
});
How does the error-handling middleware know it's the error one?
Pure convention: Express checks the function's .length. A 4-arg function (err, req, res, next) is treated as an error handler. It only runs when something upstream calls next(err) or throws inside an async handler that you wrapped properly.
Errors

22 · Error Handling — the Right Way

Operational errors

Expected runtime problems: bad user input, network timeout, DB unavailable. Catch them, respond gracefully, log them.

Programmer errors

Bugs: undefined property, wrong type, logic mistake. You can't recover. Log, then crash and let the supervisor (PM2 / K8s) restart.

Should I catch uncaughtException and keep running?
No. By the time it fires, the process state is undefined — half-mutated objects, leaked file descriptors. Log the error, try to flush logs gracefully, then call process.exit(1) and let your orchestrator restart. The official Node guidance is "crash on programmer errors".
How do I forward async errors in Express?
In modern Express (≥5) async route handlers' rejections auto-forward to the error middleware. In Express 4 you must wrap them:
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/x', wrap(async (req, res) => { ... }));
Production

23 · Memory Leaks & Debugging

"My Node service starts at 200MB and slowly creeps to 2GB then dies" — every Node engineer has lived this. The four usual suspects:

① Forgotten listeners

Adding emitter.on(...) inside a function that runs on every request — and never calling off. Each request piles a new closure on. Look for the MaxListenersExceeded warning.

② Closures holding big data

A timer, callback, or cache referencing a big object keeps the entire object alive. Audit globals; weak references (WeakMap, WeakRef) can help.

③ Unbounded caches

An in-memory Map that grows forever. Use lru-cache with a max size or TTL.

④ Module-level state in long-lived processes

Anything assigned at the top of a file lives forever. Resist temptation to keep "just one global counter".

Tools

  • node --inspect app.js + Chrome DevTools → live heap snapshots, CPU profiles.
  • --prof + node --prof-process → V8 tick profiler.
  • clinic.js doctor / flame / bubbleprof → opinionated diagnostic with auto-recommendations.
  • process.memoryUsage() → quick log line; watch heapUsed trend over time.
Security

24 · Security Cheat Sheet

Inputs

  • Validate & sanitize every body / query / param (zod, joi).
  • Never pass user input to exec, eval, or template strings sent to the shell.
  • Cap body size with express.json({ limit: '100kb' }) — defends against memory DoS.

Headers

  • Use helmet() middleware — sets ~12 secure-by-default headers (CSP, HSTS, X-Frame-Options).
  • Set cors() with an allowlist, never * on credentialed endpoints.

Auth

  • Hash passwords with bcrypt or argon2 — never SHA-256.
  • Sign JWTs with strong secrets / RS256; set short TTL + refresh tokens.
  • Store secrets in env / vault — never in git.

Dependencies

  • Run npm audit in CI; pin versions with a lockfile.
  • Use npm ci (not install) in CI for reproducible builds.
  • Drop unused deps — every package is supply-chain risk.
Quickfire

25 · Rapid-Fire Q&A

The remaining short-form questions interviewers fire when time is running out. Memorise the one-liners.

Why is Node.js single-threaded but scalable?
JS runs on one thread, but I/O is delegated to libuv (thread pool) and the OS kernel (epoll/kqueue). The thread is rarely blocked, so a single process can serve thousands of concurrent connections.
What is V8?
Google's open-source JavaScript engine, written in C++. Same one in Chrome. JIT-compiles JS to optimized machine code. Node.js embeds it.
What is libuv?
A C library that gives Node its event loop, thread pool, and cross-platform async I/O abstractions over epoll (Linux), kqueue (macOS), IOCP (Windows).
Difference between fs.readFile and fs.createReadStream?
readFile loads the entire file into memory before the callback fires — fine for small configs, dangerous for large files. createReadStream emits chunks as they're read — constant-memory, scales to any file size.
What is __dirname?
In CJS, the absolute path of the directory the current file lives in. Not available in ESM — use import.meta.url + fileURLToPath instead.
How does require caching work?
First require('x') loads + executes x and caches its module.exports. Every subsequent require('x') returns the same exports object — without re-running the file. That's why module-level state behaves like a singleton.
What is the difference between exit codes?
0 = success, 1 = generic failure, >1 = specific error class. Set with process.exit(code). Orchestrators (PM2, Kubernetes) often restart on non-zero.
What is the package-lock.json for?
Records the EXACT version of every package and sub-dependency installed, so npm ci on another machine produces the identical tree. Without it, two builds days apart can diverge if a sub-dep released a patch.
What's nodemon doing under the hood?
Watches your files with chokidar, kills the running Node process on change, restarts it. Pure dev convenience — never use in production; use PM2 / systemd.
Why is JSON.parse blocking?
It runs synchronously on the event loop. A 100MB JSON string can freeze the server for hundreds of ms. For huge payloads, stream-parse with stream-json or offload to a worker thread.
Difference between res.send, res.json, res.end?
res.end is raw — closes the response, no Content-Type. res.send auto-detects type, sets headers, supports strings/buffers/objects. res.json stringifies + sets application/json. Use res.json for APIs, res.end when streaming a custom body.
What is CORS and why does it bite Node devs?
Cross-Origin Resource Sharing — browser security policy that blocks JS on a.com from calling b.com unless b.com sends explicit Access-Control-Allow-Origin headers. Solve in Express with the cors middleware, configured with an explicit origin allowlist.
What's the difference between app.use and app.get?
app.use mounts middleware that runs for ALL HTTP methods at a path prefix. app.get mounts a handler for GET requests at an exact path. Order matters — middleware must be declared before the routes that depend on it.
Final tip. The interviewer rarely cares whether your answer is textbook-perfect. They care whether you can explain why the design is the way it is, and what breaks if it weren't. Every concept above is built around the "why" — keep that pattern in your answers, and you'll sound like an engineer instead of a flashcard.

Did this JS & Node.js guide click? If it helped, tap the ❤️ — that's how I know it landed.