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()
  • MutationObserver callbacks

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)

  1. The Event Loop executes the current synchronous code (the call stack) until it is completely empty.
  2. 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!).
  3. Only when the microtask queue is 100% empty, the Event Loop takes exactly one task from the macrotask queue and executes it.
  4. 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. 1. Script start (Sync)
  2. 5. Script end (Sync)
  3. 3. Promise 1 (Microtask Queue)
  4. 4. Promise 2 (Microtask Queue emptied)
  5. 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:

  1. Execute the entire current synchronous task on the call stack until it is empty.
  2. Drain the microtask queue completely (promise reactions, queueMicrotask, MutationObserver).
  3. Run one macrotask (timer callback, I/O completion, UI event).
  4. If in a browser, possibly render a frame.
  5. 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.

  • A and E are synchronous — they run during the initial script task.
  • The setTimeout callback is queued as a macrotask.
  • Both .then reactions are microtasks. After sync code, the microtask queue drains: C then D.
  • 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
  • setTimeout callbacks fire in the timers phase.
  • setImmediate fires in the check phase.
  • process.nextTick() is a higher-priority microtask, drained before promise microtasks.

Common Interview Traps

  1. Blocking the loop. A long synchronous loop blocks every queue. Offload CPU work to a Worker.
  2. Microtask starvation. Recursive Promise.resolve().then(...) chains can prevent timers from firing.
  3. setTimeout(fn, 0) is not zero. Browsers clamp to ~4ms after nested timeouts; the callback is still macrotask-scheduled, not immediate.
  4. 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

MethodResolves whenRejects whenResult
Promise.allAll fulfillAny rejects (fail-fast)Array of values
Promise.allSettledAll settle (fulfilled or rejected)NeverArray of {status, value/reason}
Promise.raceFirst to settle (either way)First rejectionSingle value or reason
Promise.anyFirst fulfillmentAll 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.all only 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 empty AggregateError.
  • Non-promise values are wrapped via Promise.resolve(value) — passing raw numbers is legal.
  • Combinators do not cancel in-flight work. Use AbortController to 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 N ms, then run once.
  • Throttle: allow the function to run at most once every N ms, 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

SituationPick
Search-as-you-type API callDebounce
Save draft after typing stopsDebounce
Window resize layout recalculationDebounce
Scroll-driven analytics or infinite scrollThrottle
Mousemove drag previewThrottle
Buttons protected from double-clickThrottle (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 }). The maxWait option turns debounce into a "throttle floor", guaranteeing the function runs at least every maxWait ms 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 to undefined.
  • 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

  • var is Function Scoped: If declared inside a function, it is scoped to that function. If declared block (like an if statement or for loop), it "leaks" out to the surrounding function scope.
  • let and const are 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 with undefined. You can access a var variable before you write the declaration, but its value will be undefined.
  • let and const: They are hoisted, but they are NOT initialized. Accessing them before their declaration line results in a ReferenceError. 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 const prevents the reassignment of the variable itself, it does not make the assigned object or array immutable. You can still mutate the internal properties of a const object.

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

  1. Always use const by default. It clarifies your intent that the variable should not be reassigned.
  2. If you know you will need to re-assign the variable (like a counter in a loop), use let.
  3. Stop using var entirely 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?

  1. 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!)
    
  2. Function Factories: Creating partially applied functions (like makeGreeting above).

  3. Memoization / Caching: Keeping a private cache object to store expensive calculation results.

  4. 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)

  1. new bindingnew Foo() creates a new object and binds this to it.
  2. Explicit bindingfn.call(obj), fn.apply(obj), fn.bind(obj).
  3. Implicit bindingobj.fn() binds this to obj.
  4. Default binding — bare fn() call. In strict mode this is undefined; 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] new beats explicit binding. This is the only case where bind can 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

AspectClassical (Java/C++)Prototypal (JS)
BlueprintClass defined at compile timeObjects link to other objects at runtime
Inheritance unitClass extends classObject delegates to object
MutationClass hierarchy is fixedAdd/replace methods anytime
Multiple parentsOften forbidden or via interfacesSingle 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__. Use Object.getPrototypeOf / Object.setPrototypeOf. Better still, set the prototype at creation time via Object.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

  1. 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).

  2. JSON.parse(JSON.stringify(obj)) — quick and ubiquitous but lossy.

    • Loses undefined, functions, symbols.
    • Converts Date to string, NaN/Infinity to null.
    • Throws on cycles.
    • Drops the prototype chain.
  3. Library helperslodash.cloneDeep is battle-tested if structuredClone isn't available.

  4. Hand-rolled recursion — only when you need custom semantics (e.g., skip certain keys, preserve class identity).

Trade-off Table

TechniqueSpeedHandles cyclesPreserves typesLoses
{...obj} / spreadFastestShallow onlyTop-level onlyNested sharing
JSON.parse(stringify)MediumNo (throws)No (Date/Map/etc lost)Functions, undefined
structuredCloneFastYesMaps/Sets/Date/regex/etcFunctions, DOM
lodash.cloneDeepSlowerYesMost typesSome 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 — structuredClone to 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

