Javascript / Typescript Interview Notes
Welcome to the JS/TS specific interview prep notes. More sections will be added here!
Q: Can you explain the difference between the microtask queue and the macrotask queue?
Answer:
Both the microtask queue and the macrotask queue (often just called the "task queue") are deeply integrated into the JavaScript Event Loop. They dictate the strict priority order in which asynchronous callbacks are executed.
1. The Microtask Queue
The microtask queue has absolute, highest priority over the macrotask queue. Its purpose is to execute small, immediate background tasks exactly before the Event Loop is allowed to continue, render the UI, or look at the macrotask queue.
What goes into the microtask queue?
- Promises (
.then(),.catch(),.finally()) process.nextTick()(in Node.js)queueMicrotask()MutationObservercallbacks
2. The Macrotask Queue
The macrotask queue holds larger, more generic operations that the browser or host environment schedules.
What goes into the macrotask queue?
setTimeout()setInterval()setImmediate()(in Node.js)- I/O operations (like fetching network data)
- UI rendering and event callbacks (like clicks or keyboard presses)
How they interact (The Execution Order)
- The Event Loop executes the current synchronous code (the call stack) until it is completely empty.
- Once empty, it checks the microtask queue. It executes every single task inside the microtask queue until it's completely empty. (If a microtask schedules another microtask, it will process that one too!).
- Only when the microtask queue is 100% empty, the Event Loop takes exactly one task from the macrotask queue and executes it.
- After that one macrotask finishes, it loops back and checks the microtask queue again.
Classic Interview Example
console.log('1. Script start'); // Synchronous
setTimeout(() => { // Macrotask
console.log('2. setTimeout');
}, 0);
Promise.resolve().then(() => { // Microtask
console.log('3. Promise 1');
}).then(() => { // Microtask chained
console.log('4. Promise 2');
});
console.log('5. Script end'); // Synchronous
Output Order:
1. Script start(Sync)5. Script end(Sync)3. Promise 1(Microtask Queue)4. Promise 2(Microtask Queue emptied)2. setTimeout(Macrotask Queue picked up last)
Q: Explain the JavaScript Event Loop. How does single-threaded JavaScript handle asynchronous operations?
Answer:
JavaScript is single-threaded — it has one call stack and can do exactly one thing at a time. Yet it handles network calls, timers and user input concurrently. This is possible because the runtime (browser or Node.js) provides extra machinery around the engine: a Call Stack, Web/C++ APIs, Task Queues, and the Event Loop that ties them together.
The Big Picture
+-------------------+ +-----------------------+
| Call Stack | | Web APIs / libuv |
| (synchronous) | | setTimeout, fetch, |
| | | DOM events, I/O |
+---------+---------+ +-----------+-----------+
| |
| pushes/pops frames | when work is done,
v v callback is enqueued
+-------------------+ +-----------------------+
| Heap | | Macrotask Queue |
| (objects) | | (timers, I/O, UI) |
+-------------------+ +-----------+-----------+
|
+-----------v-----------+
| Microtask Queue |
| (promises, nextTick) |
+-----------+-----------+
|
+-----------v-----------+
| EVENT LOOP |
| pulls work back into |
| the call stack |
+-----------------------+
The Loop's Algorithm
The event loop runs a simple, deterministic cycle:
- Execute the entire current synchronous task on the call stack until it is empty.
- Drain the microtask queue completely (promise reactions,
queueMicrotask,MutationObserver). - Run one macrotask (timer callback, I/O completion, UI event).
- If in a browser, possibly render a frame.
- Repeat.
[!NOTE] The microtask queue is drained to empty between each macrotask. A long chain of
.then(...)callbacks can starve timers and rendering.
Worked Example
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));
console.log('E');
Output: A, E, C, D, B.
AandEare synchronous — they run during the initial script task.- The
setTimeoutcallback is queued as a macrotask. - Both
.thenreactions are microtasks. After sync code, the microtask queue drains:CthenD. - Only then is the next macrotask dequeued:
B.
Node.js Specifics
Node.js uses libuv and has additional phases inside one macrotask "tick":
timers -> pending -> idle/prepare -> poll -> check -> close
setTimeoutcallbacks fire in the timers phase.setImmediatefires in the check phase.process.nextTick()is a higher-priority microtask, drained before promise microtasks.
Common Interview Traps
- Blocking the loop. A long synchronous loop blocks every queue. Offload CPU work to a Worker.
- Microtask starvation. Recursive
Promise.resolve().then(...)chains can prevent timers from firing. setTimeout(fn, 0)is not zero. Browsers clamp to ~4ms after nested timeouts; the callback is still macrotask-scheduled, not immediate.- Rendering happens between macrotasks. Mutating the DOM 1000 times in one synchronous loop produces exactly one paint, not 1000.
Q: What are the differences between Promise.all, Promise.allSettled, Promise.race, and Promise.any?
Answer:
All four are promise combinators — static methods that take an iterable of promises and return a single promise. They differ in when they settle and what they resolve or reject with.
Quick Comparison
| Method | Resolves when | Rejects when | Result |
|---|---|---|---|
Promise.all | All fulfill | Any rejects (fail-fast) | Array of values |
Promise.allSettled | All settle (fulfilled or rejected) | Never | Array of {status, value/reason} |
Promise.race | First to settle (either way) | First rejection | Single value or reason |
Promise.any | First fulfillment | All reject (with AggregateError) | Single value |
Promise.all — fail-fast aggregation
const [user, posts, settings] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchSettings(),
]);
If any single promise rejects, the entire Promise.all rejects immediately. The other promises keep running in the background (they aren't cancelled), but their results are discarded.
[!NOTE] Use
Promise.allonly when partial failure is unacceptable. If a single optional resource fails, the whole batch is lost.
Promise.allSettled — never rejects
Introduced in ES2020. Waits for every promise regardless of outcome.
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
results.forEach(r => {
if (r.status === 'fulfilled') console.log('value:', r.value);
else console.error('reason:', r.reason);
});
Ideal for dashboards where you want to render whatever succeeded and show errors for the rest.
Promise.race — first to finish
Returns whichever promise settles first, success or failure. Common for adding a timeout:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms)
);
return Promise.race([promise, timeout]);
}
Promise.any — first success
Introduced in ES2021. Resolves on the first fulfillment; rejects only if every promise rejects, with an AggregateError containing all reasons.
try {
const fastest = await Promise.any([
fetch('https://mirror1/api'),
fetch('https://mirror2/api'),
fetch('https://mirror3/api'),
]);
} catch (e) {
// e instanceof AggregateError; e.errors is the list of reasons
}
Decision Flow
need every result?
|
+-- yes --> tolerate partial failure?
| |
| +-- yes --> Promise.allSettled
| +-- no --> Promise.all
|
+-- no --> need any success?
|
+-- yes --> Promise.any
+-- no --> Promise.race (first settle wins)
Common Pitfalls
Promise.all([])resolves immediately with[].Promise.any([])rejects with emptyAggregateError.- Non-promise values are wrapped via
Promise.resolve(value)— passing raw numbers is legal. - Combinators do not cancel in-flight work. Use
AbortControllerto actually abort fetches when a race winner is determined.
Q: What are common pitfalls when using async/await? How does it actually work under the hood?
Answer:
async/await is syntactic sugar over promises. An async function always returns a Promise; an await expression pauses the function until the awaited promise settles, then either resumes with the resolved value or throws the rejection reason. The function is conceptually transformed into a state machine using generators and the microtask queue.
Mental Model
async function foo() {
const a = await stepA();
const b = await stepB(a);
return b;
}
// Roughly equivalent to:
function foo() {
return stepA().then(a => stepB(a)).then(b => b);
}
Every await is a then boundary — a microtask hop. There is no thread; the function "pauses" by returning control to the event loop.
Pitfall 1: Sequential vs Parallel
// BAD: 6 seconds total
const user = await fetchUser(); // 2s
const orders = await fetchOrders(); // 2s (independent of user!)
const reviews = await fetchReviews(); // 2s
// GOOD: 2 seconds total
const [user, orders, reviews] = await Promise.all([
fetchUser(), fetchOrders(), fetchReviews(),
]);
[!NOTE] Only chain
awaits sequentially when each call genuinely depends on the previous result.
Pitfall 2: await Inside forEach
// Does NOT wait! forEach ignores returned promises.
items.forEach(async item => {
await process(item);
});
console.log('done'); // logs before any item finishes
// Use for...of for sequential
for (const item of items) {
await process(item);
}
// Or Promise.all for parallel
await Promise.all(items.map(process));
Pitfall 3: Forgetting to Await Means Unhandled Rejection
async function save() {
doNetworkCall(); // fire-and-forget; rejection becomes UnhandledPromiseRejection
return 'ok';
}
If doNetworkCall() rejects, the error is lost. Either await it or attach .catch(handleError).
Pitfall 4: try/catch Only Catches Synchronous Awaited Errors
async function load() {
try {
return fetchData(); // NO await: error escapes try/catch
} catch (e) { /* unreachable */ }
}
The return here returns a promise; the function exits before the promise rejects, so the catch never sees it. Add await (return await fetchData()) or .catch the call.
Pitfall 5: Mixing await and Loops in Hot Paths
Awaiting inside a tight loop serializes work. For independent tasks, build a promise array and await Promise.all([...]). For backpressure, batch with chunks:
for (const chunk of chunks(items, 10)) {
await Promise.all(chunk.map(process));
}
Pitfall 6: Top-Level Await Blocks Module Graph
ES module top-level await is allowed but delays evaluation of every importing module. Avoid long awaits at module top-level.
Under the Hood — State Machine
async function f() {
const x = await p1; --> suspend, register .then on p1
const y = await p2; --> on resume with x, suspend on p2
return x + y; --> on resume with y, resolve f's promise
}
Each await resumption is queued as a microtask. That means an await always yields at least once to the event loop, even if the value is already resolved.
Pitfall 7: await on a Non-Promise
await 42 is legal — the value is wrapped in Promise.resolve(42) and resumes on the next microtask tick. Useful in tests but introduces unnecessary microtask hops in hot code.
Q: What is the difference between debouncing and throttling? When would you use each?
Answer:
Both are techniques to rate-limit how often a function runs in response to a high-frequency event (scroll, resize, keypress, mousemove). They differ in which invocations get through.
- Debounce: wait until the event has stopped firing for
Nms, then run once. - Throttle: allow the function to run at most once every
Nms, regardless of how many events fire.
Visual Timeline
events: x x x x x x x x x x x x x x x
debounce -----------|-----|-------------|-----|-X (fires only at end of bursts)
throttle |---|---|---|---|---|---|---|---|---|---| (steady cadence)
Use Cases
| Situation | Pick |
|---|---|
| Search-as-you-type API call | Debounce |
| Save draft after typing stops | Debounce |
| Window resize layout recalculation | Debounce |
| Scroll-driven analytics or infinite scroll | Throttle |
| Mousemove drag preview | Throttle |
| Buttons protected from double-click | Throttle (leading) |
Implementation — Debounce
function debounce(fn, wait) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
const onSearch = debounce(query => api.search(query), 300);
input.addEventListener('input', e => onSearch(e.target.value));
Each new event resets the timer; only the last invocation in a burst actually runs after the wait window.
Implementation — Throttle (leading + trailing)
function throttle(fn, wait) {
let last = 0;
let timer;
return function (...args) {
const now = Date.now();
const remaining = wait - (now - last);
if (remaining <= 0) {
clearTimeout(timer);
timer = null;
last = now;
fn.apply(this, args);
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}
This variant fires immediately on the first call (leading edge) and ensures a final trailing call so the last event isn't dropped.
[!NOTE] Libraries like Lodash expose
_.debounce(fn, wait, { leading, trailing, maxWait }). ThemaxWaitoption turns debounce into a "throttle floor", guaranteeing the function runs at least everymaxWaitms even during continuous activity.
React Gotcha
Defining the debounced function inside a component creates a new debounced wrapper on every render — defeating the timer. Wrap with useMemo or useCallback:
const onSearch = useMemo(
() => debounce(q => api.search(q), 300),
[]
);
useEffect(() => () => onSearch.cancel?.(), [onSearch]);
Remember to cancel pending timers on unmount to avoid setting state on an unmounted component.
When to Pick Which
Ask: do I need every Nth event, or only the final event after silence?
- "User stopped typing" — debounce.
- "Update the UI at a smooth 60fps while user drags" — throttle to 16ms.
- "Don't double-submit when user mashes the button" — throttle leading with no trailing.
Q: What is Hoisting in JavaScript?
Answer:
Hoisting is JavaScript's default behavior of moving declarations to the top of the current scope (script or function) prior to execution.
It's crucial to understand that only declarations are hoisted, not initializations (assignments). The JavaScript engine executes code in a two-pass process: the Creation Phase (where hoisting happens) and the Execution Phase.
1. Function Hoisting
Function Declarations are completely hoisted—both the function name and the function body. This means you can invoke a function before it appears in your code.
// ✅ Works perfectly!
sayHello();
function sayHello() { // Function Declaration
console.log("Hello there!");
}
However, Function Expressions (including arrow functions) are treated like variables. If they use var, let, or const, they follow those respective hoisting rules.
// ❌ TypeError: sayGoodbye is not a function (it is `undefined` right now)
sayGoodbye();
var sayGoodbye = function() { // Function Expression
console.log("Goodbye!");
};
2. Variable Hoisting (var)
Variables declared with var are also hoisted to the top of their function/global scope. However, they are initialized with the default value of undefined.
console.log(count); // Output: undefined
var count = 5; // Declaration is hoisted, but assignment (= 5) stays here
How the JS Engine sees it:
var count; // Hoisted and set to undefined
console.log(count);
count = 5;
3. The Temporal Dead Zone (let and const)
Variables declared with let and const are technically hoisted, but with a major catch: they are NOT initialized.
Because they aren't initialized with undefined, trying to access them before the exact line they are declared results in a strict ReferenceError. The space between the top of the scope and the line where they are defined is known as the Temporal Dead Zone (TDZ).
// entering TDZ for `username`
console.log("Doing some work...");
// console.log(username); // ❌ ReferenceError: Cannot access 'username' before initialization
let username = "Abhay"; // TDZ ends here
console.log(username); // ✅ Output: Abhay
Summary
- Function Declarations: Fully hoisted (safe to call early).
var: Hoisted, but initialized toundefined.let/const: Hoisted, but uninitialized. Placed in the Temporal Dead Zone (causes an error if accessed early).- Function Expressions / Arrow Functions: Handled based on the variable keyword (
var/let/const) they are attached to.
Q: What are the differences between var, let, and const?
Answer:
The primary differences between var, let, and const revolve around scope, hoisting behavior, and reassignment. This is a fundamental concept in modern JavaScript.
1. Scope
varis Function Scoped: If declared inside a function, it is scoped to that function. If declared block (like anifstatement orforloop), it "leaks" out to the surrounding function scope.letandconstare Block Scoped: They are constrained strictly to the block they are defined in (any code wrapped in{}).
function scopeTest() {
if (true) {
var functionScoped = "I leak out!";
let blockScoped = "I stay inside.";
}
console.log(functionScoped); // ✅ "I leak out!"
// console.log(blockScoped); // ❌ ReferenceError
}
2. Hoisting
All three are hoisted to the top of their respective scopes, but they initialize differently:
var: Initialized withundefined. You can access avarvariable before you write the declaration, but its value will beundefined.letandconst: They are hoisted, but they are NOT initialized. Accessing them before their declaration line results in aReferenceError. The space before their declaration is known as the Temporal Dead Zone (TDZ).
console.log(a); // Output: undefined
var a = 10;
// console.log(b); // ❌ ReferenceError: Cannot access 'b' before initialization
let b = 20;
3. Reassignment & Redeclaration
var: Can be randomly redeclared in the same scope without throwing an error (which is highly prone to bugs), and can be reassigned.let: Cannot be redeclared in the same scope, but its value can be reassigned.const: Cannot be redeclared and its binding cannot be reassigned. It must be initialized at the time of declaration.
[!CAUTION] While
constprevents the reassignment of the variable itself, it does not make the assigned object or array immutable. You can still mutate the internal properties of aconstobject.
var x = 1;
var x = 2; // ✅ Perfectly fine
let y = 1;
// let y = 2; // ❌ SyntaxError: Identifier 'y' has already been declared
y = 2; // ✅ Reassignment is fine
const person = { name: "Abhay" };
// person = { name: "John" }; // ❌ TypeError: Assignment to constant variable.
person.name = "John"; // ✅ Allowed! (Mutation, not reassignment)
Summary Rule of Thumb
- Always use
constby default. It clarifies your intent that the variable should not be reassigned. - If you know you will need to re-assign the variable (like a counter in a loop), use
let. - Stop using
varentirely in modern codebase environments unless maintaining legacy code.
Q: Can you explain what a closure is in JavaScript? And as a follow-up, what would this code log and why? How would you fix it to log 0, 1, 2?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
Answer:
A Closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).
In simpler terms, a closure gives a function access to its outer scope, even after the outer function has returned. In JavaScript, closures are created every time a function is created, at function creation time.
How it Works
When a function executes, it uses a scope chain to look up variables. If a function returns another function inside of it, that inner function maintains a "backpack" (a hidden [[Environment]] reference) containing all the variables it needs from its parent's scope.
function makeGreeting(greeting) {
// `greeting` is in the outer lexical scope
return function(name) {
// This inner function forms a closure
console.log(`${greeting}, ${name}!`);
}
}
const sayHello = makeGreeting("Hello");
const sayHowdy = makeGreeting("Howdy");
// `makeGreeting` has already finished executing, but `sayHello`
// still remembers the `greeting` variable ("Hello")!
sayHello("Abhay"); // Output: Hello, Abhay!
sayHowdy("Abhay"); // Output: Howdy, Abhay!
Why are Closures Useful?
-
Data Privacy / Encapsulation: JavaScript did not historically have access modifiers like
private. Closures allow you to create private variables that cannot be accessed from the outside.function createCounter() { let count = 0; // Private variable return { increment: () => ++count, getCount: () => count }; } const counter = createCounter(); counter.increment(); console.log(counter.getCount()); // 1 console.log(counter.count); // undefined (cannot access directly!) -
Function Factories: Creating partially applied functions (like
makeGreetingabove). -
Memoization / Caching: Keeping a private cache object to store expensive calculation results.
-
Callbacks & Event Handlers: When attaching an event listener, you often use variables from the outer scope inside the callback. The event listener forms a closure to remember those variables when the event actually fires.
Classic Interview "Gotcha"
A common interview question involves closures inside a for loop using var vs let:
// Using var (Function Scoped)
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
// Output: 3, 3, 3
// Because `var` is function-scoped, there is only one `i` variable shared
// by all closures. By the time the timeout runs, the loop has finished and `i` is 3.
// Using let (Block Scoped)
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 1000);
}
// Output: 0, 1, 2
// Because `let` is block-scoped, a new `j` is created for every single iteration.
// Each closure gets its own independent copy.
Q: How does the this keyword work in JavaScript? Explain all the binding rules.
Answer:
this is not a reference to the function itself or to where it was defined. It is determined by how a function is called (call-site), with four classic rules plus arrow-function lexical binding. Strict mode and ES modules also affect the defaults.
The Four Classic Rules (in precedence order)
newbinding —new Foo()creates a new object and bindsthisto it.- Explicit binding —
fn.call(obj),fn.apply(obj),fn.bind(obj). - Implicit binding —
obj.fn()bindsthistoobj. - Default binding — bare
fn()call. In strict modethisisundefined; in sloppy mode it's the global object (window/globalThis).
Arrow functions ignore all four rules: they capture this from the enclosing lexical scope at definition time.
Examples for Each Rule
'use strict';
function whoAmI() { return this; }
// 4. Default
whoAmI(); // undefined (strict) / window (sloppy)
// 3. Implicit
const obj = { whoAmI };
obj.whoAmI(); // obj
// 2. Explicit
whoAmI.call({ name: 'X' }); // { name: 'X' }
// 1. new
function Person(name) { this.name = name; }
const p = new Person('Ada'); // this -> brand new object
The "Lost this" Pitfall
const user = {
name: 'Ada',
greet() { console.log(`Hi, ${this.name}`); },
};
const greet = user.greet;
greet(); // Hi, undefined (default binding!)
setTimeout(user.greet, 100); // same problem — method passed as bare reference
The reference to the function was extracted from user, so the call-site is now plain greet(). Fixes:
setTimeout(user.greet.bind(user), 100);
setTimeout(() => user.greet(), 100); // arrow keeps `user.greet()` as the call-site
Arrow Functions
Arrows don't have their own this, arguments, super, or new.target. They inherit this from the surrounding scope at creation time — bind/call/apply cannot change it.
class Timer {
constructor() {
this.count = 0;
setInterval(() => { this.count++; }, 1000); // `this` is the instance
}
}
If you used a function expression here, the interval callback would have this === undefined (strict) or window.
Precedence Quick Test
function Foo() { this.a = 1; }
const obj = {};
const bound = Foo.bind(obj);
new bound(); // {a: 1} — `new` wins over bind
console.log(obj.a); // undefined
[!NOTE]
newbeats explicit binding. This is the only case wherebindcan be overridden.
Method Shorthand vs Property Arrow in Classes
class Btn {
// Prototype method — `this` is whoever calls it
click() { console.log(this); }
// Class field arrow — `this` is the instance permanently
onClick = () => console.log(this);
}
Use the arrow-field form for event handlers passed to React/DOM to avoid manual .bind.
Modules and this
At the top level of an ES module, this is undefined. In a CommonJS module, top-level this equals module.exports. In a browser <script> without type=module, top-level this is window.
Q: What is currying? Implement a curry function that supports partial application.
Answer:
Currying transforms a function that takes n arguments into a sequence of n unary functions:
f(a, b, c) --currying--> f(a)(b)(c)
Partial application is related but slightly different: it pre-fills some arguments and returns a function expecting the rest. A practical curry usually supports both — you can pass arguments one at a time, in groups, or all at once.
Why It Matters
- Build specialized functions from generic ones (
const add5 = add(5)). - Compose pipelines without anonymous lambdas everywhere.
- Match the signature expected by point-free functional utilities.
Manual Currying
// Original
function add(a, b, c) { return a + b + c; }
// Curried by hand
const addCurried = a => b => c => a + b + c;
addCurried(1)(2)(3); // 6
Generic curry Implementation
function curry(fn, arity = fn.length) {
return function curried(...args) {
if (args.length >= arity) {
return fn.apply(this, args);
}
return function (...more) {
return curried.apply(this, args.concat(more));
};
};
}
const sum = (a, b, c, d) => a + b + c + d;
const cSum = curry(sum);
cSum(1, 2, 3, 4); // 10
cSum(1)(2)(3)(4); // 10
cSum(1, 2)(3, 4); // 10
cSum(1)(2, 3)(4); // 10
The trick: each invocation either has enough arguments to call the original or returns a closure that remembers what we have so far and keeps collecting.
Partial Application with Placeholders
Real-world libraries (Ramda, Lodash) support a placeholder so you can skip an earlier argument:
const _ = Symbol('placeholder');
function curryP(fn) {
return function curried(...args) {
const filled = args.slice(0, fn.length);
if (
filled.length >= fn.length &&
filled.every(a => a !== _)
) {
return fn.apply(this, filled);
}
return (...more) => {
const merged = [...filled];
let i = 0;
for (const m of more) {
const idx = merged.indexOf(_, i);
if (idx === -1) merged.push(m);
else { merged[idx] = m; i = idx + 1; }
}
return curried.apply(this, merged);
};
};
}
const greet = (g, name, punct) => `${g}, ${name}${punct}`;
const cGreet = curryP(greet);
const yell = cGreet(_, _, '!');
yell('Hi', 'Ada'); // "Hi, Ada!"
Practical Uses
const fetchFrom = curry((baseUrl, path) => fetch(`${baseUrl}${path}`));
const fromApi = fetchFrom('https://api.example.com');
fromApi('/users');
fromApi('/orders');
const map = curry((fn, arr) => arr.map(fn));
const doubleAll = map(x => x * 2);
doubleAll([1, 2, 3]); // [2, 4, 6]
[!NOTE] Currying composes especially well with pipelines:
pipe(map(double), filter(isEven), reduce(sum))(list).
Trade-offs
- Currying adds function-call overhead. In hot loops that matters.
- Stack traces become deeper and harder to read.
- Variadic functions (
fn.length === 0) need an explicit arity argument.
Q: Explain JavaScript's prototype chain. How does prototypal inheritance differ from classical inheritance?
Answer:
Every JavaScript object has an internal slot [[Prototype]] (exposed via Object.getPrototypeOf(obj) or the legacy __proto__). When you access a property, the engine walks up the chain until it finds the property or reaches null. There are no classes at runtime — class syntax is sugar over functions and prototypes.
The Chain
instance ----> Constructor.prototype ----> Object.prototype ----> null
{ name } { greet, constructor } { toString, hasOwn... }
Property lookup is dynamic: changing Constructor.prototype.greet is visible from every existing instance instantly.
Three Ways to Build the Chain
// 1. Constructor function (pre-ES6)
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return `${this.name} makes a sound`; };
const a = new Animal('Rex');
// 2. Object.create — direct prototype linking
const proto = { speak() { return `${this.name} makes a sound`; } };
const b = Object.create(proto);
b.name = 'Rex';
// 3. class — sugar over option 1
class Animal2 {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
}
All three produce equivalent prototype chains.
Inheritance Hierarchy
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
speak() { return `${super.speak()} — bark!`; }
}
const d = new Dog('Rex');
//
// d --> Dog.prototype --> Animal.prototype --> Object.prototype --> null
super resolves through the prototype chain, not through any class-internal table.
Prototypal vs Classical
| Aspect | Classical (Java/C++) | Prototypal (JS) |
|---|---|---|
| Blueprint | Class defined at compile time | Objects link to other objects at runtime |
| Inheritance unit | Class extends class | Object delegates to object |
| Mutation | Class hierarchy is fixed | Add/replace methods anytime |
| Multiple parents | Often forbidden or via interfaces | Single chain, but mixins are easy |
In a prototypal system, every object is the data. There's no separate class entity; you can clone, link, or rewire chains at runtime.
Object.create and Pure Prototypal Style
const vehicle = {
start() { return `${this.kind} starting`; },
};
const car = Object.create(vehicle);
car.kind = 'Car';
car.start(); // "Car starting"
No constructors, no new, no classes — pure delegation.
[!NOTE] Avoid
__proto__. UseObject.getPrototypeOf/Object.setPrototypeOf. Better still, set the prototype at creation time viaObject.create— mutating an object's prototype after creation is a major performance deoptimization in V8.
Common Interview Questions
1. Difference between Object.create(null) and {}?
Object.create(null) has no prototype — no toString, hasOwnProperty, etc. Useful for safe maps so obj['toString'] returns undefined rather than the inherited method.
2. instanceof vs duck typing?
a instanceof B walks a's prototype chain looking for B.prototype. It is brittle across realms (iframes, workers). Prefer feature checks where possible.
3. Why is shared state on the prototype dangerous?
Storing a mutable array on Animal.prototype.tags means every instance shares the same array. Put per-instance state inside the constructor with this.tags = [].
Q: Comparison of Object.freeze() and Object.seal()
Answer:
Both Object.freeze() and Object.seal() are methods used to make an object immutable to a certain degree, but they have different levels of strictness.
1. Object.seal()
Sealing an object prevents new properties from being added and existing properties from being removed. However, you can still modify the values of existing properties (as long as they are writable).
- Add properties? No
- Delete properties? No
- Modify existing properties? Yes
- Reconfigure properties? No (cannot change enumerability/writability)
Example:
const user = { name: "Abhay", role: "admin" };
Object.seal(user);
user.name = "John"; // ✅ Allowed! (Value is modified)
user.age = 25; // ❌ Not allowed! (Silently fails in non-strict mode, throws error in strict mode)
delete user.role; // ❌ Not allowed!
2. Object.freeze()
Freezing an object is the strictest level of immutability. It does exactly what seal does, but it also prevents modifying the values of existing properties. The object becomes completely read-only.
- Add properties? No
- Delete properties? No
- Modify existing properties? No
- Reconfigure properties? No
Example:
const user = { name: "Abhay", role: "admin" };
Object.freeze(user);
user.name = "John"; // ❌ Not allowed!
user.age = 25; // ❌ Not allowed!
delete user.role; // ❌ Not allowed!
Shallow vs Deep
[!IMPORTANT] Both methods are shallow. This means if the object contains a nested object, the nested object's properties can still be modified, added, or deleted! To freeze or seal a deeply nested object, you have to recursively call the method on all child objects.
const company = {
name: "Tech Corp",
details: { employees: 50 }
};
Object.freeze(company);
// company.name = "New Tech"; // ❌ Blocked
company.details.employees = 100; // ✅ Allowed! Nested objects are unprotected.
Q: What's the difference between a shallow copy and a deep copy in JavaScript? What are the trade-offs of each cloning technique?
Answer:
A shallow copy duplicates only the top-level structure: nested objects/arrays are shared by reference between original and clone. A deep copy recursively duplicates every nested value, so mutating the clone never affects the original.
Visualizing the Difference
original shallow copy deep copy
+----------+ +----------+ +----------+
| a: 1 | | a: 1 | | a: 1 |
| b: *-----+--+ | b: *-----+--+ | b: *-----+--+
+----------+ | +----------+ | +----------+ |
v v v
+------+ (same) +------+ +------+
| x: 1 | <--------| x: 1 | (NEW) | x: 1 |
+------+ +------+ +------+
Shallow Copy Techniques
const a = { x: 1, nested: { y: 2 } };
const b = { ...a }; // spread
const c = Object.assign({}, a); // Object.assign
const d = Array.from(arr); // for arrays
const e = arr.slice(); // for arrays
b.nested.y = 99;
console.log(a.nested.y); // 99 — shared reference!
Deep Copy Techniques
-
structuredClone— built into modern browsers and Node 17+. The right default.const deep = structuredClone(original);Handles cycles, Maps, Sets, Dates, RegExps, ArrayBuffers, typed arrays. Cannot clone functions, DOM nodes (mostly), class prototype identity, or symbol-keyed properties (with caveats).
-
JSON.parse(JSON.stringify(obj))— quick and ubiquitous but lossy.- Loses
undefined, functions, symbols. - Converts
Dateto string,NaN/Infinitytonull. - Throws on cycles.
- Drops the prototype chain.
- Loses
-
Library helpers —
lodash.cloneDeepis battle-tested ifstructuredCloneisn't available. -
Hand-rolled recursion — only when you need custom semantics (e.g., skip certain keys, preserve class identity).
Trade-off Table
| Technique | Speed | Handles cycles | Preserves types | Loses |
|---|---|---|---|---|
{...obj} / spread | Fastest | Shallow only | Top-level only | Nested sharing |
JSON.parse(stringify) | Medium | No (throws) | No (Date/Map/etc lost) | Functions, undefined |
structuredClone | Fast | Yes | Maps/Sets/Date/regex/etc | Functions, DOM |
lodash.cloneDeep | Slower | Yes | Most types | Some custom classes |
[!NOTE] Prefer shallow + immutability discipline in apps using Redux/Zustand. Most state libraries assume new references at every level you change, so a controlled spread-per-level pattern is faster than a global deep clone.
Common Bug
function reset(state) {
const copy = { ...state };
copy.user.name = ''; // mutates state.user.name too!
return copy;
}
Either deep-clone, or spread layer-by-layer:
return { ...state, user: { ...state.user, name: '' } };
When Each Matters
- React/Redux state updates — use targeted shallow clones to maintain referential equality.
- Caching API responses —
structuredCloneto fully isolate cached values from callers. - Sending data to a Worker via
postMessage— the structured clone algorithm is applied automatically.
Q: When should you use a Map over a plain Object in JavaScript? What about Set vs Array?
Answer:
Map and Set were introduced in ES2015 to address long-standing gaps in using plain Object and Array as hash tables and uniqueness containers.
Map vs Object
| Concern | Map | Object |
|---|---|---|
| Key types | Any value (objects, functions, NaN) | Only strings / symbols |
| Insertion order | Guaranteed across all iterations | Mostly guaranteed for string keys |
| Size | map.size — O(1) | Object.keys(obj).length — O(n) |
| Default prototype keys | None (truly empty) | Inherits toString, hasOwnProperty, etc. |
| Iteration | Built-in iterator: for...of map | Need Object.entries(obj) first |
| Performance for frequent add/delete | Optimized | Slower; hidden-class churn in V8 |
| Serialization | No native JSON support | JSON.stringify works out of the box |
const obj = {};
obj['toString']; // [Function: toString] — inherited!
obj['__proto__']; // dangerous key
const map = new Map();
map.set('toString', 1);
map.get('toString'); // 1 — no inheritance leak
const userKey = { id: 42 };
map.set(userKey, 'admin'); // object as key — impossible with plain {}
[!NOTE] Use a
Mapwhen keys are dynamic, untrusted user input, or non-strings. Use anObjectwhen keys are a known fixed set known at code time (a struct).
When Object Is Still Better
- Static, known-at-development keys (config records, DTOs).
- JSON-compatible payloads.
- Better destructuring ergonomics:
const { id, name } = user. - Tooling/IDE support for property names.
Set vs Array
| Concern | Set | Array |
|---|---|---|
| Uniqueness | Enforced by definition | Manual (indexOf, includes) |
| Lookup | has(x) — O(1) | includes(x) — O(n) |
| Order | Insertion order | Insertion order |
| Random access | No (set[0] doesn't work) | Yes (arr[0]) |
| Dup detection cost | Free | O(n^2) loop, or extra Set |
const uniqueIds = [...new Set(ids)]; // de-dupe an array
const visited = new Set();
function dfs(node) {
if (visited.has(node)) return;
visited.add(node);
node.children.forEach(dfs);
}
WeakMap and WeakSet
When keys are objects and you want garbage collection to reclaim entries once nothing else references the key, use WeakMap/WeakSet.
- Keys must be objects (or, in modern engines, registered symbols).
- Not iterable; no
size. - Ideal for cache/metadata keyed by DOM nodes or per-request objects.
const meta = new WeakMap();
function tag(node, data) { meta.set(node, data); }
// When the DOM node is removed and GC'd, meta entry disappears automatically.
Decision Cheatsheet
keys are arbitrary or non-string? -> Map
keys are objects and GC should clean up? -> WeakMap
need uniqueness + fast contains? -> Set
keys live in DOM/runtime objects? -> WeakSet / WeakMap
static known fields, serialized to JSON? -> Object
ordered random access by index? -> Array
Q: What are Type Guards in TypeScript?
Answer:
A Type Guard is a technique in TypeScript that allows you to narrow down the type of a variable within a conditional block. By performing a runtime check, you give TypeScript the guarantee it needs to let you safely access properties that belong only to a specific type.
TypeScript supports several built-in type guards, and also allows you to define your own.
1. typeof
Used to check basic, standard Javascript primitive types (string, number, boolean, symbol).
function printId(id: number | string) {
if (typeof id === "string") {
// In this block, TypeScript knows `id` is a string
console.log(id.toUpperCase());
} else {
// Here, TypeScript knows it's a number
console.log(id.toFixed(2));
}
}
2. instanceof
Used to check if an object was constructed from a specific class.
class Car { drive() {} }
class Plane { fly() {} }
function moveVehicle(vehicle: Car | Plane) {
if (vehicle instanceof Car) {
vehicle.drive();
} else {
vehicle.fly();
}
}
3. The in Operator
Often used to narrow down structural types (like interfaces or generic objects) by checking if a specific property exists on the object.
interface Bird { fly(): void; }
interface Fish { swim(): void; }
function moveAnimal(animal: Bird | Fish) {
if ("fly" in animal) {
animal.fly(); // TypeScript narrowed `animal` down to `Bird`
} else {
animal.swim(); // Narrowed to `Fish`
}
}
4. User-Defined Type Guards (Type Predicates)
Sometimes standard checks aren't descriptive enough. You can define a custom validation function that returns a type predicate (parameterName is Type).
interface Admin { role: string; privileges: string[]; }
interface User { role: string; lastLogin: Date; }
// The `person is Admin` tells the TS compiler the type if this returns true
function isAdmin(person: Admin | User): person is Admin {
// We are doing a manual check
return (person as Admin).privileges !== undefined;
}
function processDashboard(person: Admin | User) {
if (isAdmin(person)) {
// TypeScript knows `person` is an Admin here!
console.log("Admin Privileges:", person.privileges);
} else {
console.log("User Last Login:", person.lastLogin);
}
}
Q: What is the difference between interface and type in TypeScript?
Answer:
In modern TypeScript, both interface and type (type aliases) are often used interchangeably to define object shapes, but they have a few crucial differences under the hood.
Here are the key distinctions to remember for your interviews:
1. Primitive and Union Types
type can represent any kind of type. This includes primitives, unions, and tuples.
interface can only represent the shape of an object (including functions and arrays).
// ✅ Only possible with `type`
type ID = string | number; // Union
type Coordinates = [number, number]; // Tuple
type Callback = (data: string) => void;
// ❌ Cannot be done with `interface`
// interface ID = string | number; // Error!
2. Extending / Inheritance
Both support extension, but their syntax and under-the-hood behavior differ.
interfaceuses theextendskeyword.typeuses intersections (&).
// Interface extension
interface Animal { name: string; }
interface Bear extends Animal { honey: boolean; }
// Type intersection
type AnimalType = { name: string; }
type BearType = AnimalType & { honey: boolean; }
[!NOTE] Performance tip: TypeScript's compiler caches interface resolution much more efficiently than type intersections. If you have deep, complex hierarchies,
interface extendswill compile faster than massivetype & typeintersections.
3. Declaration Merging
interface supports declaration merging. If you declare the same interface twice, TypeScript will automatically merge them into one. This is extremely useful for extending third-party libraries (like adding custom properties to the Window object).
type does not support declaration merging. Declaring a type twice throws an error.
// ✅ Interfaces Merge
interface User { name: string; }
interface User { age: number; }
// Result: { name: string; age: number }
// ❌ Types Conflict
type Person = { name: string; }
type Person = { age: number; } // Duplicate identifier 'Person'
4. Implementation in Classes
A class can implements both an interface and a type alias (as long as the type alias resolves to an object shape). There is no difference here.
type Flyable = { fly(): void };
interface Swimmable { swim(): void };
class Duck implements Flyable, Swimmable {
fly() {}
swim() {}
}
Summary Rule of Thumb
Use interface for public-facing API contracts, object shapes, and when you need declaration merging. Use type when you need a union, intersection, tuple, or are aliasing a primitive type.
Q: What's the difference between Partial<T>, Required<T>, Pick<T, K>, and Omit<T, K>?
Answer:
These are built-in Utility Types in TypeScript that perform transformations on existing types. They allow you to create new, derivative types without needing to copy-paste interface definitions, which keeps your type definitions perfectly in sync (DRY code).
1. Partial<T>
Makes all properties in type T optional (?). Highly useful for update/patch payloads where you might only send a subset of the object.
interface User {
id: number;
name: string;
email: string;
}
// PartialUser allows objects with any combination of the User properties
type PartialUser = Partial<User>;
// Example usage:
function updateUser(id: number, changes: Partial<User>) {
// `changes` can be { name: "Abhay" }, { email: "a@b.com" }, or empty!
}
2. Required<T>
The exact opposite of Partial. It makes all properties in type T required, stripping away any optional ? modifiers.
interface RegistrationForm {
username: string;
bio?: string; // Optional
avatarUrl?: string; // Optional
}
// By the time it hits the database, we expect everything to be filled out
type CompleteProfile = Required<RegistrationForm>;
const user: CompleteProfile = {
username: "abhay",
// ❌ Error: Property 'bio' is missing
// ❌ Error: Property 'avatarUrl' is missing
};
3. Pick<T, K>
Constructs a new type by "picking" a specific set of properties K (string literals or union of string literals) from type T. It's great for creating stripped-down versions of gigantic models.
interface Product {
id: number;
title: string;
description: string;
price: number;
stock: number;
manufacturerId: string;
}
// We only need the title and price for a small list view
type ProductPreview = Pick<Product, "title" | "price">;
const renderPreview = (product: ProductPreview) => {
console.log(`${product.title} costs $${product.price}`);
// console.log(product.description); // ❌ Error: Property does not exist
};
4. Omit<T, K>
The exact opposite of Pick. It constructs a new type by taking all properties from T and then omitting (removing) the specific keys K you provide. This is especially useful when creating database insertion payloads where autogenerated fields like id or createdAt shouldn't be included.
interface BlogPost {
id: string; // generated by DB
title: string;
content: string;
authorId: string;
createdAt: Date; // generated by DB
}
// Creating a new post payload doesn't need an ID or creation date yet!
type CreatePostPayload = Omit<BlogPost, "id" | "createdAt">;
const newPost: CreatePostPayload = {
title: "TypeScript Utils",
content: "They are great!",
authorId: "user_123"
// We cannot specify `id` or `createdAt` here!
};
Summary Comparison
Partial: "Make everything optional."Required: "Make everything mandatory."Pick: "Give me exactly these specific fields."Omit: "Give me everything except these specific fields."
Q: What is the difference between Structural Typing and Nominal Typing?
Answer:
This is one of the most fundamental design concepts in language architecture. The key difference dictates how the compiler determines if one type is "compatible" with another. TypeScript is a Structurally Typed language, whereas languages like Java, C#, and C++ are Nominally Typed.
1. Structural Typing (TypeScript)
Often referred to as "Duck Typing" (If it walks like a duck and quacks like a duck, it's a duck).
In a structurally typed language, two types are completely compatible if their internal structure (their shape) matches. The compiler doesn't care what the types are literally named or if they explicitly inherit from one another.
interface Ball { diameter: number; }
interface Earth { diameter: number; }
let myBall: Ball = { diameter: 10 };
let myEarth: Earth = { diameter: 12742 };
// 🤯 PERFECTLY VALID IN TYPESCRIPT!
myBall = myEarth;
myEarth = myBall;
Why? Because TypeScript only checks the structure. Both objects require a diameter property of type number. Since they both have it, they are structurally interchangeable.
2. Nominal Typing (Java, C#)
"Nominal" comes from the word for "name". In a nominally typed language, two types are only compatible if they share the exact same name or explicit inheritance path.
Even if two classes have the exact same shape, the compiler will refuse to mix them.
// Java Example (Nominal Typing)
class Ball { public int diameter; }
class Earth { public int diameter; }
Ball myBall = new Ball();
Earth myEarth = new Earth();
// ❌ COMPILE ERROR IN JAVA!
// "Incompatible types: Earth cannot be converted to Ball"
myBall = myEarth;
Why? Because Java strictly looks at the name of the types. An Earth is not literally a Ball, nor does class Earth implements Ball, so the Java compiler violently rejects it despite their identical shapes.
Which is better?
Neither is strictly better, they serve different paradigms:
- Structural Typing (TS) makes mocking, testing, and merging JSON data incredibly fast and flexible. You don't need massive inheritance trees.
- Nominal Typing (Java) provides tighter safety guarantees. You can't accidentally pass a
UserIdstring into a function expecting aPasswordstring if they are defined as distinct nominal classes, which prevents logical mix-ups.
Q: What is the difference between any, unknown, and never in TypeScript?
Answer:
These three special types form the extreme boundaries of TypeScript's type system. They dictate how strictly the compiler treats unknown or impossible data.
1. any (The Escape Hatch)
any completely disables the TypeScript compiler for that specific variable. You can assign anything to it, and you can perform any operation on it without the compiler complaining.
- When to use it: Almost never. It's an escape hatch. It's useful during migrations from legacy JS to TS, or when interacting with poorly typed third-party libraries.
let myVar: any = 5;
myVar = "Hello"; // Valid
myVar.doSomething(); // Valid (Compiler allows it, but it crushes at runtime!)
2. unknown (The Safe any)
unknown is the type-safe counterpart to any. Just like any, you can assign absolutely any value to unknown. However, you cannot access properties, call methods, or assign an unknown value to a strictly-typed variable until you prove what it is using a type guard.
- When to use it: When you are receiving data from an API, parsing JSON, or accepting external user input where you truly don't know the shape yet.
let safeVar: unknown = 5;
safeVar = { name: "Abhay" }; // Valid assignment
// ❌ COMPILE ERROR: Object is of type 'unknown'.
// safeVar.name = "John";
// ✅ We must prove what it is first (Type Narrowing)
if (typeof safeVar === "object" && safeVar !== null && "name" in safeVar) {
console.log(safeVar.name); // Now the compiler is happy
}
3. never (The Impossible Type)
never represents a state that should never physically occur in your code. It's the bottom-most type in TypeScript.
- When does it happen naturally?
- A function that always throws an error.
- A function with an infinite loop.
function throwCrashError(): never {
throw new Error("System Crash");
}
function infiniteLoop(): never {
while (true) {}
}
- When to use it purposefully? (Exhaustive Checking)
The most advanced and powerful use case for
neveris ensuring thatswitchstatements have handled every single possible case.
type Status = "pending" | "approved" | "rejected";
function handleStatus(status: Status) {
switch (status) {
case "pending": return "Wait";
case "approved": return "Good";
case "rejected": return "Bad";
default:
// If we ever add a new status to the type union but forget to
// add a case here, the compiler will try to assign it to this `never`
// variable and throw a compile error alerting us to the bug!
const exhaustiveCheck: never = status;
return exhaustiveCheck;
}
}
Summary
any: "I don't care about types. Turn off the compiler."unknown: "I don't know what this is yet, but force me to check before I touch it."never: "This code path represents an impossible state."
Q: What is Type Coercion in JavaScript?
Answer:
Type Coercion is the process of converting a value from one data type to another (such as a string to a number, or an object to a boolean). In JavaScript, type coercion can be either explicit or implicit.
Understanding this concept is crucial because JavaScript is a loosely-typed language, meaning it will aggressively try to coerce types silently in the background to execute operations, which often leads to bizarre bugs.
1. Explicit Type Coercion (Type Casting)
This happens when a developer intentionally writes code to convert one type to another using built-in global functions.
let val = "123";
// Explicitly converting a String to a Number
const num = Number(val);
const num2 = parseInt(val, 10);
// Explicitly converting a Number to a String
const str = String(456);
const str2 = (456).toString();
// Explicitly converting to a Boolean
const bool = Boolean(1); // true
const bool2 = !!0; // false
2. Implicit Type Coercion
This happens silently by the JavaScript engine when you apply operators to values of different types. The engine automatically decides what type the value should be to make the operation work.
The String Concatenation Trap (+)
If any operand of the + operator is a string, JavaScript coerces the other operands to strings and concatenates them.
console.log(1 + "2"); // "12" (Number 1 is coerced to String "1")
console.log("5" + true); // "5true"
The Numeric Conversion Trap (-, *, /)
Unlike +, math operators like -, *, and / strictly expect numbers. JavaScript will attempt to coerce strings into numbers to perform the math.
console.log("5" - "2"); // 3 (Strings are coerced to Numbers)
console.log("5" * "2"); // 10
console.log("10" - "a"); // NaN (Not-a-Number, because "a" cannot be cleanly converted)
3. Loose Equality (==) vs Strict Equality (===)
This is the most common interview question related to coercion.
===(Strict Equality): Checks if both the value AND the data type are identical. No type coercion is performed.==(Loose Equality): Checks if the values are equal after performing implicit type coercion if the types are different.
console.log(1 === "1"); // false (Number vs String)
console.log(1 == "1"); // true (The string "1" is coerced into a Number before comparing)
console.log(0 == false); // true
console.log("" == false); // true
console.log(null == undefined); // true
[!CAUTION] Always use strict equality (
===) in modern JavaScript/TypeScript to avoid catastrophic bugs caused by unpredictable implicit coercion rules!
4. Truthy and Falsy Values
When variables are used in a boolean context (like an if statement), JavaScript coerces them into booleans.
Every value in JS is considered "Truthy" (coerces to true) except for exactly 6 "Falsy" values:
false0(and-0)""(empty string)nullundefinedNaN
Q: How do generics work in TypeScript? Explain constraints, defaults, and inference.
Answer:
A generic is a type parameter — a placeholder for a type that the caller (or the compiler via inference) fills in. Generics let you write reusable code that preserves type information across boundaries instead of erasing it to any.
The Basic Shape
function identity<T>(value: T): T {
return value;
}
const n = identity(42); // T inferred as number, n: number
const s = identity('hi'); // T inferred as string, s: string
const x = identity<boolean>(true); // explicit
Without generics you'd return any, losing all downstream type safety.
Generic Interfaces and Types
interface Box<T> { value: T; }
type Pair<K, V> = { key: K; value: V };
const b: Box<number> = { value: 1 };
Constraints with extends
A constraint narrows what types are acceptable:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest('abc', 'de'); // ok — strings have length
longest([1, 2], [3, 4, 5]); // ok — arrays have length
longest(10, 20); // error — number has no `length`
Default Type Parameters
interface ApiResponse<T = unknown> {
ok: boolean;
data: T;
}
const r: ApiResponse = await fetch(...).then(r => r.json()); // data: unknown
Use unknown rather than any as the default — it forces consumers to narrow before using data.
Inference From Usage
function pluck<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
return keys.map(k => obj[k]);
}
const user = { id: 1, name: 'Ada', email: 'a@b.co' };
const fields = pluck(user, ['id', 'name']);
// fields: (number | string)[]
keyof T and indexed access T[K] are the bread and butter of generic library code (think Lodash's _.pick).
Generic Constraints That Chain
function copy<T, U extends Partial<T>>(target: T, patch: U): T & U {
return { ...target, ...patch };
}
Here U is constrained to be a subset of T's shape; the return type intersects both.
infer — Conditional Inference
Inside a conditional type, infer introduces a placeholder for the compiler to solve:
type ReturnOf<F> = F extends (...args: any[]) => infer R ? R : never;
type ElementOf<T> = T extends (infer U)[] ? U : never;
type R = ReturnOf<() => Promise<string>>; // Promise<string>
type E = ElementOf<number[]>; // number
Variance Pitfalls
TypeScript is mostly bivariant for function parameters (a famous historical decision), but strict mode (strictFunctionTypes) makes parameters contravariant. Watch out:
type Cmp<T> = (a: T, b: T) => number;
const animalCmp: Cmp<Animal> = ...;
const dogCmp: Cmp<Dog> = animalCmp; // ok in strict mode (contravariant)
// const animalCmp2: Cmp<Animal> = dogCmp; // error — dogCmp can't handle every Animal
[!NOTE] When generics get hairy, draw the contravariant/covariant arrows. Inputs flip, outputs preserve.
Common Patterns
Generic factory with branded return:
function tagged<Tag extends string>(tag: Tag) {
return <T>(value: T) => ({ tag, value } as const);
}
const userOf = tagged('user');
const u = userOf({ id: 1 }); // { readonly tag: 'user'; readonly value: {...} }
Higher-order generics for HOCs:
function withLogger<P>(Comp: React.ComponentType<P>): React.ComponentType<P> {
return (props: P) => {
console.log(Comp.name, props);
return React.createElement(Comp, props);
};
}
Anti-Patterns
<T extends any>— just write<T>.<T>you never actually use in the signature — drop it. The compiler will warn at the call site that nothing constrains it.- Forcing explicit type arguments where inference works fine — clutter.
Q: Explain conditional types in TypeScript. What is distribution and how does infer work?
Answer:
A conditional type has the form T extends U ? X : Y. It chooses between two type branches based on assignability — the type-level analogue of a ternary. Combined with infer, conditional types let you take types apart and rebuild them.
Basic Shape
type IsString<T> = T extends string ? true : false;
type A = IsString<'hi'>; // true
type B = IsString<42>; // false
Pattern Matching with infer
infer X introduces a new type variable inside the extends clause, captured from the structure being matched:
type ReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type FirstArg<F> = F extends (a: infer A, ...rest: any[]) => any ? A : never;
type ElementOf<A> = A extends (infer E)[] ? E : never;
infer only lives inside the true branch.
Distribution Over Unions
If the checked type (left of extends) is a naked type parameter and is a union, the conditional distributes over each member:
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>; // string[] | number[] (NOT (string | number)[])
To turn distribution off, wrap both sides in a tuple:
type ToArrayNoDist<T> = [T] extends [any] ? T[] : never;
type R2 = ToArrayNoDist<string | number>; // (string | number)[]
[!NOTE] Distribution is incredibly useful — it's how
Exclude,Extract,NonNullablefilter unions:type Exclude<T, U> = T extends U ? never : T; type Extract<T, U> = T extends U ? T : never;
Useful Built-Ins Reframed
type NonNullable<T> = T extends null | undefined ? never : T;
type Parameters<F> = F extends (...args: infer P) => any ? P : never;
type InstanceType<C> = C extends new (...a: any[]) => infer I ? I : any;
Real-World Example: API Endpoints
type Endpoints = {
'/users': { method: 'GET'; res: User[] };
'/users/:id': { method: 'GET'; res: User };
'/login': { method: 'POST'; body: { email: string; pwd: string }; res: { token: string } };
};
type ResponseOf<P extends keyof Endpoints> =
Endpoints[P] extends { res: infer R } ? R : never;
type BodyOf<P extends keyof Endpoints> =
Endpoints[P] extends { body: infer B } ? B : never;
type R = ResponseOf<'/users'>; // User[]
type B = BodyOf<'/login'>; // { email: string; pwd: string }
You can now write a fetch wrapper whose response type is keyed on the URL literal.
Recursive Conditional Types
type Flatten<T> = T extends (infer U)[]
? Flatten<U>
: T;
type X = Flatten<number[][][]>; // number
TypeScript 4.1+ allows recursion in conditional types up to an instantiation depth limit.
Distributing Object Properties
Combine conditional types with mapped types to filter keys by value type:
type KeysOfType<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
interface User { id: number; name: string; isAdmin: boolean; age: number; }
type NumericKeys = KeysOfType<User, number>; // "id" | "age"
Gotchas
any extends X ? A : Bresolves toA | B(notA).anyis contagious.never extends X ? A : Bisneverbecause distribution over an empty union gives nothing.- Order matters in chained conditionals — they're checked top-down.
Q: What are mapped types and template literal types in TypeScript? Show practical examples.
Answer:
A mapped type iterates over the keys of an existing type and produces a new type whose properties are derived from them. Template literal types let you compose string literal types like template strings at the type level. Together they enable advanced "type DSLs".
Mapped Type Basics
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type Optional<T> = {
[K in keyof T]?: T[K];
};
type Required<T> = {
[K in keyof T]-?: T[K];
};
The - modifier removes a property attribute (readonly or ?), the + (default) adds it.
Built-Ins Derived from Mapped Types
type Partial<T> = { [K in keyof T]?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Record<K extends PropertyKey, V> = { [P in K]: V };
Key Remapping with as
TypeScript 4.1 added as clauses for renaming keys:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User { id: number; name: string; }
type UG = Getters<User>;
// { getId: () => number; getName: () => string; }
as never filters keys out entirely:
type OmitByValue<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};
Template Literal Types
type Hello<W extends string> = `hello, ${W}`;
type H = Hello<'world'>; // "hello, world"
type EventName<T extends string> = `on${Capitalize<T>}`;
type E = EventName<'click'>; // "onClick"
Built-in string manipulation utilities: Uppercase, Lowercase, Capitalize, Uncapitalize.
Distributing Over Unions
Template literals distribute across union components:
type Size = 'sm' | 'md' | 'lg';
type Color = 'red' | 'blue';
type Class = `${Color}-${Size}`;
// "red-sm" | "red-md" | "red-lg" | "blue-sm" | "blue-md" | "blue-lg"
Real Example: CSS-Style Variant Props
type Spacing = 0 | 1 | 2 | 4 | 8;
type Side = 't' | 'r' | 'b' | 'l';
type MarginClass = `m${Side | ''}-${Spacing}`;
// "m-0" | "m-1" | ... | "mt-0" | "mr-0" | ...
You can now constrain a className prop to a finite, type-safe set.
Path Strings
type DeepKeys<T, P extends string = ''> = {
[K in keyof T & string]:
T[K] extends object
? `${P}${K}` | DeepKeys<T[K], `${P}${K}.`>
: `${P}${K}`;
}[keyof T & string];
interface Cfg { db: { host: string; port: number }; app: { name: string } }
type Paths = DeepKeys<Cfg>;
// "db" | "app" | "db.host" | "db.port" | "app.name"
This is the foundation behind type-safe get(obj, 'a.b.c') helpers in libraries like react-hook-form.
[!NOTE] Mapped + template literals + conditional types form a small, Turing-incomplete (but practical) compile-time language. Use them sparingly — every clever helper costs readability.
Common Patterns
1. "DeepReadonly":
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
2. "Snake to camel":
type Camel<S extends string> =
S extends `${infer A}_${infer B}${infer C}`
? `${A}${Uppercase<B>}${Camel<C>}`
: S;
type X = Camel<'first_name'>; // "firstName"
3. Filter keys by value:
type StringKeys<T> = { [K in keyof T]: T[K] extends string ? K : never }[keyof T];
When To Stop
If a mapped type takes more than two reads to understand, alias intermediate steps with descriptive names, or push the complexity into a code generator rather than the type system.
Q: How does TypeScript narrow types? Explain discriminated unions, exhaustiveness, and assertion functions.
Answer:
Narrowing is the process by which TypeScript refines a value's type as it flows through control-flow constructs. Every if, switch, typeof, instanceof, equality check, and user-defined predicate participates in the control-flow analysis the compiler performs.
The Built-In Narrowers
| Construct | Narrows |
|---|---|
typeof x === 'string' | primitives |
x instanceof Foo | classes |
'prop' in x | object shapes |
x == null / x === undefined | nullability |
Literal equality x === 'admin' | string/number literal members |
Array.isArray(x) | tuple/array vs object |
Truthiness if (x) | strips `0 |
function format(x: string | number | null) {
if (x == null) return '-';
// x: string | number
if (typeof x === 'string') return x.trim(); // x: string
return x.toFixed(2); // x: number
}
Discriminated Unions
The canonical pattern: every member has a literal-typed discriminant property.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rect'; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
case 'rect': return s.w * s.h;
}
}
The compiler can prove this switch is exhaustive.
Exhaustiveness with never
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return ...;
case 'square': return ...;
case 'rect': return ...;
default:
const _exhaustive: never = s; // compile error if a new variant is added
throw new Error(`Unhandled: ${(_exhaustive as any).kind}`);
}
}
When you add { kind: 'triangle'; ... } to Shape, the default branch's s is no longer never, so the assignment fails — the compiler points you at every place to update.
User-Defined Type Guards
A function with return type arg is Type is a predicate that narrows on truthy return:
function isString(x: unknown): x is string {
return typeof x === 'string';
}
function example(v: unknown) {
if (isString(v)) v.toUpperCase(); // v: string
}
For runtime-validated data (parsed JSON, API responses), pair a predicate with a schema validator (Zod, Valibot) and use it as the type-guard return.
Assertion Functions
asserts declarations narrow without returning a boolean — they throw on failure:
function assertString(v: unknown): asserts v is string {
if (typeof v !== 'string') throw new TypeError('not a string');
}
function use(v: unknown) {
assertString(v);
v.toUpperCase(); // v: string from here onward
}
Useful with frameworks that perform invariant checks (assert(x, msg)).
in Narrowing for Object Shapes
type Cat = { meow(): void };
type Dog = { bark(): void };
function speak(a: Cat | Dog) {
if ('meow' in a) a.meow();
else a.bark();
}
[!NOTE] Prefer discriminated unions over
in-based duck typing. Discriminants survive serialization, refactoring, and tooling far better.
Control-Flow Pitfalls
1. Aliasing breaks narrowing.
function f(o: { v?: string }) {
if (o.v !== undefined) {
const cb = () => o.v.trim(); // error: o.v could be undefined
}
}
The async callback runs later; the compiler conservatively widens. Capture the narrowed value first:
const v = o.v;
if (v !== undefined) const cb = () => v.trim();
2. let widening.
let s: 'a' | 'b' = 'a';
function set(x: string) { s = x; } // error — but the compiler may widen elsewhere
Use as const and readonly to keep literal types narrow.
3. unknown is the gate.
Treat external input as unknown and narrow explicitly. Using any defeats every checker in the codebase.
Q: What is declaration merging in TypeScript? When is it useful (and dangerous)?
Answer:
Declaration merging is TypeScript's ability to combine multiple separate declarations that share the same name into a single definition. The compiler unifies them in well-defined ways depending on what kind of declarations they are.
What Can Merge
| Combination | Result |
|---|---|
interface + interface | Members unioned |
namespace + namespace | Inner declarations merged |
namespace + class | Static side augmented with namespace members |
namespace + function | Function gains properties |
namespace + enum | Enum gains extra members or static helpers |
interface + class | Class's instance side gains extra members (declared but not impl) |
Module augmentation declare module | Extends an external module's types |
Cannot merge: type aliases with anything else, two class declarations, two enum value-defining declarations.
Interface Merging
interface User { id: number; }
interface User { name: string; }
const u: User = { id: 1, name: 'Ada' }; // both members required
This is the basis for declaration files where many libraries augment a shared type (e.g., adding fields to Express.Request).
Module Augmentation
The most common real-world use: extend types in a third-party library.
// express-augmentation.d.ts
import 'express';
declare module 'express' {
interface Request {
user?: { id: string; roles: string[] };
}
}
// later, in middleware
app.use((req, _res, next) => {
req.user = { id: '1', roles: ['admin'] }; // now type-checks
next();
});
[!NOTE] Module augmentation only works on the module's existing interfaces, not on its
typealiases. Library authors who export shapes astypecannot be augmented — a useful authoring distinction.
Global Augmentation
// globals.d.ts
declare global {
interface Window {
analytics?: { track(event: string): void };
}
}
export {}; // turns this file into a module so `declare global` is allowed
After this, window.analytics?.track('login') is typed throughout the codebase.
Function + Namespace (Static Properties)
function makeCounter() { return 0; }
namespace makeCounter {
export const version = '1.0';
}
makeCounter(); // function
makeCounter.version; // "1.0"
Class + Interface (Mixin-like)
class Greeter { hi() { return 'hi'; } }
interface Greeter { bye(): string } // declares an instance method
Greeter.prototype.bye = function () { return 'bye'; }; // implement at runtime
Useful when you mix runtime augmentation (plugins) with the type system.
Dangerous Patterns
1. Silent shadowing. Declaration merging is invisible at call sites — readers don't see which file added a property. Reviewers may miss security-critical changes (e.g., adding a headers field to a request).
2. Augmentation drift. When a library upgrades and renames Request to a type, every augmentation across your codebase breaks silently.
3. Polluting global types. Stick to module-scoped augmentation when you can; reserve declare global for true app-wide invariants like custom Window fields.
4. Order sensitivity. Most merges are order-independent, but namespace + class requires the class to be declared first. Mistakes produce confusing "duplicate identifier" errors.
Practical Checklist
- Keep augmentations in dedicated
*.d.tsfiles near the consumers. - Always
importthe library beforedeclare module— TypeScript needs to know the module exists. - Re-export
{}from adeclare globalfile to ensure it's parsed as a module. - Document why each augmentation exists; future maintainers won't otherwise know.
Q: What are Decorators in TypeScript/JavaScript?
Answer:
A Decorator is a special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter. Decorators use the form @expression, where expression must evaluate to a function that will be called at runtime with information about the decorated declaration.
Essentially, decorators are a way to use higher-order functions to wrap or modify the behavior of classes and their members in a declarative way. They are heavily used in frameworks like Angular and NestJS.
1. Requirements
To use decorators in TypeScript, you typically need to enable the experimentalDecorators compiler option in your tsconfig.json.
{
"compilerOptions": {
"target": "ES6",
"experimentalDecorators": true
}
}
2. Class Decorators
A class decorator is applied to the constructor of the class and can be used to observe, modify, or replace a class definition.
function Logger(constructor: Function) {
console.log(`Logging creation of class: ${constructor.name}`);
}
@Logger
class Person {
constructor(public name: string) {
console.log("Person initialized");
}
}
// Outputs:
// "Logging creation of class: Person" (At definition time)
3. Method Decorators
Method decorators are applied to methods, allowing you to observe, modify, or replace a method definition. They are extremely useful for tasks like logging, error handling, or binding this context.
It takes 3 arguments:
- Target: The prototype of the class.
- PropertyKey: The name of the method.
- Descriptor: The
PropertyDescriptorof the method.
function ReadOnly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.writable = false; // Prevents the method from being overridden
}
class MathOperations {
@ReadOnly
multiply(a: number, b: number) {
return a * b;
}
}
4. Decorator Factories
If you want to customize how a decorator is applied to a declaration by passing arguments to it, you can write a decorator factory. A decorator factory is simply a function that returns the actual decorator wrapper function.
function LogWithPrefix(prefix: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[${prefix}] Calling ${propertyKey}`);
return originalMethod.apply(this, args);
};
};
}
class Service {
@LogWithPrefix("DEBUG")
fetchData() {
// ...
}
}
[!NOTE] Decorators run when the class is defined, not when it is instantiated. The decorator functions execute sequentially during the file's initialization step.
Q: What are common causes of memory leaks in JavaScript? How do you debug them?
Answer:
A memory leak in JS is memory that the GC cannot reclaim because something still holds a reference to it — usually unintentionally. The V8 garbage collector frees objects only when nothing reachable from the root set (globals, the current call stack, active closures) refers to them.
Top Five Leak Patterns
1. Accidental Globals
function init() {
cache = {}; // missing `let`/`const`/`var` — attaches to globalThis
}
In strict mode this throws. In sloppy mode the variable lives forever on window. Always use strict mode and "use strict" modules.
2. Forgotten Timers and Intervals
class Widget {
constructor() {
setInterval(() => this.tick(), 1000); // keeps `this` alive forever
}
}
If the widget is removed from the DOM, the interval still references this, so the entire instance — and anything it transitively references — survives. Always store the handle and clearInterval on teardown.
3. Detached DOM Nodes Held by JS
const cached = document.getElementById('panel');
document.body.removeChild(cached); // removed from DOM, still referenced
The element is "detached" — out of the DOM tree but pinned in memory because a JS variable holds it. Common in SPA frameworks if you cache DOM nodes globally.
4. Listeners Without Cleanup
window.addEventListener('resize', this.onResize);
// component removed, but window still holds onResize -> the entire component
Always pair addEventListener with removeEventListener (or use AbortController.signal to detach all listeners atomically). React's useEffect cleanup is the canonical place.
5. Closures Pinning Large Scope
function attach(big) {
return function () { console.log('hi'); }; // doesn't use `big`...
}
V8 generally trims unused closure variables, but if any function in the same scope references big, all closures in that scope keep it alive. Move large values out of shared scope or null them when done.
Debug Workflow in Chrome DevTools
1. Memory tab -> "Heap snapshot" before suspect action
2. Perform action that should free memory (e.g., navigate away)
3. Force GC (trash-can icon)
4. Take a second snapshot
5. Compare snapshots -> filter by "Objects allocated between"
6. Look for retained "Detached" DOM nodes, your custom classes,
arrays growing unbounded
7. Click an object -> "Retainers" pane shows what is pinning it
[!NOTE] A single suspicious snapshot is not enough. Use the three-snapshot technique: idle, action, idle. Memory that grows and never shrinks across cycles is a leak.
Allocation Timeline & Performance Profile
The Allocation instrumentation on timeline highlights bars where new objects survived multiple GCs — strong leak indicators. The Performance panel's "JS heap" line should sawtooth around a stable baseline; a steadily rising baseline equals a leak.
Server-Side (Node.js)
- Use
--inspectand Chrome DevTools to take heap snapshots of a running Node process. - Tools:
heapdump,clinic.js doctor,node --heap-prof. - Watch out for module-level caches (
const cache = new Map()at top level) that grow unboundedly under load — bound them with LRU.
Prevention Checklist
- Use
WeakMap/WeakSetfor caches keyed by objects. - Use
AbortControllerfor fetch/event listeners with a clear lifetime. - Avoid global singletons holding per-request data.
- In React: cleanup in
useEffect, never store DOM refs in module scope. - Bound caches (LRU) and intern data deliberately.
Q: What's the difference between ESM and CommonJS modules in JavaScript? Why is interop tricky?
Answer:
CommonJS (CJS) is Node.js's original module system — synchronous require/module.exports. ES Modules (ESM) is the official JavaScript standard introduced in ES2015 — import/export, static structure, async loading. They look similar but behave very differently.
Key Differences
| Concern | CommonJS | ES Modules |
|---|---|---|
| Syntax | require / module.exports | import / export |
| Loading | Synchronous | Asynchronous, statically analysed |
| Bindings | Copy of values at require time | Live read-only bindings |
| Execution timing | When require runs | After full graph is parsed |
this at top | module.exports | undefined |
| Conditional load | if (x) require('y') works | Must use dynamic import('y') |
| File extension | .js / .cjs | .mjs / .js with "type":"module" |
| Tree-shaking | Hard (dynamic) | Excellent (static) |
Live Bindings vs Snapshots
// counter.cjs
let count = 0;
module.exports = { count, inc: () => count++ };
// app.cjs
const { count, inc } = require('./counter');
inc();
console.log(count); // 0 — destructured a snapshot of the number
// counter.mjs
export let count = 0;
export function inc() { count++; }
// app.mjs
import { count, inc } from './counter.mjs';
inc();
console.log(count); // 1 — `count` is a live binding
You cannot reassign an imported binding from the consumer side — they're read-only views into the exporter's scope.
Static vs Dynamic Structure
ESM import/export must be at the top level. The module graph is built before any code runs, enabling:
- Tree-shaking (unused exports dropped).
- Top-level circular dependency resolution via live bindings.
- Async preloading by the host.
For conditional/lazy loads, use import('module') which returns a Promise<Namespace>.
Interop Pain Points
// ESM consuming CJS
import pkg from 'lodash'; // works — default import binds to module.exports
import { map } from 'lodash'; // sometimes works, sometimes throws — depends on bundler/Node version
Node's strategy: when ESM imports CJS, module.exports becomes the default export, and named exports are detected with a static analyzer (cjs-module-lexer). It often gets named exports right, but not always.
// CJS consuming ESM — must be async!
const esm = await import('./mod.mjs');
You cannot require() an ESM module synchronously in older Node. Node 22+ adds limited synchronous require() of ESM under a flag, but writing async-safe code is still safer.
[!NOTE] If you're publishing a library, ship both formats with the
exportsfield:"exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" } }
__dirname and __filename
CJS has them as globals. ESM does not. Use:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Circular Dependencies
- CJS: partial exports are returned mid-evaluation, leading to surprising
undefinedvalues. - ESM: live bindings make most cycles work as long as you don't use the circular value before the cycle completes.
When to Pick Which (in 2026)
- New code: ESM unless you're inside a legacy CJS codebase.
- Libraries: ship dual builds and gate on the
exportsfield. - Tools (CLIs) that need synchronous startup: CJS is still simpler.
Q: What are generators and iterators in JavaScript? When are they useful?
Answer:
An iterator is any object with a next() method that returns { value, done }. An iterable is any object with a [Symbol.iterator]() method that returns an iterator. for...of, spread, destructuring, and Array.from all operate on iterables.
A generator function (function*) is the easiest way to build both — it returns an object that is both an iterator and an iterable, and it can yield values lazily.
Basic Generator
function* range(start, end, step = 1) {
for (let i = start; i < end; i += step) yield i;
}
for (const n of range(0, 5)) console.log(n); // 0 1 2 3 4
[...range(0, 3)]; // [0, 1, 2]
Execution pauses at each yield and resumes on the next .next() call — no buffering of the full sequence.
The Iterator Protocol — by Hand
const counter = {
[Symbol.iterator]() {
let i = 0;
return {
next() { return i < 3 ? { value: i++, done: false } : { value: undefined, done: true }; }
};
}
};
for (const n of counter) console.log(n); // 0 1 2
Why Generators Matter
1. Infinite/lazy sequences. You can describe streams that would never fit in memory.
function* naturals() { let n = 1; while (true) yield n++; }
function* take(iter, k) { for (const v of iter) { if (k-- <= 0) return; yield v; } }
[...take(naturals(), 5)]; // [1,2,3,4,5]
2. Pipeline composition. Each step yields one item at a time:
function* map(iter, f) { for (const v of iter) yield f(v); }
function* filter(iter, f) { for (const v of iter) if (f(v)) yield v; }
[...take(filter(map(naturals(), x => x * x), x => x % 2), 5)];
// [1, 9, 25, 49, 81]
3. Two-way communication via .next(value). A consumer can push data back into a paused generator. This is the foundation of older coroutine-style async (co, redux-saga).
function* dialog() {
const name = yield 'What is your name?';
yield `Hello, ${name}`;
}
const g = dialog();
g.next(); // { value: 'What is your name?', done: false }
g.next('Ada'); // { value: 'Hello, Ada', done: false }
g.next(); // { value: undefined, done: true }
Async Iterators (Symbol.asyncIterator)
Async generators (async function*) yield Promises and are consumed with for await...of:
async function* lines(stream) {
let buf = '';
for await (const chunk of stream) {
buf += chunk;
let i; while ((i = buf.indexOf('\n')) >= 0) {
yield buf.slice(0, i);
buf = buf.slice(i + 1);
}
}
if (buf) yield buf;
}
for await (const line of lines(fileStream)) processLine(line);
Streaming pagination, server-sent events, and reading large files become elegant one-shot loops.
Generators vs Async/Await
async/await = generator + auto-runner that awaits each yielded promise
generators = manual control flow primitive
Most application code should reach for async/await. Reach for generators when you need:
- Pull-based lazy streams.
- Custom iteration protocols.
- Pausable / cancellable coroutines.
[!NOTE] Generators are not parallel. They cooperatively pause; the engine is still single-threaded.
Termination
return(value)ends the generator early as ifreturn value;ran in place of the nextyield.throw(err)injects an error at the suspendedyield, allowing atry/catchinside the generator to handle it.
Real-World Sightings
- Node streams expose async iterators (
for await (const chunk of req)). - Redux-Saga uses synchronous generators for testable side-effect orchestration.
- Web Streams'
ReadableStreamworks withfor await.
Q: What are Web Workers and when should you use them?
Answer:
JavaScript on the main thread shares the same event loop as rendering and user input. CPU-bound work there freezes the UI. Web Workers are background OS threads exposed to JS via a message-passing API — separate event loop, separate global scope, no shared DOM access. They let you do heavy work without dropping frames.
Worker Flavors
| Kind | Lifetime | Shared between tabs/pages? | Typical use |
|---|---|---|---|
| Dedicated Worker | Same page as creator | No | Per-page CPU work |
| Shared Worker | As long as any tab open | Yes, same origin | Cross-tab coordination |
| Service Worker | Independent of pages | Yes | Offline, caching, push, fetch interception |
| Worklet (Audio, Paint) | Tied to specific subsystem | No | Realtime audio, custom paint, animations |
Hello, Worker
// main.js
const worker = new Worker(new URL('./hash.worker.js', import.meta.url), { type: 'module' });
worker.postMessage({ payload: bigArrayBuffer }, [bigArrayBuffer]); // transfer ownership
worker.onmessage = e => console.log('hash =', e.data);
// hash.worker.js
self.onmessage = async (e) => {
const digest = await crypto.subtle.digest('SHA-256', e.data.payload);
self.postMessage(new Uint8Array(digest));
};
Communication Model
main thread worker thread
+---------------------------------+ +----------------------------+
| window, document, DOM | | self (DedicatedWorkerScope)|
| | | no DOM, no window |
| worker.postMessage(data) ------>| structured clone or transfer ---> |
| | | self.onmessage |
| worker.onmessage <-------------| <-- self.postMessage(reply) |
+---------------------------------+ +----------------------------+
postMessage performs a structured clone of the payload (same algorithm as structuredClone). Pass the second arg, a list of transferable objects (ArrayBuffer, MessagePort, ImageBitmap), to give ownership without copying.
[!NOTE] A 100 MB
ArrayBuffersent without transfer is duplicated; the same buffer marked transferable is moved in O(1).
When to Reach for a Worker
- Image/video processing, parsing large CSV/JSON, encryption, compression.
- Pathfinding, simulation steps in a game.
- Spell-check, syntax highlighting on large documents.
- Anything that runs >16ms and blocks input.
When Not to Bother
- The DOM is involved — workers cannot touch it.
- Small CPU work (<5ms). Marshalling overhead dominates.
- I/O-bound work — the network is already async on the main thread.
SharedArrayBuffer + Atomics
For tight numerical loops, a SharedArrayBuffer lets multiple workers read/write the same memory. Atomics.wait / Atomics.notify provide low-level synchronization. Browsers require COOP/COEP cross-origin isolation headers to enable it.
const sab = new SharedArrayBuffer(1024);
const view = new Int32Array(sab);
// share `sab` to a worker via postMessage; reads/writes are visible to both
Worker Pool Pattern
Workers are not free — keep a pool sized to navigator.hardwareConcurrency and round-robin work over a job queue, instead of spawning per task.
const pool = Array.from({ length: navigator.hardwareConcurrency }, makeWorker);
let next = 0;
function dispatch(job) {
const w = pool[next = (next + 1) % pool.length];
return new Promise(resolve => {
w.onmessage = e => resolve(e.data);
w.postMessage(job);
});
}
Service Workers vs Web Workers
A Service Worker is a special worker that sits between the page and the network. It handles fetch events, manages caches, and powers PWAs. It is not for offloading CPU work — use a Dedicated Worker for that.
Q: What does the cleanup function in useEffect do, and when does it run?
Answer:
In React, the cleanup function is the function you return from within a useEffect callback. Its primary job is to clean up side effects (like subscriptions, timers, or event listeners) to prevent memory leaks and unexpected behavior.
useEffect(() => {
// 1. Setup the side effect
const timer = setInterval(() => console.log('Tick'), 1000);
// 2. Return the cleanup function
return () => {
clearInterval(timer);
};
}, []);
When does the cleanup function run?
React runs the cleanup function in two specific scenarios:
- Before an Effect runs again (on re-renders): If your
useEffectdependencies change and the component re-renders, React will first run the cleanup function from the previous render with the old state/props, and then run the newly updated effect. - When the component unmounts: Before the component is removed from the DOM entirely, React fires the cleanup function to destroy any lingering processes.
What happens if you forget to clean it up?
Forgetting to clean up effects (like event listeners, WebSockets, or setInterval timers) typically leads to Memory Leaks.
If the component unmounts but a setInterval is still running in the background, it will continue executing forever. If that interval tries to update a React state variable (e.g., setCount(c => c + 1)) on an unmounted component, React used to aggressively throw a memory leak warning:
"Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application."
Even worse, if the component is mounted and unmounted 10 times, you will now have 10 identical intervals running at the exact same time, severely degrading app performance and causing chaotic UI bugs.
Q: What's the difference between useMemo and useCallback? When are they not worth using?
Answer:
Both useMemo and useCallback are React Hooks used for performance optimization (memoization). They both take a dependency array and re-compute only when those dependencies change.
Their core difference lies in what they return:
useMemocaches the result of a function calculation.useCallbackcaches the function itself (the reference to the function).
Essentially, useCallback(fn, deps) is exactly equivalent to useMemo(() => fn, deps).
1. useMemo Use Cases
Use it to avoid repeating expensive, time-consuming calculations on every single render.
// ✅ GOOD USE CASE: Expensive calculation
const expensiveResult = useMemo(() => {
return bigArray.filter(item => item.value > threshold).map(heavyTransformation);
}, [bigArray, threshold]);
2. useCallback Use Cases
Use it when you need to keep a function reference completely stable between renders. This is almost exclusively needed when you are passing a callback function as a prop to a deeply nested child component that is wrapped in React.memo(), or if the function is used in a useEffect dependency array.
// ✅ GOOD USE CASE: Passing to a pure child component
const handleSubmit = useCallback((data) => {
api.submit(data);
}, []); // Function reference never changes
// MemoizedChild will NOT re-render since `handleSubmit` is identical across renders
return <MemoizedChild onSubmit={handleSubmit} />;
When is it NOT worth it? (The "Gotcha")
A massive red flag in interviews is developers who wrap every function in useCallback and every variable in useMemo.
React components are designed to tear down and rebuild extremely fast. Over-memoizing actually hurts performance because caching results and tracking dependency arrays takes up more memory and execution time than just letting React do its normal job.
DON'T use them when:
- The operation is cheap: Doing basic math or string concatenation? Don't use
useMemo.const fullName = firstName + lastNameis thousands of times faster to recalculate than wrapping it inuseMemo. - Passing to native HTML elements: Wrapping a function in
useCallbackjust to pass it to<button onClick={handleClick}>is completely useless. Native DOM elements don't care about reference equality. - The child isn't memoized: If you pass a
useCallbackfunction to a custom<Button>component, but that<Button>component is NOT wrapped inReact.memo, the child is going to re-render anyway. You just wasted memory caching the function!
[!TIP] Interview Rule of Thumb: Write the code without
useMemooruseCallbackfirst. Only add them if you explicitly identify a performance bottleneck (like a 500ms lag on typing) or auseEffectinfinite loop caused by an unstable function dependency.
Q: What is the difference between Controlled and Uncontrolled Components in React?
Answer:
This question fundamentally asks about how forms and input data are handled within a React application. The difference lies in who controls the current state of the data: React, or the DOM itself.
1. Controlled Components (React handles the state)
In a controlled component, the form data is handled strictly by the React component. React acts as the "Single Source of Truth."
You track the input's value using useState and update it dynamically using an onChange handler. The input element only displays what the React state tells it to display.
import { useState } from 'react';
function ControlledInput() {
const [name, setName] = useState('');
const handleChange = (e) => {
// We can format, validate, or intercept the typing instantly!
setName(e.target.value.toUpperCase());
};
return (
<form>
<input
type="text"
value={name} // Driven strictly by React
onChange={handleChange}
/>
</form>
);
}
Pros:
- Instant validation (disabling buttons if input is invalid).
- Enforcing input formats (like forcing uppercase, as shown above).
- Dynamic inputs (conditionally showing other fields based on this input).
2. Uncontrolled Components (The DOM handles the state)
In an uncontrolled component, form data is handled directly by the DOM, mimicking traditional HTML behavior.
Instead of tracking every single keystroke with state, you use a ref (useRef) to grab the data directly from the DOM only when you actually need it (usually upon form submission).
import { useRef } from 'react';
function UncontrolledInput() {
const nameRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
// We ONLY access the value right when the user clicks submit
alert(`Submitted: ${nameRef.current.value}`);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
ref={nameRef} // Tells React to track this DOM node
defaultValue="Abhay" // Used instead of 'value' for initial state
/>
<button type="submit">Submit</button>
</form>
);
}
Pros:
- Less code (no
useStateoronChangeboilerplate). - Faster execution (typing does not trigger a React component re-render).
- Easier integration with non-React third-party APIs or vanilla JS libraries that expect direct DOM manipulation.
Summary
- Use Controlled for real-time validation, dynamic UI changes based on input, and strictly keeping React as the source of truth. (This is generally the recommended approach in React).
- Use Uncontrolled if the form is incredibly simple or if you need to wrap legacy vanilla JS components.
Q: Why does React need key props when rendering lists? Why is using the array index a problem?
Answer:
This question dives into the core of how React optimizes UI updates, a process known as Reconciliation (or the "Diffing" algorithm).
Why do we need Keys?
When a React component re-renders, React compares the newly generated virtual DOM tree with the old virtual DOM tree to figure out what precisely changed.
When it comes to rendering looping lists of elements (e.g. using .map()), React needs a way to instantly identify which specific items have been added, removed, reordered, or modified. The key is a unique identifier that tells React the specific identity of that element across renders.
Without keys, React would have to destroy and recreate the entire list from scratch if anything shifted, which is terrible for performance.
The Problem with using Array Index as the Key
Often, developers default to using the array index (item, index) as a key when they don't have a unique ID:
// 🚨 Bad Practice (for dynamic lists)
{items.map((item, index) => (
<ListItem key={index} item={item} />
))}
If the list is completely static (never sorted, filtered, or prepended to), using the index is perfectly fine. However, if the list is dynamic, using the index causes severe bugs.
Here is why: An array index only represents the item's current position in the array, not its true identity.
Imagine you have a list of three text-input fields loaded from state:
["Apple", "Banana", "Cherry"]- They are rendered with keys
0,1,2. - You type "Green" into the "Apple" input.
- You click a button to delete "Apple" from the array.
The remaining array is now ["Banana", "Cherry"].
- "Banana" shifts from index
1to index0. - React sees an element with key
0in the old tree, and an element with key0in the new tree. - React mistakenly thinks this is the exact same underlying DOM element. Instead of deleting the first input, it recycles it! The input field that used to say "Apple" (and the word "Green" you typed into it) will now simply have its label changed to "Banana".
The Solution
Always use a unique, stable identifier from your data payload (like a database ID or UUID) as the key.
// ✅ Good Practice
{items.map(item => (
<ListItem key={item.databaseId} item={item} />
))}
This guarantees that no matter how the items are sorted, added, or removed, React knows exactly which DOM node maps to which piece of data.
Q: When should you use React Context vs a state management library like Redux/Zustand?
Answer:
React.Context and external state managers (Redux Toolkit, Zustand, Jotai, Recoil) solve overlapping but distinct problems. Context is a dependency injection mechanism, not a state engine; treating it as one leads to performance issues.
What Context Actually Does
Context propagates a value down the tree without prop drilling. When the Provider's value reference changes, every consumer re-renders — there is no built-in selector or partial subscription.
const ThemeCtx = createContext('light');
function App() { return <ThemeCtx.Provider value="dark"><Page/></ThemeCtx.Provider>; }
function Btn() { const theme = useContext(ThemeCtx); /* re-renders on any value change */ }
What State Libraries Add
| Feature | Context | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|
| Selector-based subscriptions | No | Yes (useSelector) | Yes (useStore(s => s.x)) | Atomic |
| Reference equality opt-in | No | Yes | Yes (shallow) | N/A |
| Time-travel / DevTools | No | Yes | Yes (via DevTools middleware) | Limited |
| Middleware (logging, async) | No | Yes (thunks, RTK Query) | Yes | No (uses atoms + effects) |
| Boilerplate | None | Higher | Low | Low |
| Bundle size | 0 | ~10 KB | ~1 KB | ~3 KB |
Rule of Thumb
Is the value rarely-changing config (theme, locale, current user, feature flags)?
|-- Yes --> Context
|
Is the value updated frequently or read by many components?
|-- Yes --> external store (Redux/Zustand/Jotai)
|
Is it server data (lists, entities, requests)?
|-- Yes --> data-fetching cache (TanStack Query, RTK Query, SWR)
[!NOTE] The single most common React perf bug in 2025 codebases: pushing rapidly-changing values (form input strings, mouse positions) through Context. Every keystroke re-renders the entire subtree under the Provider.
Splitting Context to Mitigate
If you must use Context for a changing value, split by change rate:
const StateCtx = createContext(null); // changes often
const DispatchCtx = createContext(null); // stable reference
<StateCtx.Provider value={state}>
<DispatchCtx.Provider value={dispatch}> {/* never re-renders */}
{children}
</DispatchCtx.Provider>
</StateCtx.Provider>
Components that only call dispatch subscribe only to DispatchCtx and avoid the noise.
Redux Toolkit — When It Earns Its Keep
- Complex domain logic with many reducers, selectors, and asynchronous workflows.
- Need for serializable state, replayable actions, audit logs.
- Team is large and benefits from explicit action contracts.
- Server cache management with RTK Query.
const slice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
add(state, { payload }) { state.items.push(payload); },
remove(state, { payload }) {
state.items = state.items.filter(i => i.id !== payload);
},
},
});
Immer powers the "mutate state" syntax safely.
Zustand — Pragmatic Middle Ground
import { create } from 'zustand';
const useCart = create((set) => ({
items: [],
add: (item) => set(s => ({ items: [...s.items, item] })),
}));
function Count() {
return <span>{useCart(s => s.items.length)}</span>; // re-renders only when count changes
}
No Provider needed; selectors give fine-grained subscriptions; full TS inference.
Jotai / Recoil — Atomic State
State is decomposed into small atoms. Components subscribe to only the atoms they read, getting maximally fine-grained re-renders.
const countAtom = atom(0);
function Counter() { const [n, set] = useAtom(countAtom); }
Useful for highly interactive UIs (graph editors, design tools) where the bottleneck is propagating updates.
Server State Is Different
Caching, refetching, mutations, optimistic updates, and revalidation belong in TanStack Query / SWR, not in Redux. Mixing the two leads to duplicated, drifting data. Treat the server as the source of truth and the client store for truly client-only state (UI flags, drafts, selection).
Q: What are React Server Components? How do they differ from SSR and from client components?
Answer:
React Server Components (RSC) are components that run only on the server and never ship to the client. They can read databases, hit secrets, or perform heavy work, then send a serialized UI tree to the browser. They are not the same as Server-Side Rendering — SSR runs a normal client component on the server to generate initial HTML; RSC defines a new component kind that lives entirely server-side.
Mental Model
┌────────────────────────┐ ┌──────────────────────────┐
│ Server │ │ Browser │
│ │ │ │
│ Server Component │──────│► Streams RSC payload │
│ (DB, fs, env) │ │ (JSON-ish tree) │
│ │ │ │ │
│ ├─ embeds Client │ │ Client Component │
│ │ Component refs │ │ hydrates, runs JS │
│ ▼ │ │ │
│ HTML (optional) │──────│► initial paint │
└────────────────────────┘ └──────────────────────────┘
Key Properties of Server Components
- No browser APIs. No
useState,useEffect,useRef, no event handlers, nowindow. - Async by default. A Server Component can be an
asyncfunction andawaitdirectly. - Zero JS to the client. Their code stays on the server; only their rendered output is serialized.
- Can import server-only deps. Database drivers, secrets, big formatting libs — none of it ships to the browser.
// app/posts/page.tsx — server component (default in Next App Router)
import { db } from '@/lib/db';
import { PostList } from './PostList'; // could be client or server
export default async function Page() {
const posts = await db.post.findMany();
return <PostList posts={posts} />;
}
Client Components
Marked explicitly with "use client" at the top of the file. They are normal React components that ship JS and hydrate in the browser.
'use client';
import { useState } from 'react';
export function LikeButton({ postId }) {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Likes: {count}</button>;
}
Composition Rules
- Server components can render client components.
- Client components cannot import server components directly — but they can receive them as
childrenprops.
// server.tsx
<ClientShell>
<ExpensiveServerWidget /> {/* passed as children — allowed */}
</ClientShell>
This pattern keeps client bundles small while reusing server-rendered subtrees inside interactive shells.
Differences vs SSR
| Aspect | Traditional SSR | React Server Components |
|---|---|---|
| Code on client | Yes (full component code) | No (server components excluded) |
| Output to browser | HTML | Serialized RSC payload (+ HTML) |
| Hydration | Required for interactivity | Only client components hydrate |
| Data fetching | In getServerSideProps/etc. | Inline via await |
| Streaming | Optional, manual | Built-in via Suspense |
| Bundle impact | Heavy with framework code | Slimmer — server-only libs excluded |
[!NOTE] Server Components do not replace SSR; they complement it. Frameworks like Next.js App Router run both: the server component tree is rendered, server-side, and the parts marked
"use client"are SSR'd into HTML for first paint and then hydrated.
Server Actions
Server Actions ("use server") let a client component call a server function as if it were local. The runtime serializes the call, executes it on the server, and returns a result.
'use server';
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}
'use client';
import { deletePost } from './actions';
<form action={deletePost.bind(null, post.id)}>...</form>
No fetch handler, no API route — the framework wires up the RPC for you.
When to Reach for RSC
- Large static or read-heavy pages (dashboards, content sites).
- Pages that need access to databases/secrets but minimal interactivity.
- Reducing JS bundle size for first interaction.
When to Stay Client-Side
- Highly interactive widgets (editors, drag-and-drop, games).
- Anything requiring browser APIs (Canvas, WebGL, geolocation).
- Realtime data via WebSockets (still possible via a server-pushed bridge but more involved).
Common Pitfalls
- Forgetting
"use client"and then trying to useuseState— runtime error in development. - Importing a server component from a client component — the compiler will error or the runtime will leak server code.
- Mutating shared module state on the server — RSCs run per request; module-level singletons must be thread-safe or per-request.
Q: How does React's reconciliation algorithm work? What is the Virtual DOM and how does Fiber change things?
Answer:
Reconciliation is the process of figuring out what changed between two renders and applying the minimum set of DOM mutations. React describes the desired UI as a tree of plain objects (the Virtual DOM / "React elements"), diffs the new tree against the previous one, and emits the diff to the host environment (DOM, native, canvas).
The Diff Heuristic (O(n))
A full tree diff is O(n³). React makes it linear by leaning on two assumptions:
- Two elements of different types produce different trees. A
<div>swapped for a<span>is treated as "tear down and rebuild", not "compare children". - Stable keys identify siblings across renders. Without keys, React diffs by index; with keys, it matches by identity.
Old tree New tree
<ul> <ul>
<li key="a"/> match→ <li key="b"/>
<li key="b"/> <li key="a"/>
<li key="c"/>
With keys, React detects: keep a and b, just move them, insert c. Without keys (or with key={index}), it would re-render each <li> and rebuild internal state.
Same-Type Component Update
<Counter count={1} /> → <Counter count={2} />
React keeps the instance, updates props, calls the component function, diffs its output. Local state (useState) is preserved.
Element-Type Change Tears Down State
{loading ? <Spinner/> : <Form/>}
When loading flips, React unmounts Spinner (running its cleanups) and mounts a fresh Form. Any state inside Form from a previous show is lost — it's a brand new component instance.
Why Keys Matter So Much
Wrong keys are the most common subtle React bug. Using array index as key in a reorderable list causes:
- Inputs lose their values when items are inserted at the top.
- Animations attach to the wrong element.
useEffectcleanup runs for the wrong identity.
[!NOTE] A good key is a stable, unique identifier of the underlying data. Database IDs are ideal. Indexes are fine only if the list is append-only and items have no internal state.
Fiber — Incremental Reconciliation
Prior to React 16, reconciliation was a synchronous recursive walk that blocked the main thread for large trees. Fiber rewrote this into a cooperative, interruptible scheduler.
Key ideas:
- Each component instance is a fiber node in a linked-list tree.
- Work is split into units that can be paused, resumed, or discarded.
- A double-buffered tree ("current" and "work-in-progress") lets React build the next render without disturbing the current one.
- The scheduler assigns priorities — urgent updates (input, hover) preempt low-priority ones (data fetch results, transitions).
Render Phase (interruptible)
┌──────────────────────────────────────────┐
│ begin work on each fiber │
│ build work-in-progress tree │
│ collect side-effects in a list │
└──────────────────┬───────────────────────┘
│
v
Commit Phase (synchronous, atomic)
┌──────────────────────────────────────────┐
│ apply DOM mutations │
│ run layout effects (useLayoutEffect) │
│ schedule passive effects (useEffect) │
└──────────────────────────────────────────┘
Concurrent Features Build on Fiber
useTransitionmarks a state update as non-urgent — React can pause it to keep input responsive.useDeferredValuelets a heavy subtree lag behind a typing-driven input.Suspenselets a component "throw" a promise; React renders fallback content until the promise resolves, then continues.
Render vs Commit
- The render phase calls your function components — it may run multiple times, can be aborted, must be pure.
- The commit phase applies the result. Side effects belong here (
useEffect,useLayoutEffect).
Common Perf Pitfalls That Fight the Reconciler
- New inline objects/functions every render that are then passed as props with
useMemo-derived deps (defeats memoization). - Conditional rendering that swaps component types (
AvsB) when re-using a single component with a prop would preserve state. - Putting whole-app state in a Context provider's
valuerecreated each render — every consumer re-renders.
What Reconciliation Does Not Do
- It does not minimize re-renders — it minimizes DOM mutations. Components still call your function every render unless memoized.
- It does not deduplicate state updates — multiple
setStatecalls in the same event are batched, but state is not "diffed" before commit.
Q: How do Suspense and Error Boundaries work in React? When should you use each?
Answer:
Suspense and Error Boundaries are React's two top-down primitives for handling non-local UI states — loading and errors — in a declarative, composable way. Both rely on a component "throwing" something during render that an ancestor catches.
Error Boundaries — Catching Errors
An Error Boundary is any class component that implements componentDidCatch or getDerivedStateFromError. It catches errors thrown by descendants during rendering, lifecycle methods, and constructors.
class Boundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
componentDidCatch(error, info) { logErrorToService(error, info); }
render() {
return this.state.error
? <Fallback error={this.state.error}/>
: this.props.children;
}
}
<Boundary><Dashboard /></Boundary>
What it does not catch:
- Errors in event handlers — wrap them with
try/catchmanually. - Errors inside
setTimeout/Promisecallbacks unless they bubble back into render. - Errors during SSR (use the streaming-aware variant on the server).
- Errors in the boundary itself.
[!NOTE] There is no hook equivalent yet. Use
react-error-boundaryfor the ergonomic functional API — it wraps the class internally.
Suspense — Catching Loading
Suspense catches a thrown promise from a child. While that promise is pending, the nearest ancestor <Suspense> renders its fallback.
<Suspense fallback={<Spinner/>}>
<UserProfile id={42} />
</Suspense>
Inside UserProfile, something — typically a data-fetching library (use in React 19, TanStack Query, Relay, Apollo, Next.js fetching) — suspends by throwing the in-flight promise. React shows the fallback, awaits the promise, retries the render.
React 19's use Hook
function UserProfile({ id }) {
const user = use(fetchUser(id)); // suspends until the promise resolves
return <h1>{user.name}</h1>;
}
use(promise) integrates promises directly into render. Combined with React Server Components, it removes the need for useEffect + useState loading patterns.
Composition
The two boundaries compose with each other and with each other repeatedly:
<ErrorBoundary fallback={<Crashed/>}>
<Suspense fallback={<PageSpinner/>}>
<Layout>
<ErrorBoundary fallback={<WidgetCrashed/>}>
<Suspense fallback={<WidgetSpinner/>}>
<RemoteWidget />
</Suspense>
</ErrorBoundary>
</Layout>
</Suspense>
</ErrorBoundary>
This produces graceful, fine-grained loading and isolated failure regions.
┌─────────── ErrorBoundary (page-level) ──────────────┐
│ ┌──────── Suspense (page-level) ────────────────┐ │
│ │ Layout │ │
│ │ ┌────── ErrorBoundary (widget) ─────────┐ │ │
│ │ │ ┌──── Suspense (widget) ──────────┐ │ │ │
│ │ │ │ RemoteWidget │ │ │ │
│ │ │ └─────────────────────────────────┘ │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Streaming SSR
In React 18+ on the server, Suspense becomes the seam for streaming HTML. The shell renders synchronously; suspended subtrees render later and are streamed in <template> chunks that replace their fallback on the client. This unlocks TTFB-fast pages even with slow data.
useTransition + Suspense
To avoid showing a fallback for a fast state change (e.g., navigating between tabs), mark the update as a transition:
const [isPending, startTransition] = useTransition();
startTransition(() => setTab('orders'));
React keeps the old UI visible until the new one is ready (or shows the fallback only if it takes too long). Without this, every navigation would flash a spinner.
Pitfalls
- Throwing in event handlers is not caught by Error Boundaries. Wrap with
try/catchor surface into state. - Suspense without a data library that opts in does nothing — vanilla
fetch + useStatenever suspends. - Nested Suspense fallbacks cascade. If you don't want a parent fallback to hide everything, push the fallback further down.
- Hydration errors (mismatched server vs client output) cannot be recovered by Error Boundaries until React 19's recovery mode.
Quick Decision Guide
Need to show a spinner / skeleton while data loads? -> Suspense
Need to recover from unexpected exceptions? -> Error Boundary
Both? -> Nest them: ErrorBoundary > Suspense > content
Q: What are custom React hooks and what are the rules of hooks? Show examples of useful custom hooks.
Answer:
A custom hook is any JavaScript function whose name starts with use and which calls other hooks. It is a mechanism for reusing stateful logic (not UI) between components, replacing the older mixin/HOC/render-prop patterns.
The Rules of Hooks
- Call hooks at the top level. Never inside loops, conditions, or nested functions.
- Call hooks only from React function components or other custom hooks — not from regular functions or class methods.
Why? React identifies which state slot belongs to which useState by call order, not by name. Any conditional or out-of-order call shifts subsequent slots and corrupts state. The ESLint plugin eslint-plugin-react-hooks enforces both rules.
// Wrong
function Comp({ enabled }) {
if (enabled) {
const [v, setV] = useState(0); // call order changes when `enabled` flips
}
}
// Right
function Comp({ enabled }) {
const [v, setV] = useState(0);
if (!enabled) return null;
// use v
}
Custom Hooks Are Just Functions
Two components calling the same custom hook get independent state. The hook is the recipe; the components are the instances.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(o => !o), []);
return [on, toggle];
}
Useful Custom Hooks
1. useDebouncedValue — debounce a value:
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
2. useLocalStorage — sync state to localStorage:
function useLocalStorage(key, initial) {
const [val, setVal] = useState(() => {
try { return JSON.parse(localStorage.getItem(key)) ?? initial; }
catch { return initial; }
});
useEffect(() => { localStorage.setItem(key, JSON.stringify(val)); }, [key, val]);
return [val, setVal];
}
3. usePrevious — read the previous value of a prop or state:
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; });
return ref.current;
}
4. useEventListener — attach a listener with auto-cleanup:
function useEventListener(target, event, handler) {
const saved = useRef(handler);
useEffect(() => { saved.current = handler; }, [handler]);
useEffect(() => {
const fn = e => saved.current(e);
target.addEventListener(event, fn);
return () => target.removeEventListener(event, fn);
}, [target, event]);
}
The ref trick is React's idiomatic way to read the latest handler from inside an effect without re-binding the listener on every render.
5. useIsMounted — guard against setState after unmount:
function useIsMounted() {
const ref = useRef(false);
useEffect(() => {
ref.current = true;
return () => { ref.current = false; };
}, []);
return () => ref.current;
}
6. useFetch (simplified) — but prefer TanStack Query for real apps:
function useFetch(url) {
const [state, set] = useState({ status: 'idle', data: null, error: null });
useEffect(() => {
const ctrl = new AbortController();
set(s => ({ ...s, status: 'loading' }));
fetch(url, { signal: ctrl.signal })
.then(r => r.json())
.then(data => set({ status: 'success', data, error: null }))
.catch(error => { if (error.name !== 'AbortError') set({ status: 'error', data: null, error }); });
return () => ctrl.abort();
}, [url]);
return state;
}
[!NOTE] Always provide an abort/cleanup path in custom hooks that start async work — otherwise unmounting during a request leaks setState calls and listeners.
Composition
Custom hooks compose just like functions. A useUser can call useFetch, which calls useEffect, etc. The state-slot mechanism preserves correctness as long as the call order within each invocation is deterministic.
Common Mistakes
- Returning new object references each render — destructured consumers re-render unnecessarily. Wrap return objects in
useMemoif components destructure them and rely on referential equality. - Calling hooks inside callbacks —
onClick={() => useState(...)}is illegal. - Forgetting dependencies — let the lint rule auto-fix; ignoring it leads to stale closures.
- Hidden side effects in the body — anything not wrapped in
useEffectruns during render and can cause double-execution in StrictMode.
Q: How do you optimize React rendering performance? When should you reach for memo, useMemo, useCallback?
Answer:
React's default behavior is to re-render a component whenever its parent re-renders. Most of the time this is fast — diffing virtual DOM is cheap. Optimization should be measured first, not preemptive. Premature useMemo everywhere is itself a perf bug (extra closures, dep arrays, complexity).
Step Zero: Measure
Use the React DevTools Profiler tab. Record an interaction, look at the flamegraph, and find components whose render time is large or whose render count is unexpected. The browser's Performance tab gives you long-task and scripting cost.
The Three Memoization Tools
| Tool | What it does | Cost |
|---|---|---|
React.memo | Skips a child render if props are shallow-equal | Comparison + extra HOC |
useMemo | Caches a value (object/array/computed result) | Comparison + closure |
useCallback | Caches a function identity | Same as useMemo |
useMemo and useCallback are about stabilizing references to satisfy React.memo or hook dependency arrays — they are not magic speed boosters.
When React.memo Actually Helps
A child wrapped in memo only avoids work if its props are referentially stable across parent renders. So memo and useCallback/useMemo come as a package:
const Row = React.memo(function Row({ item, onSelect }) { /* ... */ });
function List({ items, onSelect }) {
const onRowSelect = useCallback(id => onSelect(id), [onSelect]);
return items.map(it => <Row key={it.id} item={it} onSelect={onRowSelect} />);
}
Without useCallback, onRowSelect would be a new function each render and memo would never bail out.
[!NOTE] If the only prop is
key + primitive,memolikely pays off. If props include big objects rebuilt every render, you needuseMemoon them too — otherwisememodoes nothing.
Avoid Unnecessary Re-renders At The Source
Before reaching for memo, ask: can I avoid the re-render entirely?
- Move state down. A parent that owns input state re-renders the whole subtree on every keystroke. Move the input + state into a child.
- Lift props as
children. Components passed viachildrento a parent don't re-render when that parent re-renders, because thechildrenelement reference is preserved by the grandparent.
If<Layout><ExpensiveTree/></Layout>Layoutupdates its own state,ExpensiveTreeskips re-render becausechildrenis the same element. - Split contexts by change rate (see Context vs Redux). High-frequency values shouldn't share a provider with low-frequency ones.
Big Lists — Virtualization
Rendering thousands of rows always hurts. Use windowing (react-virtual, react-window) to render only what's visible:
viewport
┌──────────────┐
│ visible rows │ ← rendered (~20 nodes)
├──────────────┤
│ spacer │ ← height matches off-screen rows
└──────────────┘
DOM nodes drop from O(n) to O(viewport size) regardless of list length.
Other Levers
- Code splitting with
React.lazy(() => import('./Big'))+<Suspense>to defer heavy modules until needed. - Concurrent transitions — wrap heavy state updates in
startTransitionso input stays responsive. useDeferredValue— let a slow subtree lag behind a fast input.- Move work off main thread with a Web Worker when CPU-bound.
- Avoid layout thrash — batch DOM reads/writes; don't toggle classes inside a tight loop that triggers reflow.
Common Anti-Patterns
// 1. useMemo on a primitive — useless
const total = useMemo(() => items.length, [items]);
// 2. useCallback that's never compared — useless
const onClick = useCallback(() => doThing(), []);
return <button onClick={onClick}>x</button>;
// 3. memo with new prop objects every render — useless
<MemoChild config={{ size: 10 }} /> // {} is new each time
Decision Tree
Profiler shows slow component?
|
+-- yes -> Can I reduce work or split it? (move state down, children prop)
| |
| +-- still slow -> memoize stable props (useCallback/useMemo)
| + wrap child in React.memo
|
+-- no -> stop optimizing
React Compiler (2025+)
The React Compiler (formerly "React Forget") auto-memoizes components and dependencies at compile time. With it enabled, hand-written useMemo/useCallback become largely unnecessary. Adopt it gradually and remove redundant memoization once you've verified equivalent behavior in production.
Q: How do you reduce the JavaScript bundle size of a web app? Walk through your toolbox.
Answer:
Bundle size directly drives time-to-interactive, especially on slow networks and low-end mobile. The toolbox is layered: measure first, then attack imports, then build configuration, then runtime patterns.
Measure First
webpack-bundle-analyzer/rollup-plugin-visualizer/vite-bundle-visualizer— flame view of what's in each chunk.source-map-explorer— works on any final bundle with a source map.- Lighthouse + WebPageTest — real-world TTI, LCP, and JS execution time.
npx bundlephobia <pkg>— pre-flight check before adding a dependency.
Layer 1: Pick Smaller Dependencies
| Heavy | Lighter alternative |
|---|---|
| moment (~70 KB) | date-fns (tree-shakable) or Intl.DateTimeFormat |
| lodash full import | lodash-es + named imports, or one-off helpers |
| axios | native fetch for most cases |
recharts/chart.js | uPlot for line/bar use cases |
jquery | DOM APIs are plenty in 2026 |
| Redux + RTK | Zustand/Jotai if features fit |
Layer 2: Tree-Shaking Hygiene
For tree-shaking to work, packages and your own code must use ES modules with side-effect-free imports.
// package.json — tell the bundler files have no side effects
{
"sideEffects": false,
// or, scoped:
"sideEffects": ["*.css", "./polyfills.js"]
}
In application code, prefer named imports from ESM packages:
// good: only debounce ends up in the bundle
import { debounce } from 'lodash-es';
// bad: pulls in the entire library
import _ from 'lodash';
const f = _.debounce(...);
Layer 3: Code Splitting
Route-based:
const Settings = React.lazy(() => import('./Settings'));
<Suspense fallback={<Spinner/>}>
<Route path="/settings" element={<Settings/>}/>
</Suspense>
Component-based for heavy widgets used rarely (rich-text editor, chart):
const Editor = React.lazy(() => import('./Editor'));
Vendor splitting — keep stable third-party code in a long-cached chunk so app updates don't invalidate it.
Layer 4: Dynamic Imports for Conditional Code
async function exportCsv(rows) {
const { stringify } = await import('csv-stringify/browser/esm');
return stringify(rows);
}
The CSV library only ships when a user actually exports.
Layer 5: Minification & Compression
- Terser/SWC/esbuild for minification — fine-tune
pure_funcs,passes, drop console. - Brotli static compression at the CDN edge often beats gzip by 15-20% on JS.
- Preload critical chunks (
<link rel="modulepreload">) and defer non-critical ones.
Layer 6: Polyfill Strategy
Sending a 100 KB pile of polyfills to a modern Chrome user is wasteful. Use differential serving: ship a modern bundle (<script type="module">) and a legacy fallback (<script nomodule>).
<script type="module" src="/app.modern.js"></script>
<script nomodule src="/app.legacy.js" defer></script>
Tools: Vite legacy plugin, esbuild target, @babel/preset-env with browserslist.
[!NOTE] Audit your
browserslist. Defaults of> 0.5%, last 2 versionstypically include browsers nobody in your audience uses, ballooning polyfills.
Layer 7: Server Components / SSR
Move code that doesn't need the browser to the server (Next.js App Router, RSC). Server-only dependencies don't enter the client bundle at all — a huge win for date libraries, schema validators, markdown renderers, etc.
Layer 8: Avoid Re-Bundling The Framework
- Keep React and major libs external if you use a CDN or Module Federation.
- Don't accidentally bundle two copies of React via mismatched peer deps.
Layer 9: Images & Fonts
Not strictly JS, but the most under-optimized assets on most sites. Use AVIF/WebP, responsive srcset, font-display: swap, and only preload above-the-fold fonts.
Realistic Targets
| Bundle stage | Target (gzipped) |
|---|---|
| Initial app chunk | < 100-170 KB |
| Per-route chunk | < 50 KB |
| Vendor chunk | < 150 KB |
| Total JS on first load | < 250-300 KB |
Decision Cheatsheet
pulling in moment / momentjs? -> swap for date-fns or Intl
importing whole library? -> use named import, check tree-shake
route only used by 5% of users? -> React.lazy
feature loaded after CTA click? -> dynamic import()
heavy code only on Node side? -> move to server component / API
legacy browser support cost > benefit? -> drop them; raise browserslist
Q: What design patterns come up frequently in JavaScript? Give examples for module, singleton, observer, factory, and strategy.
Answer:
Classic GoF patterns translate to JS, but most look quite different because the language has first-class functions, prototypes, dynamic typing, and modules. Idiomatic JS leans on closures, higher-order functions, and dependency injection more than on the explicit class hierarchies of Java or C++.
1. Module Pattern
Encapsulate private state behind a public API. Pre-ESM idiom uses IIFE + closures; today ES modules give it natively.
// IIFE version (legacy)
const Counter = (function () {
let count = 0;
return {
inc() { count++; },
get() { return count; },
};
})();
// ESM version — the file itself is the module
let count = 0;
export const inc = () => count++;
export const get = () => count;
Each importer sees the same module instance (modules are singletons at the loader level).
2. Singleton
A single instance shared across the program. In JS, an ES module already behaves like a singleton, so writing an explicit Singleton class is often unnecessary.
// db.js
let instance;
export function getDb() {
if (!instance) instance = createConnection();
return instance;
}
[!NOTE] Be cautious — global singletons make testing harder, hide dependencies, and break in environments that spin multiple module realms (SSR, workers). Prefer dependency injection where you can.
3. Observer (Pub/Sub, EventEmitter)
A producer notifies many subscribers without knowing who they are. Foundation of DOM events, Node's EventEmitter, and reactive libraries.
class Emitter {
constructor() { this.listeners = new Map(); }
on(event, fn) {
const set = this.listeners.get(event) ?? new Set();
set.add(fn);
this.listeners.set(event, set);
return () => set.delete(fn); // unsubscribe handle
}
emit(event, payload) {
this.listeners.get(event)?.forEach(fn => fn(payload));
}
}
Returning the unsubscribe function is the standard ergonomic touch; it removes the need for a separate off.
4. Factory
A function that returns objects, hiding which concrete implementation is chosen. Reduces new coupling and enables polymorphism.
function makeLogger(env) {
if (env === 'prod') return { log: msg => sendToCloud(msg) };
if (env === 'test') return { log: () => {} };
return { log: msg => console.log(msg) };
}
const log = makeLogger(process.env.NODE_ENV);
log.log('hello');
Factories pair beautifully with closures — the returned object carries private state.
5. Strategy
Encapsulate interchangeable algorithms behind a common interface. Trivial in JS because functions are values.
const strategies = {
fifo: q => q.shift(),
lifo: q => q.pop(),
priority: q => q.sort((a, b) => a.p - b.p).shift(),
};
function process(queue, strategyName) {
const next = strategies[strategyName] ?? strategies.fifo;
while (queue.length) handle(next(queue));
}
Other Patterns Worth Knowing
Decorator (function form):
const withTiming = fn => async (...args) => {
const t = performance.now();
try { return await fn(...args); }
finally { console.log(fn.name, performance.now() - t, 'ms'); }
};
const timedFetch = withTiming(fetchUser);
Adapter / Facade: wrap a verbose API in a simpler one.
const storage = {
get: key => JSON.parse(localStorage.getItem(key) ?? 'null'),
set: (key, v) => localStorage.setItem(key, JSON.stringify(v)),
};
Command: package an action as data — undo/redo systems, queue workers.
const command = { type: 'MOVE', from: [0, 0], to: [3, 4] };
dispatch(command);
Iterator: the language gives you it for free via Symbol.iterator and generators (see Generators chapter).
Anti-Patterns to Avoid
- God objects — single 5000-line module that everyone imports.
- Stringly typed Strategy —
do('fifo')with no type safety. In TS, use a union literal. - Subclass abuse — JS classes encourage tall hierarchies. Prefer composition (
function compose(...fs)). - Singleton + global mutable state — turns testing into fixture archaeology.
Why JS Has Fewer "Pattern Books"
Because functions are first-class and modules already provide encapsulation, many GoF patterns degenerate to "just write a function" or "use closure". The patterns above still apply; the rest are often over-engineering in idiomatic JavaScript.
Q: What are best practices for error handling in JavaScript and TypeScript?
Answer:
JavaScript's error model has rough edges — anything can be thrown, async errors are easy to lose, and the type system doesn't track "what can throw". Good error handling is therefore a combination of conventions, boundary patterns, and observability.
Always Throw Error Instances
throw new Error('User not found'); // good
throw 'User not found'; // bad — no stack trace
Strings, numbers, plain objects work but lose stack info and break instanceof checks. Use a subclass for typed conditions:
class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`);
this.name = 'NotFoundError';
this.resource = resource;
}
}
Use cause for Wrapping
ES2022 added Error.cause to preserve the original cause when re-throwing:
try {
await db.query(sql);
} catch (e) {
throw new Error('Failed to load user', { cause: e });
}
Modern runtimes print the cause chain in stack traces — invaluable for debugging.
Never Swallow Errors Silently
try { await save(); } catch {} // bad — disappears the error
If a failure is genuinely safe to ignore, log it explicitly. Empty catches turn production bugs into mysteries.
Promise Hygiene
// 1. Always await or attach .catch on every promise
promise.catch(reportError);
// 2. Beware of "fire and forget" inside async functions
async function save() {
doThingAsync(); // promise leak — rejection unhandled
}
// 3. Node: listen for unhandledRejection so you don't crash silently
process.on('unhandledRejection', err => log.fatal({ err }, 'unhandled'));
Result Types vs Exceptions
Two competing styles:
Throwing style — terse, idiomatic, integrates with async/await:
const user = await fetchUser(id);
Result style — explicit, type-safe, hints at every failure mode:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
async function fetchUser(id: string): Promise<Result<User, NotFoundError>> {
const res = await fetch(`/api/u/${id}`);
if (res.status === 404) return { ok: false, error: new NotFoundError('User') };
if (!res.ok) throw new Error('Network error'); // unexpected vs expected failures
return { ok: true, value: await res.json() };
}
[!NOTE] A useful split: expected failures (validation, not-found, permission) -> Result type or domain error class. Unexpected failures (DB down, OOM) -> throw and let a boundary handle it.
Boundaries
Place a single catch-all at architectural seams:
- React: an Error Boundary per page or section.
- Express/Fastify: an error middleware that maps exceptions to HTTP responses.
- Worker queue jobs: a wrapper that catches, logs, and retries with backoff.
app.use((err, req, res, next) => {
if (err instanceof NotFoundError) return res.status(404).json({ msg: err.message });
log.error({ err, path: req.path }, 'unhandled');
res.status(500).json({ msg: 'Internal Error' });
});
The boundary is where you decide: log, retry, surface a user-friendly message, alert an oncall.
Async Stack Traces
Without await, stack traces show only synchronous frames. Always:
return await innerCall(); // not `return innerCall()` — keeps the frame visible
V8 produces near-perfect async stacks when you await consistently and use Error.cause for re-throws.
TypeScript and Errors
TS doesn't track which errors a function throws (no checked exceptions). Compensate by:
- Documenting failure modes in JSDoc / type aliases.
- Using Result types for expected, recoverable failures.
- Adding
instanceofdiscriminators in catches:
try { ... }
catch (e) {
if (e instanceof ZodError) return handleValidation(e);
if (e instanceof NotFoundError) return handleNotFound(e);
throw e; // re-throw unknown
}
Logging & Observability
Errors should always include:
- A message that names the operation that failed.
- Enough context (
userId,orderId,requestId). - The original error as
cause. - A correlation/trace ID so logs, metrics, and traces line up.
Tools: Sentry, Datadog, OpenTelemetry. Don't roll your own — they capture source maps, breadcrumbs, and grouping out of the box.
Anti-Patterns Recap
- Throwing strings.
- Empty
catchblocks. - Wrapping every line in
try/catchinstead of placing boundaries. - Catching
Errorjust to log and re-throw withoutcause. - Returning
nullon failure when a real domain error is more informative.
Q: How does Node.js's event loop differ from the browser's? What are the phases and when does each fire?
Answer:
Node.js uses libuv under the hood. While the browser event loop has only "tasks" and "microtasks", Node's loop is divided into phases, each with its own callback queue. The interaction between phases, process.nextTick, microtasks, and setImmediate produces some famously surprising ordering.
Phases (in order, repeated forever)
┌───────────────────────────┐
│ timers │ setTimeout / setInterval callbacks whose threshold is met
├───────────────────────────┤
│ pending callbacks │ deferred system-level errors (e.g. TCP errors)
├───────────────────────────┤
│ idle, prepare │ internal use only
├───────────────────────────┤
│ poll │ retrieve new I/O events; block here if no other work
├───────────────────────────┤
│ check │ setImmediate() callbacks
├───────────────────────────┤
│ close callbacks │ socket.on('close', ...) etc.
└───────────────────────────┘
│
└──── microtasks + nextTick run **between** each callback,
not just between phases
Microtasks in Node
Between every single callback (not just at phase boundaries), Node drains:
- The
process.nextTickqueue — higher priority than promise microtasks. - The promise microtask queue (
.then,awaitcontinuations,queueMicrotask).
So:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
Output:
sync
nextTick
promise
timeout // or immediate first — see below
immediate
nextTick and promise are drained after the sync phase finishes, before any I/O or timer phase runs.
setTimeout(fn, 0) vs setImmediate(fn)
These two have an indeterminate order when scheduled from the main module:
setTimeout(() => console.log('a'), 0);
setImmediate(() => console.log('b'));
// Order can be a-b or b-a — depends on how quickly the loop entered the timers phase
But when scheduled from inside an I/O callback, the order is guaranteed:
fs.readFile('foo', () => {
setTimeout(() => console.log('a'), 0); // runs in next loop's timers phase
setImmediate(() => console.log('b')); // runs immediately after current poll, in check phase
// Output: b, a
});
After the poll callback, the loop goes check before wrapping back to timers — so setImmediate wins.
process.nextTick Is Not A Phase
It is a microtask-like queue, drained between every callback. Heavy use of nextTick can starve I/O because the loop won't advance to the poll phase while nextTick is still recursively enqueueing.
function loop() { process.nextTick(loop); }
loop(); // I/O is now starved forever — server stops responding
[!NOTE]
process.nextTickexists mostly for backward compatibility and for cases where you need to defer to the end of the current operation without yielding to I/O. In new code, preferqueueMicrotask.
Worker Threads vs The Event Loop
CPU-bound work blocks the loop. For heavy computation, use:
worker_threads— true OS threads sharing the same process; communicate viaMessageChannel/postMessagelike browser workers.child_process.fork— separate Node process with IPC.
Network I/O does not block — libuv uses async system calls (epoll, kqueue, IOCP) and a thread pool for filesystem and DNS.
The Thread Pool
By default libuv has a 4-thread pool used for:
fs.*filesystem operations.crypto.pbkdf2,crypto.randomBytes,crypto.scrypt.- DNS lookup via
getaddrinfo. zlibasync functions.
If you saturate the pool with synchronous-style work, async filesystem calls queue up. Tune with UV_THREADPOOL_SIZE (max 1024) when needed.
Differences vs Browser
| Aspect | Browser | Node.js |
|---|---|---|
| Phases | Tasks + microtasks | Multi-phase libuv loop |
| Rendering | Interleaved between tasks | None (no DOM) |
| Highest priority queue | Microtasks | process.nextTick (then microtasks) |
setImmediate | Not standard (IE-only) | Native, distinct phase |
| Thread pool | Web Workers (manual) | libuv pool (implicit for fs/dns/crypto) |
Debugging the Loop
node --inspect+ Chrome DevTools "Performance".process.hrtime.bigint()to measure callback duration.perf_hooks.monitorEventLoopDelay()— detects lag spikes.
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => console.log('p99 lag ms', h.percentile(99) / 1e6), 5000);
Persistently high lag means something synchronous is blocking the loop — likely a hot JSON parse, a regex backtrack, or a sync fs call.
Q: What are Node.js streams? Explain backpressure and the four stream types.
Answer:
Node streams are an abstraction for processing data incrementally, piece by piece, instead of loading entire payloads into memory. They are the foundation of fs.createReadStream, HTTP requests/responses, TCP sockets, child-process pipes, zlib, and crypto.
The Four Types
| Type | Reads | Writes | Example |
|---|---|---|---|
| Readable | yes | no | fs.createReadStream, http.IncomingMessage |
| Writable | no | yes | fs.createWriteStream, http.ServerResponse |
| Duplex | yes | yes | TCP socket — read and write are independent |
| Transform | yes | yes | zlib.createGzip() — write input -> read transformed output |
A Transform is a Duplex where output is a function of input.
Two Reading Modes
Flowing mode — the stream pushes data via events:
stream.on('data', chunk => process(chunk));
stream.on('end', () => done());
stream.on('error', err => fail(err));
Paused mode — the consumer pulls via .read() or for await:
for await (const chunk of stream) {
process(chunk);
}
Modern code overwhelmingly prefers the async-iterator form — it integrates with await, propagates errors naturally, and respects backpressure for free.
Backpressure
Backpressure is the mechanism that prevents a fast producer from overwhelming a slow consumer. Each writable has an internal buffer; write(chunk) returns false when the buffer exceeds its highWaterMark.
function copy(src, dst, cb) {
src.on('data', chunk => {
if (!dst.write(chunk)) src.pause(); // slow consumer — stop reading
});
dst.on('drain', () => src.resume()); // ready for more
src.on('end', () => dst.end(cb));
src.on('error', cb);
}
Writing without honoring the return value leads to unbounded memory growth — process RSS climbs forever while the buffer queues chunks.
pipe and pipeline
stream.pipe() handles backpressure for you but does not forward errors:
src.pipe(gzip).pipe(dst);
src.on('error', handle);
gzip.on('error', handle);
dst.on('error', handle);
Use pipeline from node:stream/promises — it cleans up on any error and resolves when done:
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
await pipeline(
createReadStream('big.log'),
createGzip(),
createWriteStream('big.log.gz')
);
[!NOTE]
pipelineis to streams whatPromise.allis to promises — the right default.
Visual: Backpressure Across A Pipeline
Source Transform Sink
┌──────┐ ┌──────────┐ ┌──────┐
│ read │ ──► │ buffer │ ──► │ write│
└──────┘ └──────────┘ └──────┘
▲ ▲ │
│ │ drain │ slow disk
│ └─────────────────┘
│ │
└── pause when downstream full ┘
Implementing a Transform
import { Transform } from 'node:stream';
const upper = new Transform({
transform(chunk, _enc, cb) {
cb(null, chunk.toString().toUpperCase());
}
});
process.stdin.pipe(upper).pipe(process.stdout);
For object-mode streams (chunks are JS objects, not buffers), set objectMode: true.
Common Pitfalls
- Listening for
dataand forgettingerror. Unhandled errors crash the process. - Not consuming the readable. If nothing reads, the source stays in paused mode forever.
- Mixing async iteration with
pipe. Choose one model per stream. - Mutating the chunk buffer in-place. Cause:
Buffer.concatreuses memory regions. - Calling
res.end()while writes are still buffered. Useawait pipeline(...)orawait new Promise(r => dst.end(r)).
Web Streams Interop
Modern Node also supports the Web Streams API (ReadableStream, WritableStream, TransformStream) used by browsers and Service Workers. Convert with helpers:
import { Readable } from 'node:stream';
const nodeStream = Readable.fromWeb(webStream);
const webStream = Readable.toWeb(nodeStream);
Useful when integrating Fetch API (Response.body is a Web stream) with Node tooling.
Q: What's the difference between Next.js App Router and Pages Router? What changed and why?
Answer:
Next.js historically organized routes via the pages/ directory: each file became a route, with getStaticProps/getServerSideProps controlling data fetching. The App Router (introduced in Next 13, stable since 13.4) replaces this with the app/ directory and embraces React Server Components, layouts, streaming, and Server Actions natively.
Side-by-Side
| Aspect | Pages Router (pages/) | App Router (app/) |
|---|---|---|
| Components by default | Client | Server ("use client" opt-in) |
| Data fetching | getStaticProps, getServerSideProps | Async server components + fetch with caching |
| Layouts | Manual _app.js + _document.js | Nested, file-system based |
| Streaming | Limited | Built-in via Suspense |
| Loading state | Manual | loading.tsx co-located file |
| Error UI | _error.js | error.tsx per route |
| Mutations | API routes | Server Actions ("use server") |
| Caching model | Full-route SSG/ISR | Per-fetch cache + per-route segment cache |
| Dynamic routes | [id].js | [id]/page.tsx |
File Conventions (App Router)
app/
layout.tsx ← root layout, wraps every route
page.tsx ← "/"
loading.tsx ← Suspense fallback for siblings
error.tsx ← error boundary
not-found.tsx ← 404 UI
posts/
layout.tsx ← nested layout for /posts/*
page.tsx ← "/posts"
[slug]/
page.tsx ← "/posts/:slug"
Layouts are nested and preserved across navigations — a shared sidebar doesn't unmount when you click between two routes under the same layout.
Server Components by Default
// app/posts/page.tsx — runs only on the server
import { db } from '@/lib/db';
export default async function Page() {
const posts = await db.post.findMany();
return <PostList posts={posts}/>;
}
No getServerSideProps. Data fetching is just await inside the component. The result is serialized to the client; the server-side code never ships.
fetch Is Cache-Aware
// statically cached at build time (default in route segments)
fetch(url);
// revalidate every 60 seconds (ISR equivalent)
fetch(url, { next: { revalidate: 60 } });
// no cache — always live
fetch(url, { cache: 'no-store' });
// tag for on-demand revalidation
fetch(url, { next: { tags: ['posts'] } });
revalidateTag('posts');
The App Router collapses SSG, ISR, and SSR into a single mental model controlled per fetch.
Server Actions Replace Most API Routes
// actions.ts
'use server';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
await db.post.create({ data: { title: formData.get('title') as string } });
revalidatePath('/posts');
}
// component.tsx
import { createPost } from './actions';
<form action={createPost}>
<input name="title"/><button>Create</button>
</form>
Forms post directly to the server function; no manual fetch, no API route, no parsing.
Streaming and loading.tsx
app/dashboard/
layout.tsx
loading.tsx ← shown while page.tsx + children load
page.tsx
loading.tsx is sugar for wrapping the segment in <Suspense fallback={<Loading/>}>. Slow data inside page.tsx streams in, replacing the fallback when ready — no full-page spinner needed.
Error Boundaries Per Segment
// app/dashboard/error.tsx
'use client';
export default function Error({ error, reset }) {
return <div>Failed: {error.message}<button onClick={reset}>retry</button></div>;
}
Each route segment can isolate its failures. The shell stays interactive even if one panel crashes.
When Pages Router Still Makes Sense
- Mature apps with large existing investments in
getStaticProps/getServerSideProps. - Apps that need pure static export with no server runtime.
- Tooling that hasn't fully migrated (some plugins/adapters lag the App Router).
You can mix both routers in one project — pages/ and app/ coexist while you migrate.
[!NOTE] The App Router's caching model is powerful but easy to misconfigure. The four caches (full-route, router, data, request memoization) interact in ways that catch newcomers. When in doubt, opt out with
dynamic = 'force-dynamic'until you understand each layer.
Migration Checklist
- Move static pages first (no data) — pure JSX.
- Replace
getStaticProps/getServerSidePropswith async server components. - Convert interactive widgets to
"use client"components. - Replace REST/API routes with Server Actions where possible.
- Audit caching defaults — set explicit
cache/revalidateper fetch. - Replace
_app.tsxproviders withapp/layout.tsx+ client-side providers wrapping{children}.