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.
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.
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:
setTimeout, I/O, UI events, setImmediate..then/.catch/.finally callbacks, queueMicrotask, MutationObserver."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.
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.
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.
// 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;
Function-scoped. Hoisted & initialised to undefined. Re-declaration allowed. Avoid in modern code.
Block-scoped. Hoisted but in TDZ until the line runs. Re-declaration in the same scope throws. Re-assignment allowed.
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.
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.const truly 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.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.
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:
Closures are how JavaScript implements private state, partial application, and stateful callbacks without classes.
closure-counter.jsfunction 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
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.
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.
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):
new binding — new Fn() creates a fresh empty object and sets this to it.fn.call(obj), fn.apply(obj), or fn.bind(obj) directly specify this.obj.fn() sets this to the object before the dot.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 style | Example | this is… |
|---|---|---|
| 1. Method call | obj.fn() | obj |
| 2. Plain call | fn() | undefined in strict mode, else globalThis |
3. new call | new Fn() | the freshly created object |
| 4. Explicit bind | fn.call(x) · fn.apply(x) · fn.bind(x) | x |
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.
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
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.setTimeout(this.fn, 1000) sometimes lose this?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()).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.
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:
foo is an own property of obj. If yes, returns it.[[Prototype]] to the parent object and repeats step 1.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.
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 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'); };
__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.
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
Every async question is the event loop wearing a different hat. If you only memorise one diagram for your interview, memorise this one.
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:
.then / .catch / .finally callbacks, queueMicrotask(), and MutationObserver callbacks.setTimeout, setInterval, I/O completion callbacks, UI events (click, scroll), and setImmediate in Node.The algorithm per loop iteration:
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.jsconsole.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
.then / .catch / .finally callbacks, queueMicrotask(), and MutationObserver callbacks. They run before the next macrotask, after the current one finishes.setTimeout, setInterval, I/O callbacks, UI events (click, scroll), setImmediate in Node.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.
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:
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.
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).
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).
Settles with whichever promise settles first — fulfilled OR rejected. Useful for timeouts: race a fetch against a 5-second timer.
Resolves with the first FULFILLED one. Only rejects if ALL reject (with an AggregateError). Use for fallback mirrors — "give me whichever CDN responds first."
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).
// ❌ 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));
.catch a rejected promise?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.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.
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:
==, <, >)+, -, etc.) — note + with any string becomes string concatenationif, ternary, &&, ||, !)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.
[] == ![] // 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
Only seven values are falsy: false, 0, -0, 0n, "", null, undefined, NaN. Everything else (including [] and {}!) is truthy.
?? 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 ✅
NaN?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.The bug story behind this question is identical at every company: "I changed one item in userB and somehow userA changed too."
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:
const b = a. Both variables point to the same object. Any mutation through one is visible through the other.Object.assign({}, obj), the spread operator {...obj}, or Array.from(arr).structuredClone(obj) (the modern standard, built into Node ≥17 and all current browsers).Why structuredClone over JSON.parse(JSON.stringify(obj)):
undefined, functions, and symbols.Date objects to strings and Map / Set to empty {}.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.
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
JSON.parse(JSON.stringify(obj)) a bad deep clone?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).Both limit how often a function runs. They're cousins but solve different problems. Interviewers love asking you to implement both from scratch.
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.jsfunction 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); }; }
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:
const addTen = add(10)).compose/pipe chains.Currying is implemented purely with closures — each returned function captures the previously supplied arguments.
curry.jsfunction 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 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:
JSON.stringify (which fails on cycles) or a structural hash.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.jsfunction 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); }; }
const arr2 = [...arr1, 4]; // spread function sum(...nums) { ... } // rest
Same syntax, opposite meaning. Spread expands, rest collects.
const { name, age = 18 } = user; const [first, ...rest] = arr;
Pull values out of objects/arrays in one line. Default values fill in for undefined.
user?.address?.city api?.fetch?.()
Returns undefined instead of throwing when a link in the chain is null/undefined.
value ?? 'fallback'
Falls back ONLY on null/undefined, not on 0 or "".
Map keeps insertion order, accepts any key type, has .size. Set stores unique values — handy one-liner: [...new Set(arr)] dedupes an array.
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 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.
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:
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.
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.
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.
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.
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.
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.
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."
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):
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.setImmediate is pending, it moves on to Check; otherwise it may block here waiting for I/O.setImmediate callbacks. This is the only phase that executes them.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.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".
Runs setTimeout and setInterval callbacks whose threshold has elapsed. "5ms timeout" doesn't mean exactly 5ms — it means at least 5ms.
Internal — TCP error callbacks deferred from the previous loop tick. Rarely your code.
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.
Runs setImmediate callbacks. The ONLY phase that runs them. Use setImmediate when you want "after the current poll cycle".
Runs close-event callbacks like socket.on('close').
process.nextTick queue first, then Promise queue. Drained between every operation, not just between phases. nextTick beats Promises.
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.
| API | When it runs | Beats |
|---|---|---|
process.nextTick(cb) | Before any other I/O / timer — drained right after the current op | Everything below |
Promise .then(cb) | Microtask queue, after nextTick | Timers & Immediate |
setTimeout(cb, 0) | Timers phase, ≥1ms later | — |
setImmediate(cb) | Check phase, after Poll | — |
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).
setImmediate over setTimeout(0)?setImmediate is the documented contract for that. setTimeout(0) can drift to ≥1ms and depends on system clock.process.nextTick dangerous?setImmediate is usually safer.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.
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:
fs.createReadStream, HTTP request, process.stdin.fs.createWriteStream, HTTP response, process.stdout.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).
You can read FROM it. Examples: fs.createReadStream(), http.IncomingMessage (the request).
You can write TO it. Examples: fs.createWriteStream(), http.ServerResponse.
Both readable and writable, independent channels. Example: TCP sockets.
Duplex where output is computed from input. Examples: zlib.createGzip(), crypto.createCipher().
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') );
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.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.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.jsconst { 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)
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.One Node process uses one CPU core. But your server has 8 cores. How do you use them all?
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:
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.
| Cluster | Worker Threads | |
|---|---|---|
| Unit | OS process | Thread inside one process |
| Memory | Separate (no shared state) | Shared via SharedArrayBuffer |
| Best for | Scaling HTTP servers across cores | CPU-heavy tasks (image processing, parsing, hashing) |
| Crash blast radius | One process — others survive | One thread — but a bad process.exit() kills all |
| Tooling | Built-in cluster module · PM2 · K8s | Built-in worker_threads |
Launch any binary, stream its stdout/stderr. Best for long-running or large-output processes — never buffers, won't OOM.
spawn('ffmpeg', ['-i', ...])
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)
Special-case spawn that runs another Node script with an IPC channel for message passing. Used for cluster.
fork('./worker.js')
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.
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.
| CommonJS | ES Modules | |
|---|---|---|
| Syntax | const x = require('x') | import x from 'x' |
| Loading | Synchronous | Asynchronous |
| Resolution | At runtime, dynamic | Static, at parse time |
| Tree-shaking | No | Yes (bundlers can drop unused exports) |
| top-level await | No | Yes |
| Trigger | Default for .js if no "type" | "type":"module" in package.json or .mjs |
require() an ESM module?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.(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 is a thin layer over Node's http module. Its core idea is the middleware pipeline — a function chain that each request walks through.
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:
next() (optionally after mutating req or res).res.send(), res.json(), res.end(), etc. Subsequent middleware in the chain is skipped.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:
app.use(...). Runs for every request that matches its path prefix.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.
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' }); });
.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.Expected runtime problems: bad user input, network timeout, DB unavailable. Catch them, respond gracefully, log them.
Bugs: undefined property, wrong type, logic mistake. You can't recover. Log, then crash and let the supervisor (PM2 / K8s) restart.
uncaughtException and keep running?process.exit(1) and let your orchestrator restart. The official Node guidance is "crash on programmer errors".const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); app.get('/x', wrap(async (req, res) => { ... }));
"My Node service starts at 200MB and slowly creeps to 2GB then dies" — every Node engineer has lived this. The four usual suspects:
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.
A timer, callback, or cache referencing a big object keeps the entire object alive. Audit globals; weak references (WeakMap, WeakRef) can help.
An in-memory Map that grows forever. Use lru-cache with a max size or TTL.
Anything assigned at the top of a file lives forever. Resist temptation to keep "just one global counter".
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.zod, joi).exec, eval, or template strings sent to the shell.express.json({ limit: '100kb' }) — defends against memory DoS.helmet() middleware — sets ~12 secure-by-default headers (CSP, HSTS, X-Frame-Options).cors() with an allowlist, never * on credentialed endpoints.bcrypt or argon2 — never SHA-256.npm audit in CI; pin versions with a lockfile.npm ci (not install) in CI for reproducible builds.The remaining short-form questions interviewers fire when time is running out. Memorise the one-liners.
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.__dirname?import.meta.url + fileURLToPath instead.require caching work?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.0 = success, 1 = generic failure, >1 = specific error class. Set with process.exit(code). Orchestrators (PM2, Kubernetes) often restart on non-zero.package-lock.json for?npm ci on another machine produces the identical tree. Without it, two builds days apart can diverge if a sub-dep released a patch.nodemon doing under the hood?chokidar, kills the running Node process on change, restarts it. Pure dev convenience — never use in production; use PM2 / systemd.JSON.parse blocking?stream-json or offload to a worker thread.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.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.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.Did this JS & Node.js guide click? If it helped, tap the ❤️ — that's how I know it landed.