ConcernMapObject
Key typesAny value (objects, functions, NaN)Only strings / symbols
Insertion orderGuaranteed across all iterationsMostly guaranteed for string keys
Sizemap.size — O(1)Object.keys(obj).length — O(n)
Default prototype keysNone (truly empty)Inherits toString, hasOwnProperty, etc.
IterationBuilt-in iterator: for...of mapNeed Object.entries(obj) first
Performance for frequent add/deleteOptimizedSlower; hidden-class churn in V8
SerializationNo native JSON supportJSON.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 Map when keys are dynamic, untrusted user input, or non-strings. Use an Object when 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

ConcernSetArray
UniquenessEnforced by definitionManual (indexOf, includes)
Lookuphas(x) — O(1)includes(x) — O(n)
OrderInsertion orderInsertion order
Random accessNo (set[0] doesn't work)Yes (arr[0])
Dup detection costFreeO(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.

  • interface uses the extends keyword.
  • type uses 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 extends will compile faster than massive type & type intersections.

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 UserId string into a function expecting a Password string 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?
    1. A function that always throws an error.
    2. 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 never is ensuring that switch statements 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:

  1. false
  2. 0 (and -0)
  3. "" (empty string)
  4. null
  5. undefined
  6. NaN

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, NonNullable filter 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 : B resolves to A | B (not A). any is contagious.
  • never extends X ? A : B is never because 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

ConstructNarrows
typeof x === 'string'primitives
x instanceof Fooclasses
'prop' in xobject shapes
x == null / x === undefinednullability
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

CombinationResult
interface + interfaceMembers unioned
namespace + namespaceInner declarations merged
namespace + classStatic side augmented with namespace members
namespace + functionFunction gains properties
namespace + enumEnum gains extra members or static helpers
interface + classClass's instance side gains extra members (declared but not impl)
Module augmentation declare moduleExtends 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 type aliases. Library authors who export shapes as type cannot 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.ts files near the consumers.
  • Always import the library before declare module — TypeScript needs to know the module exists.
  • Re-export {} from a declare global file 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:

  1. Target: The prototype of the class.
  2. PropertyKey: The name of the method.
  3. Descriptor: The PropertyDescriptor of 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 --inspect and 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/WeakSet for caches keyed by objects.
  • Use AbortController for 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

ConcernCommonJSES Modules
Syntaxrequire / module.exportsimport / export
LoadingSynchronousAsynchronous, statically analysed
BindingsCopy of values at require timeLive read-only bindings
Execution timingWhen require runsAfter full graph is parsed
this at topmodule.exportsundefined
Conditional loadif (x) require('y') worksMust use dynamic import('y')
File extension.js / .cjs.mjs / .js with "type":"module"
Tree-shakingHard (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 exports field:

"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 undefined values.
  • 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 exports field.
  • 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 if return value; ran in place of the next yield.
  • throw(err) injects an error at the suspended yield, allowing a try/catch inside 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' ReadableStream works with for 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

KindLifetimeShared between tabs/pages?Typical use
Dedicated WorkerSame page as creatorNoPer-page CPU work
Shared WorkerAs long as any tab openYes, same originCross-tab coordination
Service WorkerIndependent of pagesYesOffline, caching, push, fetch interception
Worklet (Audio, Paint)Tied to specific subsystemNoRealtime 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 ArrayBuffer sent 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:

  1. Before an Effect runs again (on re-renders): If your useEffect dependencies 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.
  2. 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:

  • useMemo caches the result of a function calculation.
  • useCallback caches 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:

  1. The operation is cheap: Doing basic math or string concatenation? Don't use useMemo. const fullName = firstName + lastName is thousands of times faster to recalculate than wrapping it in useMemo.
  2. Passing to native HTML elements: Wrapping a function in useCallback just to pass it to <button onClick={handleClick}> is completely useless. Native DOM elements don't care about reference equality.
  3. The child isn't memoized: If you pass a useCallback function to a custom <Button> component, but that <Button> component is NOT wrapped in React.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 useMemo or useCallback first. Only add them if you explicitly identify a performance bottleneck (like a 500ms lag on typing) or a useEffect infinite 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 useState or onChange boilerplate).
  • 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:

  1. ["Apple", "Banana", "Cherry"]
  2. They are rendered with keys 0, 1, 2.
  3. You type "Green" into the "Apple" input.
  4. You click a button to delete "Apple" from the array.

The remaining array is now ["Banana", "Cherry"].

  • "Banana" shifts from index 1 to index 0.
  • React sees an element with key 0 in the old tree, and an element with key 0 in 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

FeatureContextRedux ToolkitZustandJotai
Selector-based subscriptionsNoYes (useSelector)Yes (useStore(s => s.x))Atomic
Reference equality opt-inNoYesYes (shallow)N/A
Time-travel / DevToolsNoYesYes (via DevTools middleware)Limited
Middleware (logging, async)NoYes (thunks, RTK Query)YesNo (uses atoms + effects)
BoilerplateNoneHigherLowLow
Bundle size0~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, no window.
  • Async by default. A Server Component can be an async function and await directly.
  • 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 children props.
// 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

AspectTraditional SSRReact Server Components
Code on clientYes (full component code)No (server components excluded)
Output to browserHTMLSerialized RSC payload (+ HTML)
HydrationRequired for interactivityOnly client components hydrate
Data fetchingIn getServerSideProps/etc.Inline via await
StreamingOptional, manualBuilt-in via Suspense
Bundle impactHeavy with framework codeSlimmer — 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 use useState — 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:

  1. Two elements of different types produce different trees. A <div> swapped for a <span> is treated as "tear down and rebuild", not "compare children".
  2. 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.
  • useEffect cleanup 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

  • useTransition marks a state update as non-urgent — React can pause it to keep input responsive.
  • useDeferredValue lets a heavy subtree lag behind a typing-driven input.
  • Suspense lets 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 (A vs B) when re-using a single component with a prop would preserve state.
  • Putting whole-app state in a Context provider's value recreated 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 setState calls 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/catch manually.
  • Errors inside setTimeout/Promise callbacks 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-boundary for 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/catch or surface into state.
  • Suspense without a data library that opts in does nothing — vanilla fetch + useState never 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

  1. Call hooks at the top level. Never inside loops, conditions, or nested functions.
  2. 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 useMemo if components destructure them and rely on referential equality.
  • Calling hooks inside callbacksonClick={() => 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 useEffect runs 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

ToolWhat it doesCost
React.memoSkips a child render if props are shallow-equalComparison + extra HOC
useMemoCaches a value (object/array/computed result)Comparison + closure
useCallbackCaches a function identitySame 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, memo likely pays off. If props include big objects rebuilt every render, you need useMemo on them too — otherwise memo does nothing.

Avoid Unnecessary Re-renders At The Source

Before reaching for memo, ask: can I avoid the re-render entirely?

  1. Move state down. A parent that owns input state re-renders the whole subtree on every keystroke. Move the input + state into a child.
  2. Lift props as children. Components passed via children to a parent don't re-render when that parent re-renders, because the children element reference is preserved by the grandparent.
    <Layout><ExpensiveTree/></Layout>
    
    If Layout updates its own state, ExpensiveTree skips re-render because children is the same element.
  3. 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 startTransition so 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

HeavyLighter alternative
moment (~70 KB)date-fns (tree-shakable) or Intl.DateTimeFormat
lodash full importlodash-es + named imports, or one-off helpers
axiosnative fetch for most cases
recharts/chart.jsuPlot for line/bar use cases
jqueryDOM APIs are plenty in 2026
Redux + RTKZustand/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 versions typically 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 stageTarget (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 Strategydo('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 instanceof discriminators 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 catch blocks.
  • Wrapping every line in try/catch instead of placing boundaries.
  • Catching Error just to log and re-throw without cause.
  • Returning null on 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:

  1. The process.nextTick queue — higher priority than promise microtasks.
  2. The promise microtask queue (.then, await continuations, 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.nextTick exists 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, prefer queueMicrotask.

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 via MessageChannel/postMessage like 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.
  • zlib async 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

AspectBrowserNode.js
PhasesTasks + microtasksMulti-phase libuv loop
RenderingInterleaved between tasksNone (no DOM)
Highest priority queueMicrotasksprocess.nextTick (then microtasks)
setImmediateNot standard (IE-only)Native, distinct phase
Thread poolWeb 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

TypeReadsWritesExample
Readableyesnofs.createReadStream, http.IncomingMessage
Writablenoyesfs.createWriteStream, http.ServerResponse
DuplexyesyesTCP socket — read and write are independent
Transformyesyeszlib.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] pipeline is to streams what Promise.all is 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 data and forgetting error. 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.concat reuses memory regions.
  • Calling res.end() while writes are still buffered. Use await pipeline(...) or await 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

AspectPages Router (pages/)App Router (app/)
Components by defaultClientServer ("use client" opt-in)
Data fetchinggetStaticProps, getServerSidePropsAsync server components + fetch with caching
LayoutsManual _app.js + _document.jsNested, file-system based
StreamingLimitedBuilt-in via Suspense
Loading stateManualloading.tsx co-located file
Error UI_error.jserror.tsx per route
MutationsAPI routesServer Actions ("use server")
Caching modelFull-route SSG/ISRPer-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

  1. Move static pages first (no data) — pure JSX.
  2. Replace getStaticProps/getServerSideProps with async server components.
  3. Convert interactive widgets to "use client" components.
  4. Replace REST/API routes with Server Actions where possible.
  5. Audit caching defaults — set explicit cache/revalidate per fetch.
  6. Replace _app.tsx providers with app/layout.tsx + client-side providers wrapping {children}.