⚡ ES2024 TypeScript 5 Node.js Runtime ~110 questions

Senior JavaScript / TypeScript

A complete set of senior-level JavaScript and TypeScript interview questions covering language internals, the event loop, closures, prototypes, async patterns, TypeScript's type system, Node.js architecture, performance, testing, security, and modern runtime environments.

No questions match your search. Try a different keyword.

Core Language

10 questions
1How does JavaScript's execution context and call stack work? What are global, function, and eval contexts?

Every time JavaScript code runs, it runs inside an execution context — a wrapper that holds the environment for the executing code. Three types exist:

  • Global execution context — created once when the script starts. Sets up the global object (window in browsers, global in Node.js) and binds this to it at the top level.
  • Function execution context — created each time a function is invoked. Contains its own variable environment, scope chain, and this binding.
  • Eval execution context — created inside eval(). Avoid in all production code — it has access to the calling scope, disables engine optimisations, and is a security vector.

Each execution context goes through two phases:

  • Creation phase — variable declarations are hoisted (set to undefined for var, or placed in a temporal dead zone for let/const), function declarations are fully hoisted, the scope chain is set up, and this is determined.
  • Execution phase — code runs line by line; variables are assigned their values.

Execution contexts are managed on the call stack — a LIFO data structure. The currently running context is at the top. When a function returns, its context is popped. A stack overflow occurs when too many nested calls (typically recursive without a base case) exceed the engine's stack limit.

function outer() {
  let x = 10;        // in outer's variable environment
  function inner() {
    let y = 20;      // in inner's variable environment
    console.log(x);  // x found via scope chain (outer's context)
  }
  inner();           // inner's context pushed onto stack
}                    // inner's context popped on return
outer();             // outer's context pushed, then popped
2Explain hoisting in depth. How does it differ between var, let, const, and function declarations?

Hoisting is JavaScript moving declarations to the top of their scope during the creation phase of the execution context. Only the declaration is hoisted — not the initialisation.

  • var — hoisted to the top of the function scope (or global scope). Initialised to undefined. Accessible before its line but returns undefined until assigned.
  • let and const — hoisted to the top of the block scope but placed in the Temporal Dead Zone (TDZ) until the declaration line is reached. Accessing them in the TDZ throws a ReferenceError. This is better behaviour — silent undefined bugs are caught immediately.
  • Function declarations — fully hoisted, including the function body. Can be called before the declaration in source code.
  • Function expressions and arrow functions — follow the rules of the variable they're assigned to (var/let/const). Not callable before the line.
console.log(a);        // undefined (var hoisted, not initialised)
console.log(b);        // ReferenceError (TDZ for let)
console.log(greet());  // "hello" (function declaration fully hoisted)

var a = 1;
let b = 2;
function greet() { return 'hello'; }

// Class declarations are also hoisted but in the TDZ
const obj = new MyClass(); // ReferenceError
class MyClass {}
3What is the difference between == and ===? Walk through the abstract equality comparison algorithm.

=== (strict equality) — compares both value and type. No coercion. If types differ, always returns false. Only special case: NaN === NaN is false; use Number.isNaN() or Object.is(NaN, NaN).

== (loose equality) — applies the Abstract Equality Comparison algorithm, which coerces operands before comparing. Key rules:

  • If types are the same, behaves like ===.
  • null == undefinedtrue (and only these two equal each other without coercion).
  • If one operand is a number and the other is a string, the string is converted to a number: "5" == 5true.
  • If one is a boolean, it's converted to a number first: true == 1true, false == ""true.
  • If one is an object and the other is a primitive, ToPrimitive() is called on the object (calls valueOf() then toString()).
// Surprising ==  results
0  == ''          // true  ('' → 0)
0  == '0'         // true  ('0' → 0)
'' == '0'         // false (both strings, same type, different values)
false == 'false'  // false (false → 0, 'false' → NaN)
null == 0         // false (null only == undefined)
[] == ![]         // true  ([] → 0, ![] → false → 0)

// Always prefer ===. Use == null only as a shorthand for
// checking both null and undefined: if (val == null) { ... }
4How does this work in JavaScript? Explain all four binding rules and how arrow functions change things.

this is determined at call time, not at definition time (except for arrow functions). Four binding rules in priority order:

  1. New bindingnew Foo() creates a new object and binds this to it.
  2. Explicit bindingfn.call(ctx), fn.apply(ctx), fn.bind(ctx) explicitly set this.
  3. Implicit bindingobj.method() binds this to obj. Lost when the method is detached: const fn = obj.method; fn()this is undefined (strict) or global (sloppy).
  4. Default binding — standalone function call. In strict mode: undefined. In sloppy mode: global object.
const obj = {
  name: 'Alice',
  greet() { console.log(this.name); },
  greetArrow: () => console.log(this.name), // this = lexical (outer)
};

obj.greet();           // 'Alice' (implicit binding)
const fn = obj.greet;
fn();                  // undefined in strict / global.name in sloppy

obj.greet.call({ name: 'Bob' }); // 'Bob' (explicit)

// Arrow functions have NO own this — they inherit from enclosing scope
function Timer() {
  this.ticks = 0;
  setInterval(() => {
    this.ticks++;       // this = Timer instance (lexical, not the interval)
  }, 1000);
}

Arrow function rules: No own this, arguments, super, or new.target. Cannot be used as constructors. Their this is captured from the surrounding scope when the arrow function is defined, not when it's called. bind/call/apply cannot override an arrow function's this.

5What is the difference between null, undefined, and undeclared variables? How do you safely check for each?
  • undefined — a variable has been declared but not yet assigned a value. Also the return value of a function that doesn't explicitly return. Type is "undefined".
  • null — intentional absence of value. Explicitly set by the programmer. Type is "object" — a historical bug in JS. Use null when you want to explicitly indicate "no value here".
  • Undeclared — a variable that was never declared with var/let/const. Accessing it throws a ReferenceError. In sloppy mode, assigning to an undeclared variable creates an implicit global — another reason to always use strict mode.
// Checking undefined
let x;
x === undefined;           // true — safe for declared vars
typeof x === 'undefined';  // true — also safe for undeclared vars
                           // typeof never throws a ReferenceError

// Checking null
x = null;
x === null;                // true — always use ===
x == null;                 // true — also catches undefined (intentional shorthand)

// Checking undeclared (when you're not sure if var exists)
typeof undeclaredVar === 'undefined'; // true — safe, no ReferenceError
'undeclaredVar' in window;            // true if it's on the global object

// Checking for "has a meaningful value" (null OR undefined)
if (val == null) { /* null or undefined */ }
if (val != null) { /* neither null nor undefined */ }
6How do JavaScript objects work internally? Explain property descriptors, Object.defineProperty, and the difference between data and accessor properties.

Every JavaScript property has an associated property descriptor — a metadata object that controls the property's behaviour.

Data property descriptor:

  • value — the property's value.
  • writable — if false, the value cannot be changed (silently fails in sloppy, throws in strict).
  • enumerable — if false, excluded from for...in and Object.keys().
  • configurable — if false, the property cannot be deleted or have its descriptor changed.

Accessor property descriptor — instead of a value, has get and set functions. Used for computed/lazy properties:

const user = { _age: 30 };

Object.defineProperty(user, 'age', {
  get() { return this._age; },
  set(v) {
    if (typeof v !== 'number' || v < 0) throw new Error('Invalid age');
    this._age = v;
  },
  enumerable: true,
  configurable: false,
});

user.age = 31;         // calls the setter
console.log(user.age); // calls the getter → 31

// Immutable constant via descriptor
Object.defineProperty(Math, 'PI_EXACT', {
  value: 3.14159265358979,
  writable: false,
  enumerable: false,
  configurable: false,
});

// Object-level freeze/seal
Object.freeze(obj);   // all properties: writable=false, configurable=false
Object.seal(obj);     // configurable=false, writable unchanged; can't add/delete
7What are JavaScript iterators and iterables? How do Symbol.iterator and generators relate?

The iteration protocol has two parts:

  • Iterable — any object with a [Symbol.iterator]() method that returns an iterator. Arrays, strings, Maps, Sets, NodeLists are all iterable.
  • Iterator — any object with a next() method that returns { value, done }.
// Custom iterable range
const range = {
  from: 1, to: 5,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        return current <= last
          ? { value: current++, done: false }
          : { value: undefined, done: true };
      }
    };
  }
};

for (const n of range) console.log(n); // 1 2 3 4 5
const arr = [...range];                // [1, 2, 3, 4, 5]

Generators are a cleaner syntax for creating iterators. A generator function (function*) returns a generator object that implements both iterable and iterator protocols. yield pauses execution and returns a value:

function* range(from, to) {
  for (let i = from; i <= to; i++) yield i;
}

for (const n of range(1, 5)) console.log(n);

// Infinite generator — lazy evaluation
function* naturalNumbers() {
  let n = 1;
  while (true) yield n++;
}
const gen = naturalNumbers();
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2

Generators also power async iteration (for await...of) and are the foundation of some async/await transpilation targets.

8What are WeakMap and WeakSet? When do you use them over Map and Set?

WeakMap — a Map where keys must be objects (or registered symbols in ES2023). The references to keys are weak — they don't prevent garbage collection. When a key object is collected, its entry is automatically removed. Not iterable (no .keys(), .values(), .entries(), .forEach(), no .size). Supports only get, set, has, delete.

WeakSet — a Set where values must be objects. Also weakly held, not iterable.

When to use:

  • Private instance data — associate metadata with an object without preventing its GC. Classic pattern before private class fields (#field) were available.
  • DOM node caching — cache computed data for DOM nodes. When the node is removed from the document and dereferenced, the WeakMap entry is collected automatically, preventing memory leaks.
  • Memoization with object keys — cache function results keyed by object arguments without holding them alive.
const cache = new WeakMap();

function processNode(node) {
  if (cache.has(node)) return cache.get(node);
  const result = expensiveComputation(node);
  cache.set(node, result);   // node removal → entry GC'd automatically
  return result;
}

// WeakSet — track processed objects without memory leak
const processed = new WeakSet();
function processOnce(obj) {
  if (processed.has(obj)) return;
  doWork(obj);
  processed.add(obj);
}
9What are JavaScript Proxies and Reflect? What are their practical use cases?

A Proxy wraps a target object and intercepts fundamental operations via traps: property access (get), assignment (set), function calls (apply), in operator (has), delete, Object.keys (ownKeys), and more.

Reflect mirrors the Proxy traps as methods — each Reflect method is the default behaviour for its corresponding trap. Using Reflect in traps ensures correct semantics and proper receiver forwarding.

// Validation proxy
function createValidated(target, validators) {
  return new Proxy(target, {
    set(obj, prop, value, receiver) {
      if (validators[prop] && !validators[prop](value)) {
        throw new TypeError(`Invalid value for ${prop}: ${value}`);
      }
      return Reflect.set(obj, prop, value, receiver); // default behaviour
    },
  });
}

const user = createValidated({}, {
  age: v => Number.isInteger(v) && v >= 0 && v <= 150,
  email: v => /\S+@\S+\.\S+/.test(v),
});
user.age = 25;           // ok
user.age = -1;           // TypeError

// Logging / observability proxy
function observable(target, onChange) {
  return new Proxy(target, {
    set(obj, prop, value, receiver) {
      const old = obj[prop];
      const result = Reflect.set(obj, prop, value, receiver);
      if (old !== value) onChange(prop, old, value);
      return result;
    },
  });
}

Practical uses: reactive data (Vue 3 uses Proxy for its reactivity system), validation, access control / revocable capabilities, API mocking, auto-memoization, deep change tracking.

10What is Symbol in JavaScript? What are well-known symbols and why are they important?

A Symbol is a unique and immutable primitive value. Every call to Symbol() creates a new, guaranteed-unique value. Symbols are useful for non-enumerable property keys that don't collide with string keys — ideal for library/framework extensibility points.

const id = Symbol('id');        // optional description
const obj = { [id]: 123 };      // not visible in for...in or Object.keys()
console.log(obj[id]);           // 123 — access by reference

// Symbol.for — global symbol registry (shared across realms/modules)
const s1 = Symbol.for('app.version');
const s2 = Symbol.for('app.version');
s1 === s2; // true — same global symbol

Well-known symbols — built-in symbols that customise fundamental language behaviour:

  • Symbol.iterator — makes an object iterable (for...of, spread, destructuring).
  • Symbol.toPrimitive — controls type coercion. Override to customise how an object converts to a number, string, or default.
  • Symbol.hasInstance — customises instanceof behaviour.
  • Symbol.toStringTag — customises Object.prototype.toString output ([object MyType]).
  • Symbol.species — controls which constructor derived methods (map, filter) use to create new instances.
  • Symbol.asyncIterator — makes an object async iterable (for await...of).

Types & Coercion

6 questions
1What are all the JavaScript primitive types? What makes each one special?

JavaScript has seven primitive types (passed by value, immutable):

  • Number — 64-bit IEEE 754 double-precision float. All numbers share this type, including integers. Special values: Infinity, -Infinity, NaN (the only value not equal to itself). Integer precision is exact up to 2^53–1 (Number.MAX_SAFE_INTEGER).
  • BigInt — arbitrary-precision integers, suffixed with n (9007199254740993n). Cannot be mixed with Number arithmetic directly.
  • String — immutable sequence of UTF-16 code units. Methods like slice return new strings.
  • Booleantrue or false.
  • Symbol — unique identifier (see previous question).
  • undefined — absence of value (unintentional or default).
  • null — absence of value (intentional). typeof null === 'object' — a historical bug.

Everything else is an Object (including arrays, functions, dates, regex, Maps, Sets). Functions have typeof fn === 'function' as a special case but are fundamentally objects.

// Autoboxing — primitives temporarily wrapped in objects when accessing methods
'hello'.toUpperCase(); // String object created, method called, discarded
(42).toString(16);     // Number wrapper

// But primitives are not objects — property assignment is silent no-op
let str = 'hello';
str.custom = 'world'; // wrapper created, property set, wrapper discarded
str.custom;           // undefined
2Explain implicit and explicit type coercion. What are the rules for converting to boolean (truthy/falsy)?

Explicit coercion — intentional type conversion using built-in functions: Number(x), String(x), Boolean(x), parseInt(x, 10), parseFloat(x).

Implicit coercion — JavaScript automatically converts types in certain operations. The most surprising cases:

  • + with a string triggers string concatenation: 1 + "2" = "12", "3" + 1 = "31".
  • Other arithmetic operators (-, *, /) coerce to numbers: "6" - "2" = 4.
  • Unary + coerces to number: +"3" = 3, +true = 1, +null = 0, +undefined = NaN.

Falsy values — these 8 values coerce to false in boolean context:

// All falsy:
false, 0, -0, 0n, "", '', ``, null, undefined, NaN

// Everything else is truthy, including:
[], {}, 'false', '0', function(){}, new Boolean(false)

// Common gotcha — empty array is truthy
if ([]) console.log('truthy!'); // prints

// Checking for empty:
if (arr.length === 0) { ... }   // correct
if (!arr.length) { ... }        // also correct
3How does typeof work? What does instanceof check and what are its limitations?

typeof returns a string indicating the type. Notable quirks:

typeof undefined  // "undefined"
typeof null       // "object"  ← historical bug
typeof true       // "boolean"
typeof 42         // "number"
typeof "str"      // "string"
typeof Symbol()   // "symbol"
typeof 42n        // "bigint"
typeof {}         // "object"
typeof []         // "object"  ← arrays are objects
typeof function(){} // "function"  ← special case
typeof undeclared // "undefined"  ← safe, no ReferenceError

instanceof checks whether an object has a constructor's prototype anywhere in its prototype chain:

[] instanceof Array    // true
[] instanceof Object   // true (Array.prototype inherits from Object.prototype)

// Reliable alternative: Object.prototype.toString
Object.prototype.toString.call([])    // "[object Array]"
Object.prototype.toString.call(null)  // "[object Null]"
Object.prototype.toString.call(/re/)  // "[object RegExp]"

instanceof limitations:

  • Fails across different realms (iframes, Node.js vm module) — each realm has its own Array, so [] instanceof otherRealm.Array is false.
  • Can be fooled by overriding Symbol.hasInstance.
  • Cannot check for primitives — 42 instanceof Number is always false.
4How do NaN and -0 work? Why are they hard to work with and how do you handle them correctly?

NaN (Not a Number) — the result of invalid numeric operations. The only value in JavaScript not equal to itself:

NaN === NaN          // false — always
NaN == NaN           // false
typeof NaN           // "number" — NaN is of numeric type
isNaN("hello")       // true — coerces first (dangerous!)
isNaN(undefined)     // true
Number.isNaN("hello")// false — no coercion, correct check
Number.isNaN(NaN)    // true — always use this

-0 (negative zero) — IEEE 754 has distinct +0 and -0. JavaScript mostly hides this, but it leaks through:

-0 === 0              // true  ← equality treats them the same
-0 == 0               // true
Object.is(-0, 0)      // false ← correct distinction
Object.is(-0, -0)     // true
String(-0)            // "0"   ← toString loses the sign
JSON.stringify(-0)    // "0"
1 / -0                // -Infinity ← sign preserved in division

// Correct way to distinguish:
function isNegativeZero(n) {
  return n === 0 && 1/n === -Infinity;
}

// Object.is — the "same value" comparison (no coercion, handles NaN and -0)
Object.is(NaN, NaN)   // true
Object.is(-0, 0)      // false
5What are the differences between primitive and reference types? How does JavaScript handle memory allocation for each?
  • Primitives — stored directly in the variable (on the stack). Copied by value — assigning to another variable makes a completely independent copy.
  • Reference types (Objects) — the object is allocated on the heap; the variable holds a reference (pointer) to the heap location. Assigning to another variable copies the reference — both variables point to the same object.
// Primitives — copy by value
let a = 5;
let b = a;
b = 10;
console.log(a); // 5 — unchanged

// Objects — copy by reference
let obj1 = { x: 1 };
let obj2 = obj1;     // copies the reference
obj2.x = 99;
console.log(obj1.x); // 99 — same object!

// Shallow vs deep copy
const shallow = { ...obj1 };           // spread — shallow copy
const deep = structuredClone(obj1);    // ES2022 — deep copy (handles nested)
const deep2 = JSON.parse(JSON.stringify(obj1)); // deep but loses Date/Function/undefined

Garbage collection: V8 uses a generational garbage collector. Objects are allocated in the "young generation" (small, collected frequently). Long-lived objects are promoted to the "old generation" (larger, collected infrequently). Closures holding references to outer scope variables can cause unintended retention — a common memory leak pattern.

6How do template literals work? What are tagged template literals and what are they used for?

Template literals (backtick strings) support embedded expressions ${expr}, multiline strings, and tagged templates.

Tagged template literals — a function called with the template parts and interpolated values as separate arguments. The tag function receives: an array of static string parts, and the interpolated values as rest arguments. The tag controls how the string is assembled.

// Basic interpolation
const name = 'Alice';
const greeting = `Hello, ${name}!`; // "Hello, Alice!"

// Tagged template
function highlight(strings, ...values) {
  return strings.reduce((result, str, i) => {
    const value = values[i - 1];
    return result + `<mark>${value}</mark>` + str;
  });
}
const age = 30;
highlight`My age is ${age} years old.`;
// "My age is <mark>30</mark> years old."

// Real-world: SQL safe query construction
function sql(strings, ...values) {
  const params = [];
  const query = strings.reduce((acc, str, i) => {
    if (i > 0) {
      params.push(values[i - 1]); // values collected separately (parameterised)
      return acc + `$${i}` + str;
    }
    return str;
  });
  return { query, params }; // passes to pg/mysql as parameterised query — no SQL injection
}

const userId = userInput; // potentially malicious
const { query, params } = sql`SELECT * FROM users WHERE id = ${userId}`;
// query: "SELECT * FROM users WHERE id = $1"
// params: [userInput] — never interpolated directly

Other tagged template uses: styled-components CSS-in-JS, gql for GraphQL queries, html for sanitised HTML, i18n for translations.

Closures & Scope

6 questions
1What is a closure? Give a practical example and explain how it enables the module pattern.

A closure is a function that retains access to variables from its enclosing lexical scope even after that outer function has returned. The function "closes over" those variables — they persist in memory as long as the closure exists.

// Basic closure — counter
function makeCounter(initial = 0) {
  let count = initial; // persists in closure
  return {
    increment() { return ++count; },
    decrement() { return --count; },
    value()     { return count; },
  };
}

const counter = makeCounter(10);
counter.increment(); // 11
counter.increment(); // 12
counter.value();     // 12 — count is private, not accessible from outside

Module pattern — closures enable private state and public API surfaces, mimicking class access modifiers before ES modules existed:

const CartModule = (() => {
  // Private — not accessible outside the IIFE
  const items = [];
  let total = 0;

  function recalculate() {
    total = items.reduce((sum, item) => sum + item.price, 0);
  }

  // Public API
  return {
    addItem(item) { items.push(item); recalculate(); },
    removeItem(id) {
      const idx = items.findIndex(i => i.id === id);
      if (idx !== -1) { items.splice(idx, 1); recalculate(); }
    },
    getTotal() { return total; },
    getItems() { return [...items]; }, // copy to prevent external mutation
  };
})();
2What is the classic "closure in a loop" bug? Why does it happen with var and how do you fix it?

The classic bug: closures in a loop all share the same var variable (function-scoped) rather than having their own copy. By the time the callbacks run, the loop has finished and i has its final value.

// Bug — all handlers print 5
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 0); // all close over the same i
}
// Output: 5 5 5 5 5

// Fix 1: use let — block-scoped, new binding per iteration
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 0); // each closure has its own i
}
// Output: 0 1 2 3 4

// Fix 2: IIFE — create a new scope per iteration (pre-ES6 solution)
for (var i = 0; i < 5; i++) {
  ((j) => setTimeout(() => console.log(j), 0))(i);
}

// Fix 3: bind or pass value
for (var i = 0; i < 5; i++) {
  setTimeout(console.log.bind(null, i), 0);
}

The fundamental issue is that var is function-scoped, not block-scoped. The let fix works because let creates a new binding for each iteration of the loop — a subtle but important ES6 behaviour.

3What is the scope chain and how does lexical scoping work? Compare with dynamic scoping.

Lexical scoping — variable resolution is determined by where the function is written in the source code, not where it's called. JavaScript uses lexical (static) scoping.

Scope chain — when a variable is accessed, the engine looks in the current scope, then walks up the chain of enclosing scopes until it finds the variable or reaches the global scope. At the global scope, accessing an undeclared variable throws a ReferenceError (in strict mode) or creates an implicit global (sloppy mode).

const x = 'global';

function outer() {
  const x = 'outer';
  function inner() {
    // Lexical scope: inner looks for x starting in its own scope,
    // then outer's scope (finds 'outer'), never reaches global.
    console.log(x); // 'outer'
  }
  inner();
}
outer();

Dynamic scoping (not JS, but used in Bash, some Lisps) — variable resolution is based on the call stack — who called the function, not where it was defined. If JS used dynamic scoping, calling inner() from a different context would resolve x differently depending on the caller.

The only thing in JS that resembles dynamic scoping is this — it depends on how the function is called (the call site). Arrow functions opt out by capturing this lexically.

4How do function currying and partial application work in JavaScript?

Partial application — fixing some arguments of a function, returning a new function that takes the remaining arguments. Function.prototype.bind does this natively.

Currying — transforming a function of N arguments into a sequence of N functions each taking one argument. f(a, b, c)f(a)(b)(c). Enables composability and point-free style.

// Partial application with bind
function multiply(a, b) { return a * b; }
const double = multiply.bind(null, 2);
double(5); // 10
double(7); // 14

// Curry implementation
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);      // enough args — call the original
    }
    return (...more) => curried(...args, ...more); // return partially applied
  };
}

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

// Real-world: logging with context
const log = curry((level, module, message) =>
  console[level](`[${module}] ${message}`)
);
const logError = log('error');
const authError = logError('auth');
authError('Token expired'); // console.error('[auth] Token expired')
5What are IIFEs and when are they still useful? How do ES modules change the need for them?

An IIFE (Immediately Invoked Function Expression) is a function that is defined and immediately called. Before ES modules, IIFEs were the primary way to create private scope and avoid polluting the global namespace.

// Classic IIFE — creates a private scope
(function () {
  const privateVar = 'secret';
  window.MyLib = { /* public API */ };
})();

// Arrow IIFE
(() => {
  // private scope
})();

// Parameterised IIFE — import globals explicitly (better minification)
(function ($, window, document) {
  // $ is jQuery in this scope
})(jQuery, window, document);

ES modules change the need for IIFEs: every ES module has its own scope by default. Top-level let/const/function declarations are module-scoped, not global. No IIFE needed for isolation.

When IIFEs are still useful:

  • Async top-level code before top-level await was available: (async () => { await init(); })().
  • Creating one-off private scopes inside non-module scripts (e.g., in a browser environment that doesn't use bundlers).
  • Avoiding accidental global variable creation in older codebases.
6What are memory leaks in JavaScript? What are the most common causes and how do you detect them?

A memory leak occurs when memory that is no longer needed is not released — the garbage collector cannot collect it because there are still references to it. JavaScript's GC collects objects with no live references, so leaks are caused by unintended retained references.

Common causes:

  • Accidental global variables — assigning to undeclared variables in sloppy mode creates properties on window, which persists for the page lifetime.
  • Forgotten event listeners — attaching listeners to DOM nodes or global objects without removing them. If the node is removed from the DOM but the listener still references it, neither is collected.
  • Closures retaining large scopes — a closure holds a reference to its entire outer scope. If only one small value is needed but the closure captures a large object, the large object is kept alive.
  • Detached DOM nodes — JavaScript references to DOM nodes that have been removed from the document but not dereferenced in JS. The node is detached from the tree but still in memory.
  • Timers and intervalssetInterval callbacks that aren't cleared, retaining the closure's scope.
// Leak: event listener not removed
function setupListener() {
  const heavyData = new Array(1_000_000).fill('data');
  document.getElementById('btn').addEventListener('click', () => {
    console.log(heavyData.length); // heavyData held alive as long as listener exists
  });
}
// Fix: remove the listener when done
const handler = () => { ... };
btn.addEventListener('click', handler);
// later:
btn.removeEventListener('click', handler);

Detection: Chrome DevTools Memory tab → take heap snapshots before and after a suspected leak → compare. Look for growing heap over time in the Performance tab. Use the "Detached DOM trees" filter in heap snapshots.

Prototypes & OOP

6 questions
1How does JavaScript's prototype chain work? Explain the difference between __proto__, prototype, and Object.getPrototypeOf.

Every object has an internal [[Prototype]] link to another object (its prototype). Property lookups traverse this chain until null is reached. This is JavaScript's inheritance mechanism.

  • __proto__ — the deprecated, non-standard accessor for [[Prototype]]. Avoid in production; use Object.getPrototypeOf()/Object.setPrototypeOf() instead.
  • Foo.prototype — a property on constructor functions. When new Foo() is called, the new object's [[Prototype]] is set to Foo.prototype. This is how methods shared across instances work.
  • Object.getPrototypeOf(obj) — the correct way to get an object's prototype.
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return `${this.name} makes a sound.`; };

function Dog(name) { Animal.call(this, name); }
Dog.prototype = Object.create(Animal.prototype); // inherit Animal's prototype
Dog.prototype.constructor = Dog;                 // fix constructor reference
Dog.prototype.bark = function () { return 'Woof!'; };

const rex = new Dog('Rex');
rex.bark();   // 'Woof!'  — found on Dog.prototype
rex.speak();  // 'Rex makes a sound.' — found on Animal.prototype (via chain)

// Prototype chain: rex → Dog.prototype → Animal.prototype → Object.prototype → null

Object.getPrototypeOf(rex) === Dog.prototype;     // true
Object.getPrototypeOf(Dog.prototype) === Animal.prototype; // true
2How do ES6 classes work under the hood? Are they just syntactic sugar over prototypes?

Yes — ES6 class is primarily syntactic sugar, but with some important differences from manual prototype setup:

  • Class definitions are in the TDZ (like let) — you cannot use a class before it's declared.
  • Class bodies are always in strict mode.
  • Class methods are non-enumerable by default (unlike prototype methods added with assignment).
  • super() must be called before accessing this in a derived class constructor — enforced by the engine.
  • Private fields (#field) use hard private semantics — truly inaccessible from outside, not closures.
class Animal {
  #name;                         // truly private — hard private field
  constructor(name) { this.#name = name; }
  get name() { return this.#name; }  // accessor
  speak() { return `${this.#name} makes a sound.`; }
  static create(name) { return new this(name); }  // static factory
}

class Dog extends Animal {
  #breed;
  constructor(name, breed) {
    super(name);               // must come before this.#breed
    this.#breed = breed;
  }
  bark() { return 'Woof!'; }
  toString() { return `${this.name} (${this.#breed})`; }
}

const rex = Dog.create('Rex', 'Labrador'); // static inherited
// Under the hood:
// Dog.prototype.__proto__ === Animal.prototype  → true
// Dog.__proto__ === Animal → true (static method inheritance)
3What is Object.create and what problems does it solve? How does it relate to prototypal inheritance?

Object.create(proto, descriptors?) creates a new object with its [[Prototype]] set to proto. No constructor function is involved — it's pure prototypal inheritance (the OLOO pattern — Objects Linking to Other Objects).

// OLOO pattern — no new, no constructor functions
const AnimalBehaviours = {
  speak() { return `${this.name} makes a sound.`; },
  eat()   { return `${this.name} is eating.`; },
};

const DogBehaviours = Object.create(AnimalBehaviours); // links to AnimalBehaviours
DogBehaviours.bark = function () { return `${this.name} says Woof!`; };
DogBehaviours.init = function (name, breed) {
  this.name = name; this.breed = breed; return this;
};

const rex = Object.create(DogBehaviours).init('Rex', 'Lab');
rex.bark();  // 'Rex says Woof!'
rex.speak(); // 'Rex makes a sound.' (via prototype chain)

// Object.create(null) — creates an object with NO prototype at all
// Useful for pure dictionaries / hash maps with no inherited properties
const dict = Object.create(null);
dict.toString = 'custom';  // safe — no Object.prototype.toString conflict
4What are mixins and composition in JavaScript? Why do some engineers prefer composition over inheritance?

JavaScript single-inheritance (one prototype chain) means you can't directly inherit from multiple sources. Mixins copy methods from multiple source objects into a target, achieving multiple-behaviour composition.

// Functional mixin pattern
const Serializable = (Base) => class extends Base {
  serialize() { return JSON.stringify(this); }
  static deserialize(json) { return Object.assign(new this(), JSON.parse(json)); }
};

const Validatable = (Base) => class extends Base {
  validate() {
    return Object.entries(this.rules || {}).every(([key, rule]) => rule(this[key]));
  }
};

const Timestamped = (Base) => class extends Base {
  constructor(...args) {
    super(...args);
    this.createdAt = new Date();
    this.updatedAt = new Date();
  }
  touch() { this.updatedAt = new Date(); }
};

class User extends Timestamped(Validatable(Serializable(class {}))) {
  constructor(name, email) {
    super();
    this.name = name;
    this.email = email;
    this.rules = { email: v => /\S+@\S+/.test(v) };
  }
}

Composition over inheritance: deep class hierarchies create tight coupling — a change to a base class can break all subclasses (the fragile base class problem). Composition assembles behaviour from independent units. Functions that take and return objects (or closures) compose more flexibly than class hierarchies and are easier to test in isolation.

5How does new work? What are the four steps it performs and what happens if the constructor returns an object?

When new Foo(args) is called, JavaScript performs four steps:

  1. Create a new empty object.
  2. Set the new object's [[Prototype]] to Foo.prototype.
  3. Execute Foo with this bound to the new object.
  4. Return the new object — unless the constructor explicitly returns a different object (returning a primitive is ignored).
// Simulating new manually
function simulateNew(Constructor, ...args) {
  const obj = Object.create(Constructor.prototype); // steps 1 & 2
  const result = Constructor.apply(obj, args);       // step 3
  return (result !== null && typeof result === 'object') ? result : obj; // step 4
}

// Constructor returning an object — the returned object wins
function Foo() {
  this.value = 1;
  return { value: 2 }; // explicit object return
}
const f = new Foo();
f.value; // 2 — the returned object overrides the newly created one

// Returning a primitive — ignored, new object returned
function Bar() {
  this.value = 1;
  return 42; // primitive — ignored
}
const b = new Bar();
b.value; // 1
6What are getters and setters in JavaScript classes and objects? When do you use them?

Getters and setters are accessor properties that look like regular property accesses but execute functions behind the scenes.

class Temperature {
  #celsius;
  constructor(celsius) { this.#celsius = celsius; }

  // Getter — computed property
  get fahrenheit() { return this.#celsius * 9/5 + 32; }
  get kelvin()     { return this.#celsius + 273.15; }

  // Setter — validation and transformation
  set celsius(value) {
    if (value < -273.15) throw new RangeError('Below absolute zero!');
    this.#celsius = value;
  }
  get celsius() { return this.#celsius; }
}

const t = new Temperature(100);
t.fahrenheit; // 212 — no () needed
t.celsius = -300; // RangeError

Use getters/setters when:

  • A property is derived from other properties and should stay in sync without explicit recalculation.
  • You need to validate or transform values on assignment.
  • You want to add lazy initialisation — compute an expensive value only when first accessed, then cache it.
  • You're implementing a reactive system (Vue 2 used getters/setters for reactivity; Vue 3 upgraded to Proxy).

Pitfall: getters without setters silently fail on assignment in sloppy mode; throw in strict mode. Always define both if the property should be assignable.

Async & Event Loop

10 questions
1How does the JavaScript event loop work? Explain the call stack, task queue, and microtask queue in detail.

JavaScript is single-threaded — one piece of code runs at a time. The event loop enables async behaviour by deferring work to queues processed between tasks.

Execution model:

  1. Execute current synchronous code on the call stack.
  2. When the stack empties, drain the entire microtask queue (including microtasks added by microtasks).
  3. Perform one rendering update (in browsers).
  4. Pick one task from the task queue (macrotask queue) and execute it (goes back to step 1).

Microtask sources: Promise.then/catch/finally, queueMicrotask(), MutationObserver, process.nextTick (Node.js — actually has its own queue, runs before other microtasks).

Task (macrotask) sources: setTimeout, setInterval, setImmediate (Node.js), I/O callbacks, UI events.

console.log('1 — sync');

setTimeout(() => console.log('2 — macrotask'), 0);

Promise.resolve()
  .then(() => {
    console.log('3 — microtask');
    Promise.resolve().then(() => console.log('4 — nested microtask'));
  });

queueMicrotask(() => console.log('5 — microtask'));

console.log('6 — sync');

// Output: 1, 6, 3, 5, 4, 2
// Explanation:
// Sync: 1, 6
// Microtask queue drained: 3, 5, 4 (4 added while draining)
// Macrotask: 2
2How do Promises work internally? What are the states and what happens with .then chaining?

A Promise represents an eventual value. Three states (transitions are irreversible):

  • Pending — initial state, awaiting resolution.
  • Fulfilled — resolved with a value.
  • Rejected — rejected with a reason (error).

Chaining: .then(onFulfilled, onRejected) always returns a new Promise. The return value of onFulfilled becomes the value of the next Promise in the chain. If a thenable (object with a .then method) is returned, it's adopted. Uncaught rejections propagate down the chain to the first .catch().

// Chain anatomy
fetchUser(1)                           // Promise<User>
  .then(user => fetchPosts(user.id))   // returns Promise<Post[]> — adopted
  .then(posts => posts.filter(...))    // returns filtered array — wrapped in Promise
  .then(filtered => {
    if (!filtered.length) throw new Error('No posts'); // rejects the chain
    return filtered;
  })
  .catch(err => {                      // catches any rejection above
    console.error(err);
    return [];                         // recovers — next .then gets []
  })
  .finally(() => setLoading(false));   // runs regardless of outcome

// Promise.resolve() vs new Promise()
// Promise.resolve(x) — creates an already-fulfilled promise
// new Promise(executor) — executor runs synchronously

Unhandled rejection: in Node.js 15+, unhandled Promise rejections crash the process (like unhandled exceptions). In browsers, they fire the unhandledrejection event. Always attach a .catch() or use try/catch with await.

3How does async/await work under the hood? What are common mistakes when using it?

async/await is syntactic sugar over Promises and generators. An async function always returns a Promise. await expr pauses the function's execution (suspends the generator-like state machine), schedules a microtask to resume when the awaited Promise settles, and then continues with the resolved value (or throws the rejection as an exception).

// Under the hood (conceptually):
async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}
// Is roughly equivalent to:
function fetchUser(id) {
  return fetch(`/api/users/${id}`).then(res => res.json());
}

Common mistakes:

  • Sequential when parallel is possibleawait inside a loop runs sequentially. Use Promise.all for independent operations.
  • Missing error handlingawait can throw; always wrap in try/catch or handle at the call site.
  • Returning a Promise from an async function is redundant — async already wraps the return in a Promise.
  • Awaiting in forEachforEach ignores returned Promises; use for...of or Promise.all.
// Bad — sequential (2 seconds total)
const user  = await fetchUser(id);    // 1s
const posts = await fetchPosts(id);   // 1s

// Good — parallel (1 second total)
const [user, posts] = await Promise.all([fetchUser(id), fetchPosts(id)]);

// Bad — await in forEach (loop doesn't wait)
ids.forEach(async id => await process(id)); // all fire at once, uncontrolled

// Good — sequential with for...of
for (const id of ids) { await process(id); }

// Good — all parallel with Promise.all
await Promise.all(ids.map(id => process(id)));
4What are all the static Promise methods? When do you use Promise.all vs allSettled vs race vs any?
  • Promise.all(iterable) — resolves when ALL promises resolve; rejects as soon as ANY one rejects (fail-fast). Use when all results are needed and a failure of any makes the whole operation invalid. Returns an array of all resolved values.
  • Promise.allSettled(iterable) — waits for ALL promises to settle (resolve or reject). Never rejects. Returns an array of { status: 'fulfilled'|'rejected', value|reason } objects. Use when you want all results regardless of individual failures — e.g., bulk operations where partial success is acceptable.
  • Promise.race(iterable) — resolves or rejects with the first promise to settle. Use for timeouts, or when you want the result of whichever service responds first.
  • Promise.any(iterable) — resolves with the first promise to resolve. Only rejects if ALL reject (with an AggregateError). Use for redundant requests — try multiple mirrors, use whichever succeeds first.
// Timeout pattern with Promise.race
function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

// Redundant fetch — use fastest successful response
const data = await Promise.any([
  fetch('https://cdn1.example.com/data'),
  fetch('https://cdn2.example.com/data'),
  fetch('https://cdn3.example.com/data'),
].map(p => p.then(r => r.json())));

// Batch operation — collect all results including failures
const results = await Promise.allSettled(ids.map(id => deleteUser(id)));
const failed = results.filter(r => r.status === 'rejected');
if (failed.length) console.warn(`${failed.length} deletions failed`);
5What is the difference between setTimeout(fn, 0) and Promise.resolve().then(fn)? When would you use each?

Both defer execution, but they place callbacks in different queues with different priorities:

  • Promise.resolve().then(fn) — schedules fn as a microtask. Runs after the current synchronous code but before the browser renders or picks the next macrotask. The highest async priority.
  • setTimeout(fn, 0) — schedules fn as a macrotask. Runs after the current task, all microtasks, a potential render update, then eventually this callback. The minimum delay is actually ~4ms in browsers (clamped).
Promise.resolve().then(() => console.log('A — microtask'));
setTimeout(() => console.log('B — macrotask'), 0);
console.log('C — sync');
// Output: C, A, B

When to use each:

  • Microtask (queueMicrotask or Promise.resolve().then) — defer work that must run before the next render or I/O event. Schedule cleanup or state updates that need to happen as soon as the current JS finishes. Used internally by libraries for "flush after current task."
  • Macrotask (setTimeout(fn, 0)) — break up long-running tasks to yield to the browser for rendering between chunks. Defer work to "the next event loop iteration" to allow UI updates (e.g., progress indicators). scheduler.yield() (Chrome) is a better modern alternative.
6What is the AbortController API and how do you use it to cancel async operations?

AbortController is a Web API for signalling cancellation to abort-aware async operations. An AbortController creates an AbortSignal that can be passed to fetch, addEventListener, and custom async operations.

// Cancel a fetch when a component unmounts (React)
useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/data`, { signal: controller.signal })
    .then(r => r.json())
    .then(setData)
    .catch(err => {
      if (err.name === 'AbortError') return; // ignore cancellation
      setError(err);
    });
  return () => controller.abort(); // cleanup on unmount or deps change
}, []);

// Cancel with a timeout — AbortSignal.timeout (modern)
const res = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000), // auto-aborts after 5s
});

// Custom async operation respecting abort
async function processItems(items, signal) {
  for (const item of items) {
    signal.throwIfAborted(); // throws DOMException('AbortError') if aborted
    await processItem(item);
  }
}

// Combine multiple signals (e.g., timeout + manual cancel)
const controller = new AbortController();
const combined = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);
fetch(url, { signal: combined });
7What are async iterators and for await...of? When do you use them over regular async/await?

An async iterator is an object with a [Symbol.asyncIterator]() method returning an iterator whose next() returns a Promise of { value, done }. for await...of consumes async iterables — it awaits each iteration step sequentially.

// Async generator — create a paginated async iterable
async function* paginatedFetch(url) {
  let nextUrl = url;
  while (nextUrl) {
    const res = await fetch(nextUrl);
    const data = await res.json();
    yield* data.items;            // yield all items in this page
    nextUrl = data.nextPageUrl;   // null on last page
  }
}

// Consume — processes items as they arrive, page by page
for await (const item of paginatedFetch('/api/products')) {
  await processItem(item);
}

// Node.js readable streams are async iterables
const chunks = [];
for await (const chunk of readableStream) {
  chunks.push(chunk);
}
const content = Buffer.concat(chunks);

// Real-world: streaming API responses (LLM token streaming)
async function* streamTokens(response) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    yield decoder.decode(value, { stream: true });
  }
}

for await (const token of streamTokens(response)) {
  appendToUI(token);
}
8What is the observable pattern and how does RxJS compare to Promises for async data streams?

A Promise represents a single future value — it resolves or rejects once. An Observable represents a stream of zero or more values over time. Observables are lazy (don't start until subscribed), cancellable (via unsubscribe()), and composable through a rich operator library.

Key differences:

  • Multiplicity — Promise: one value. Observable: zero to many values over time.
  • Laziness — Promise executor runs immediately when created. Observable work starts only on subscription.
  • Cancellation — Promises cannot be cancelled natively. Observables can be unsubscribed.
  • Operators — RxJS provides 60+ operators (debounce, throttle, merge, switchMap, retry with backoff, scan) that would require complex manual code with Promises.
import { fromEvent, interval } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';

// Type-ahead search — switchMap cancels previous requests
const search$ = fromEvent(input, 'input').pipe(
  debounceTime(300),           // wait 300ms after last keystroke
  distinctUntilChanged(),      // only fire if value changed
  switchMap(event =>           // cancel previous request, start new one
    from(fetch(`/api/search?q=${event.target.value}`).then(r => r.json()))
  )
);

const subscription = search$.subscribe({
  next: results => renderResults(results),
  error: err => showError(err),
  complete: () => console.log('Stream completed'),
});

// Cleanup (equivalent to AbortController)
subscription.unsubscribe();

RxJS excels for complex event-driven UIs (drag and drop, real-time dashboards, WebSocket streams). For simple async flows, async/await with TanStack Query is simpler and more readable. Angular uses RxJS extensively; React teams typically reach for it only for complex event orchestration.

9How do you implement rate limiting and debouncing in JavaScript?

Debounce — delays execution until after a period of inactivity. Only the last call in a burst fires. Use for: search-as-you-type, window resize handlers, auto-save.

Throttle — limits execution to at most once per interval. Fires on the first call, then ignores subsequent calls until the interval passes. Use for: scroll handlers, mousemove, rate-limiting API calls.

// Debounce
function debounce(fn, delay) {
  let timerId;
  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn.apply(this, args), delay);
  };
}

// Throttle (leading edge)
function throttle(fn, interval) {
  let lastCall = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastCall >= interval) {
      lastCall = now;
      return fn.apply(this, args);
    }
  };
}

// Async rate limiting — max N concurrent requests
async function* rateLimit(items, fn, concurrency = 3) {
  const chunks = [];
  for (let i = 0; i < items.length; i += concurrency) {
    chunks.push(items.slice(i, i + concurrency));
  }
  for (const chunk of chunks) {
    const results = await Promise.all(chunk.map(fn));
    yield* results;
  }
}

// Token bucket for API rate limiting
class RateLimiter {
  constructor(requestsPerSecond) {
    this.queue = [];
    this.interval = setInterval(() => {
      if (this.queue.length) this.queue.shift()();
    }, 1000 / requestsPerSecond);
  }
  schedule(fn) {
    return new Promise(resolve => this.queue.push(() => resolve(fn())));
  }
  destroy() { clearInterval(this.interval); }
}
10What is the queueMicrotask API? How does process.nextTick differ from it in Node.js?

queueMicrotask(fn) schedules fn as a microtask — it runs after the current synchronous code but before any macrotasks, in the same order as Promise.resolve().then(fn). It's the standard, cross-platform way to schedule microtasks without creating a Promise.

queueMicrotask(() => console.log('microtask'));
setTimeout(() => console.log('macrotask'), 0);
console.log('sync');
// Output: sync, microtask, macrotask

process.nextTick in Node.js — runs its callback at the end of the current operation, before any I/O events and before the regular microtask queue (Promises). It has its own queue that is processed before the Promise microtask queue. This makes it even higher priority than queueMicrotask.

// Node.js priority order (highest first):
// 1. process.nextTick queue
// 2. Promise microtask queue
// 3. setImmediate (check phase)
// 4. setTimeout/setInterval
// 5. I/O callbacks

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise microtask'));
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
// Output: nextTick, Promise microtask, setImmediate, setTimeout

When to use process.nextTick: deferring a callback that should run "at the end of this event loop iteration" before I/O — useful for ensuring events are emitted after the constructor has set up all listeners. Generally prefer queueMicrotask for new code — it's more predictable and platform-agnostic.

Modern ES Features

7 questions
1How do ES modules (import/export) differ from CommonJS (require)? What problems do they solve?
  • Static vs dynamic — ES module imports are static (resolved at parse time, before execution). require() is dynamic (runs at runtime, can be inside conditionals). Static analysis enables tree-shaking — bundlers can eliminate unused exports at build time.
  • Live bindings vs snapshots — ES module imports are live bindings — if the exporting module updates a variable, all importers see the update. require() copies the exported value at require-time (snapshot).
  • Asynchronous loading — ES modules support import() (dynamic import), which returns a Promise and enables code splitting.
  • Scope — each ES module has its own scope. Top-level variables are module-scoped, not global. CommonJS wraps modules in a function to achieve the same.
// ES Modules
export const PI = 3.14;
export function area(r) { return PI * r ** 2; }
export default class Circle { }

import { PI, area } from './math.js';
import Circle from './math.js';
import * as math from './math.js';  // namespace import

// Dynamic import — code splitting
const { area } = await import('./math.js');  // returns Promise

// CommonJS
module.exports = { PI, area };
const { PI, area } = require('./math');  // synchronous

In Node.js, ES modules use .mjs extension or "type": "module" in package.json. CJS and ESM interop is improving but still has edge cases — CJS cannot synchronously require() an ES module.

2Explain destructuring, rest/spread in depth. What are the advanced patterns and gotchas?
// Object destructuring with rename, default, and nested
const { name: firstName = 'Anonymous', address: { city } = {} } = user;

// Array destructuring with skip and rest
const [first, , third, ...rest] = [1, 2, 3, 4, 5];

// Computed property destructuring
const key = 'name';
const { [key]: value } = obj; // value = obj.name

// Destructuring in function parameters
function render({ title, className = 'default', children, ...rest }) { }

// Swap variables without temp
let a = 1, b = 2;
[a, b] = [b, a]; // a=2, b=1

// Rest in objects — collect remaining properties (shallow copy)
const { id, ...withoutId } = user;

// Spread — shallow merge (last wins for conflicts)
const merged = { ...defaults, ...overrides };

// Spread in function calls
Math.max(...numbers);

// Gotcha: spread is shallow — nested objects are still references
const original = { a: { b: 1 } };
const copy = { ...original };
copy.a.b = 2;
console.log(original.a.b); // 2 — shared reference!

// Gotcha: destructuring null/undefined throws
const { x } = null; // TypeError — always guard with defaults
const { x } = null ?? {}; // safe
3What are optional chaining (?.) and nullish coalescing (??)? How do they differ from || and &&?

Optional chaining (?.) — short-circuits to undefined if the left side is null or undefined, instead of throwing a TypeError. Works for property access, method calls, and array index access.

Nullish coalescing (??) — returns the right side only if the left side is null or undefined. Differs from || which returns the right side for any falsy left value (0, '', false, NaN).

// Optional chaining
user?.address?.city           // undefined if any link is null/undefined
arr?.[0]                      // undefined if arr is null/undefined
fn?.()                        // undefined if fn is null/undefined; calls if it exists

// Nullish coalescing vs OR
const count = value ?? 0;     // 0 only if value is null/undefined
const count2 = value || 0;    // 0 for any falsy value (including 0 itself!)

// Common bug with ||
const timeout = config.timeout || 5000; // wrong: 0 is valid but treated as falsy
const timeout2 = config.timeout ?? 5000; // correct: only falls back if undefined/null

// Nullish assignment — assign only if null/undefined
user.name ??= 'Anonymous';    // assign only if user.name is null/undefined
user.count ||= 0;             // assign if falsy (count could be 0 legitimately — careful!)
user.count &&= user.count + 1;// assign only if truthy

// Combining
const street = user?.address?.street ?? 'Unknown street';
4What are the main array and object methods introduced in ES2019–ES2024?
// ES2019
arr.flat(depth)              // flatten nested arrays
arr.flatMap(fn)              // map then flat(1) — more efficient than map+flat
Object.fromEntries(entries)  // inverse of Object.entries
str.trimStart() / trimEnd()  // trim only one side

// ES2020
BigInt, globalThis
Promise.allSettled([...])
arr?.()                      // optional chaining

// ES2021
str.replaceAll('old', 'new') // replace all occurrences without regex
Promise.any([...])
WeakRef / FinalizationRegistry
Numeric separators: 1_000_000

// ES2022
Object.hasOwn(obj, key)      // safer than obj.hasOwnProperty(key)
arr.at(-1)                   // last element (negative index)
Error.cause: new Error('msg', { cause: originalError })
top-level await (in modules)
class static blocks

// ES2023
arr.toSorted()               // non-mutating sort (returns new array)
arr.toReversed()             // non-mutating reverse
arr.toSpliced(i, n, ...items)// non-mutating splice
arr.with(index, value)       // non-mutating [index] assignment
arr.findLast(fn)             // find from end
arr.findLastIndex(fn)

// ES2024
Object.groupBy(arr, keyFn)   // group array items into object
Map.groupBy(arr, keyFn)      // group into Map (keys can be non-strings)
Promise.withResolvers()      // create {promise, resolve, reject} tuple
Atomics.waitAsync()

// Object.groupBy example
const grouped = Object.groupBy(users, user => user.role);
// { admin: [user1, user3], user: [user2, user4] }
5What is structured cloning and how does it differ from JSON serialisation for deep copying?

structuredClone(value) (ES2022) performs a deep clone using the Structured Clone Algorithm — the same algorithm used for postMessage. It handles types that JSON cannot:

  • Supports: Date, Map, Set, ArrayBuffer, RegExp, circular references, BigInt, typed arrays.
  • Does NOT support: functions, undefined in object values (silently drops them), DOM nodes, class instances (prototype is lost — clones as plain object), Symbol.
// JSON.parse/stringify — lossy deep clone
const obj = {
  date: new Date(),        // → becomes string "2024-01-01T..."
  map: new Map([['a',1]]), // → {} (Maps serialise to empty object)
  fn: () => 'hello',       // → undefined (functions dropped)
  undef: undefined,        // → key dropped entirely
  circular: null,
};
obj.circular = obj;        // circular reference
JSON.parse(JSON.stringify(obj)); // TypeError: circular structure

// structuredClone — handles most real-world types
const clone = structuredClone({
  date: new Date(),        // → cloned as Date object (not string)
  set: new Set([1, 2, 3]), // → cloned as Set
  buffer: new ArrayBuffer(8), // → cloned
});

// Cannot clone:
structuredClone(() => {}); // DataCloneError: functions not cloneable

// Transferable objects — move, don't copy (for performance with large buffers)
const buffer = new ArrayBuffer(1024 * 1024 * 64); // 64 MB
worker.postMessage({ data: buffer }, [buffer]); // buffer transferred, original detached
6What is top-level await in ES modules? What are its implications for module loading?

Top-level await (ES2022) allows using await at the top level of ES modules — outside any async function. The module is treated as an async function by the module system.

// config.js — top-level await
const config = await fetch('/api/config').then(r => r.json());
export { config }; // exported after fetch completes

// database.js
const connection = await Database.connect(process.env.DB_URL);
export { connection };

// app.js — importing these waits for their awaits to resolve
import { config } from './config.js';
import { connection } from './database.js';

Implications:

  • Blocking importers — any module that imports a top-level-await module is blocked until that module's await resolves. This propagates up the dependency graph.
  • Parallel loading — sibling imports at the same level can still load in parallel. Only modules that depend on each other are serialised.
  • Error handling — if the awaited expression rejects, the module evaluation fails. Importers that depend on it also fail. Use top-level try/catch for resilience.
  • CJS limitation — CommonJS modules cannot import ES modules using top-level await synchronously (CJS require is synchronous). Only works between ESM files.

Use cases: module-level initialisation that requires async I/O (database connections, config fetching, i18n loading), dynamic polyfill loading, feature detection that requires async APIs.

7What are JavaScript decorators? How do they work and what are they used for in TypeScript?

Decorators (TC39 Stage 3, TypeScript 5+) are functions that wrap and modify classes, methods, accessors, properties, and parameters. They use the @expression syntax placed before the target.

// Method decorator — logging
function log(target, context) {
  return function (...args) {
    console.log(`Calling ${context.name} with`, args);
    const result = target.apply(this, args);
    console.log(`${context.name} returned`, result);
    return result;
  };
}

// Accessor decorator — memoize getter
function memoize(getter, context) {
  const cache = new WeakMap();
  return function () {
    if (!cache.has(this)) cache.set(this, getter.call(this));
    return cache.get(this);
  };
}

class DataProcessor {
  @log
  process(data) { return transform(data); }

  @memoize
  get expensiveValue() { return heavyComputation(); }
}

// TypeScript decorators (legacy experimentalDecorators) — widely used:
// @Injectable() — Angular DI
// @Controller('/api') — NestJS routing
// @Entity(), @Column() — TypeORM / MikroORM
// @observable — MobX
// @autobind — auto-bind class methods

The TC39 decorators spec (Stage 3) differs from TypeScript's legacy experimentalDecorators — they have different signatures. TypeScript 5.0+ supports both. New code should use the standard TC39 decorators.

TypeScript Deep-Dive

10 questions
1What is TypeScript's structural type system? How does it differ from nominal typing?

TypeScript uses structural typing (duck typing) — type compatibility is determined by the structure (shape) of a type, not its name or origin. If two types have the same structure, they are compatible, regardless of what they're called.

interface Point2D { x: number; y: number; }
interface Vector2D { x: number; y: number; }

function plotPoint(p: Point2D) { }
const vec: Vector2D = { x: 1, y: 2 };
plotPoint(vec); // ✅ — structurally compatible even though names differ

// Excess property checking — only on object literals (extra properties flagged)
plotPoint({ x: 1, y: 2, z: 3 }); // ❌ excess property 'z' on literal
const obj = { x: 1, y: 2, z: 3 };
plotPoint(obj);  // ✅ — via variable, no excess check (fresh object literal rule)

Nominal typing (Java, C#) — types are compatible only if they have the same name or one explicitly extends the other. class Dog extends Animal — a Cat with the exact same methods is not assignable to Animal.

Simulating nominal typing in TypeScript — use branded types (type tags) to enforce that values came from a specific source:

type UserId  = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function makeUserId(id: string): UserId   { return id as UserId; }
function makeOrderId(id: string): OrderId { return id as OrderId; }

function fetchUser(id: UserId) { }
const uid = makeUserId('abc');
const oid = makeOrderId('xyz');
fetchUser(uid); // ✅
fetchUser(oid); // ❌ Type 'OrderId' is not assignable to 'UserId'
2Explain TypeScript's type narrowing. What are all the narrowing techniques?

Type narrowing refines a variable's type within a control flow branch. TypeScript tracks these refinements and adjusts the inferred type automatically:

function process(value: string | number | null) {
  // typeof guard
  if (typeof value === 'string') {
    value.toUpperCase(); // string
  }

  // Truthiness guard
  if (value) { /* string | number (null eliminated) */ }

  // instanceof guard
  if (value instanceof Date) { /* Date */ }

  // in operator guard
  if ('name' in value) { /* has name property */ }

  // Equality narrowing
  if (value === null) { /* null */ }

  // Discriminated union narrowing
  // (see section 3 of React/TS guide)
}

// Type predicate — custom narrowing function
function isUser(obj: unknown): obj is User {
  return typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj;
}

// Assertion function — throws if condition fails
function assertDefined<T>(val: T | null | undefined, msg: string): asserts val is T {
  if (val == null) throw new Error(msg);
}
assertDefined(user, 'User not found');
user.name; // TypeScript knows user is defined here

// Never type in exhaustive checks
function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}
3What are template literal types in TypeScript? What can you build with them?

Template literal types construct new string literal types by combining existing string types — at the type level, not at runtime:

type Direction = 'top' | 'right' | 'bottom' | 'left';
type CSSProperty = `margin-${Direction}` | `padding-${Direction}`;
// "margin-top" | "margin-right" | "margin-bottom" | ... | "padding-left"

type EventMap = { click: MouseEvent; keydown: KeyboardEvent; focus: FocusEvent };
type EventHandlers = {
  [K in keyof EventMap as `on${Capitalize<string & K>}`]: (e: EventMap[K]) => void;
};
// { onClick: (e: MouseEvent) => void; onKeydown: ... ; onFocus: ... }

// Extracting parts of strings at the type level
type GetRouteParam<T extends string> =
  T extends `${string}:${infer Param}/${string}`
    ? Param | GetRouteParam<`${string}/${string}`>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = GetRouteParam<'/users/:id/posts/:postId'>; // 'id' | 'postId'

// Deeply nested key paths
type NestedKeyPath<T, Prefix extends string = ''> = {
  [K in keyof T]: T[K] extends object
    ? NestedKeyPath<T[K], `${Prefix}${string & K}.`>
    : `${Prefix}${string & K}`
}[keyof T];

type Config = { db: { host: string; port: number }; app: { name: string } };
type ConfigPaths = NestedKeyPath<Config>; // "db.host" | "db.port" | "app.name"
4What is the infer keyword and how do you use it in conditional types?

infer captures a type variable from a pattern within a conditional type's extends clause. It's how TypeScript extracts inner types without you having to know or specify them:

// Extract the return type (like built-in ReturnType<T>)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type R = MyReturnType<() => string>;  // string

// Extract Promise's inner type (like built-in Awaited<T>)
type Unwrap<T> = T extends Promise<infer Inner> ? Unwrap<Inner> : T;
type U = Unwrap<Promise<Promise<string>>>;  // string

// Extract function parameter types
type Params<T> = T extends (...args: infer P) => any ? P : never;
type P = Params<(a: string, b: number) => void>;  // [string, number]

// Extract first argument
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;

// Extract array element type
type ElementType<T> = T extends (infer E)[] ? E : never;
type E = ElementType<string[]>;  // string

// Infer in template literals
type ExtractId<T extends string> = T extends `user_${infer Id}` ? Id : never;
type Id = ExtractId<'user_abc123'>;  // 'abc123'

// Multiple infer in one expression
type SplitPath<T extends string> =
  T extends `/${infer Segment}/${infer Rest}`
    ? [Segment, ...SplitPath<`/${Rest}`>]
    : T extends `/${infer Last}`
      ? [Last]
      : [];
5What is declaration merging in TypeScript? How is it used to extend third-party types?

Declaration merging — when TypeScript sees multiple declarations with the same name, it merges them. This enables extending existing types without modifying original source code.

// Interface merging — add to an existing interface
interface User { id: string; name: string; }
interface User { email: string; }  // merged → id, name, email

// Module augmentation — extend a library's types
// Without modifying node_modules:
declare module 'express' {
  interface Request {
    user?: AuthenticatedUser;    // add to Express's Request type
    tenantId?: string;
  }
}

// Extend a class via declaration merging
declare class EventEmitter {
  addListener(event: string, listener: Function): this;
}
// Augment with custom events (type-safe event emitter pattern)
declare module 'events' {
  interface EventEmitter {
    on(event: 'ready', listener: () => void): this;
    on(event: 'error', listener: (err: Error) => void): this;
  }
}

// Global augmentation — add to the global namespace
declare global {
  interface Window { myAnalytics: Analytics; }
  interface Array<T> {
    groupBy(fn: (item: T) => string): Record<string, T[]>;
  }
}

Declaration merging with namespace is also used for companion namespaces — combining a function type with a namespace of the same name to add static properties (a pattern used in React's type definitions).

6What are TypeScript's strict mode flags and what do the most important ones enable?

"strict": true in tsconfig.json enables a group of strictness flags. Always enable for new projects. The key flags:

  • strictNullChecks — the most impactful. null and undefined are not assignable to any type unless explicitly included. Without this, TypeScript allows const s: string = null — the source of most runtime TypeErrors.
  • noImplicitAny — function parameters without type annotations are any without this flag. Forces explicit typing of all parameters.
  • strictFunctionTypes — enables contravariant function parameter checking. Prevents assigning a function expecting a Dog parameter to a slot expecting a function handling Animal.
  • strictPropertyInitialization — class properties declared in the class body must be initialised in the constructor (or marked with ! definite assignment).
  • noUncheckedIndexedAccess (not in strict group, highly recommended) — array index access returns T | undefined since the index might be out of bounds. Catches many runtime errors.
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,                      // enables all strict flags
    "noUncheckedIndexedAccess": true,    // extra — highly recommended
    "noImplicitReturns": true,           // all code paths must return a value
    "exactOptionalPropertyTypes": true,  // {x?: number} — x can be absent or number, NOT undefined
    "noFallthroughCasesInSwitch": true,
    "useUnknownInCatchVariables": true   // catch (e) → e is unknown, not any
  }
}
7How do TypeScript's unknown and never types work? When do you use each?

unknown — the type-safe counterpart to any. A value of type unknown can hold any value, but you cannot do anything with it until you narrow it. Forces explicit type checking before use.

never — the bottom type. Represents values that never exist — a function that always throws, an infinite loop, or the intersection of mutually exclusive types. Assignable to any type; no type is assignable to it (except never itself).

// unknown — safe any
function processInput(input: unknown) {
  input.toUpperCase(); // ❌ TypeScript error — must narrow first

  if (typeof input === 'string') {
    input.toUpperCase(); // ✅ narrowed to string
  }
  if (input instanceof Error) {
    console.error(input.message); // ✅ narrowed to Error
  }
}

// never — unreachable code
function fail(msg: string): never {
  throw new Error(msg); // function never returns — return type is never
}

function infinite(): never {
  while (true) {} // never returns
}

// never in conditional types — filtering out union members
type NonNullable<T> = T extends null | undefined ? never : T;
type Nums = NonNullable<number | string | null>;  // number | string

// Exhaustive switch — never catches unhandled union members
type Shape = Circle | Rectangle | Triangle;
function getArea(s: Shape): number {
  switch (s.type) {
    case 'circle':    return Math.PI * s.radius ** 2;
    case 'rectangle': return s.width * s.height;
    case 'triangle':  return 0.5 * s.base * s.height;
    default:
      const check: never = s; // Error if new union member is unhandled
      return check;
  }
}
8How does TypeScript handle variance? What are covariance and contravariance?

Variance describes how subtype relationships on types relate to subtype relationships on generic types built from those types.

  • Covariant — if Dog extends Animal, then Box<Dog> extends Box<Animal>. Preserves the direction of the relationship. Return types are covariant — a function returning Dog is assignable to a function returning Animal.
  • Contravariant — if Dog extends Animal, then Fn<Animal> extends Fn<Dog>. Reverses the direction. Function parameter types are contravariant — a function accepting Animal is assignable to a function accepting Dog (it can handle any dog since dogs are animals).
  • Invariant — neither direction works. Mutable containers are typically invariant.
class Animal { eat() {} }
class Dog extends Animal { bark() {} }

// Covariant — return types
type Producer<T> = () => T;
const produceDog: Producer<Dog> = () => new Dog();
const produceAnimal: Producer<Animal> = produceDog; // ✅ covariant

// Contravariant — parameter types (with strictFunctionTypes)
type Consumer<T> = (t: T) => void;
const consumeAnimal: Consumer<Animal> = (a) => a.eat();
const consumeDog: Consumer<Dog> = consumeAnimal; // ✅ contravariant
// consumeAnimal handles any animal, so it certainly handles a Dog

// TypeScript 4.7+ — explicit variance annotations
type ReadonlyBox<out T> = { readonly value: T };  // covariant (out)
type WriteBox<in T>  = { set(value: T): void };   // contravariant (in)
9How do you write a type-safe event emitter in TypeScript?
type EventMap = Record<string, any>;

type Listener<T> = (data: T) => void;

class TypedEmitter<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<Listener<any>>>();

  on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): this {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event)!.add(listener);
    return this;
  }

  off<K extends keyof Events>(event: K, listener: Listener<Events[K]>): this {
    this.listeners.get(event)?.delete(listener);
    return this;
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): boolean {
    const listeners = this.listeners.get(event);
    if (!listeners?.size) return false;
    listeners.forEach(l => l(data));
    return true;
  }

  once<K extends keyof Events>(event: K, listener: Listener<Events[K]>): this {
    const wrapper: Listener<Events[K]> = (data) => { listener(data); this.off(event, wrapper); };
    return this.on(event, wrapper);
  }
}

// Usage — fully type-safe
interface AppEvents {
  login: { userId: string; timestamp: Date };
  logout: { userId: string };
  error: Error;
  dataLoaded: { records: number };
}

const emitter = new TypedEmitter<AppEvents>();
emitter.on('login', ({ userId, timestamp }) => {  // typed — userId: string, timestamp: Date
  console.log(`User ${userId} logged in at ${timestamp}`);
});
emitter.emit('login', { userId: '123', timestamp: new Date() }); // ✅
emitter.emit('login', { userId: 123 }); // ❌ TypeScript error — userId must be string
10What is TypeScript's as const and what is the difference between widening and narrowing literal types?

TypeScript widens literal types by default — let x = 'hello' infers string (not 'hello') because let implies the variable could be reassigned to any string. const x = 'hello' infers the literal type 'hello' because it cannot be reassigned.

as const (const assertion) tells TypeScript to infer the narrowest possible type and make it readonly, preventing widening:

// Without as const — widened types
const config = {
  endpoint: '/api',   // string
  port: 3000,         // number
  methods: ['GET', 'POST'], // string[]
};

// With as const — narrowed, readonly
const config = {
  endpoint: '/api',   // readonly '/api'
  port: 3000,         // readonly 3000
  methods: ['GET', 'POST'] as const, // readonly ['GET', 'POST']
} as const;

config.endpoint = '/other'; // ❌ readonly property

// as const is essential for discriminated unions from config objects
const ROUTES = {
  HOME: '/',
  ABOUT: '/about',
  USERS: '/users',
} as const;

type Route = (typeof ROUTES)[keyof typeof ROUTES]; // '/' | '/about' | '/users'

// Array as const — tuple type instead of array type
const pair = [1, 'hello'] as const;  // readonly [1, 'hello'] — a tuple
// Without as const: (number | string)[]

// Enum alternative with as const
const Direction = { Up: 'UP', Down: 'DOWN' } as const;
type Direction = (typeof Direction)[keyof typeof Direction]; // 'UP' | 'DOWN'

Node.js & Runtime

10 questions
1How does Node.js's architecture work? Explain the V8 engine, libuv, and the event loop in Node's context.

Node.js is built on two key components:

  • V8 — Google's JavaScript engine (used in Chrome). Compiles JavaScript to native machine code via JIT compilation. Handles the call stack, memory management, and garbage collection. It is single-threaded and has no I/O capabilities.
  • libuv — a cross-platform C library that provides the event loop, thread pool, async I/O (network, file system), timers, and OS abstractions. It bridges the synchronous JS world to the async OS world.

Node's event loop has distinct phases, unlike the browser's simpler model:

  1. TimerssetTimeout/setInterval callbacks whose delay has expired.
  2. Pending I/O callbacks — I/O callbacks deferred from the previous iteration.
  3. Idle / Prepare — internal use.
  4. Poll — retrieve new I/O events; block here if no timers/checks pending.
  5. ChecksetImmediate callbacks.
  6. Close callbacks — e.g., socket.on('close', ...).

Between each phase, Node drains the process.nextTick queue and the Promise microtask queue. The thread pool (4 threads by default, configurable with UV_THREADPOOL_SIZE) handles file system operations and crypto — offloading blocking work from the event loop thread.

2How do Node.js Streams work? Explain readable, writable, duplex, and transform streams with backpressure.

Streams process data in chunks rather than loading it entirely into memory — essential for large files, HTTP bodies, and real-time data.

  • Readable — source of data (file read, HTTP request, process.stdin). Can be in flowing mode (events) or paused mode (pull).
  • Writable — destination for data (file write, HTTP response, process.stdout).
  • Duplex — both readable and writable (TCP sockets, WebSockets).
  • Transform — duplex stream that transforms data as it passes through (gzip, encryption, JSON parsing).

Backpressure — when a writable destination processes data slower than the readable source produces it, buffers fill up. Backpressure signals the readable to pause. .pipe() handles backpressure automatically. Without it, you OOM on large inputs.

import { createReadStream, createWriteStream } from 'fs';
import { createGzip } from 'zlib';
import { pipeline } from 'stream/promises';

// pipeline — handles backpressure + errors + cleanup automatically
await pipeline(
  createReadStream('large-file.txt'),
  createGzip(),
  createWriteStream('large-file.txt.gz')
);

// Modern: streams as async iterables
import { createReadStream } from 'fs';
const stream = createReadStream('file.txt', { encoding: 'utf8' });
for await (const chunk of stream) {
  process(chunk); // process each chunk without loading entire file
}

// Custom transform stream
import { Transform } from 'stream';
class JSONParser extends Transform {
  _transform(chunk, encoding, callback) {
    try {
      const parsed = JSON.parse(chunk.toString());
      this.push(parsed);
      callback();
    } catch (err) { callback(err); }
  }
}
3How do Worker Threads work in Node.js? When do you use them vs the cluster module?
  • Worker Threads — spawn additional V8 instances in separate OS threads within the same process. Share memory via SharedArrayBuffer and Atomics. Communicate via postMessage. Best for: CPU-intensive tasks (image processing, crypto, ML inference, regex on large text) that would block the event loop.
  • Cluster module — forks multiple Node.js processes (one per CPU core), each with its own event loop and memory. The master process distributes incoming connections round-robin. Best for: horizontally scaling I/O-bound applications across multiple cores.
// Worker Threads — CPU-intensive work off the main thread
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';

if (isMainThread) {
  // Main thread — spawn worker
  const worker = new Worker('./worker.js', { workerData: { input: largeArray } });
  worker.on('message', result => console.log('Result:', result));
  worker.on('error', err => console.error(err));
} else {
  // Worker thread — CPU work
  const { input } = workerData;
  const result = input.reduce((sum, n) => sum + n, 0); // heavy computation
  parentPort.postMessage(result);
}

// Worker pool pattern — reuse workers (avoid spawn overhead)
import { StaticPool } from 'node-worker-threads-pool';
const pool = new StaticPool({ size: 4, task: './worker.js' });
const result = await pool.exec(data);

Choose Worker Threads when a single request requires heavy computation that blocks the event loop. Choose Cluster to utilise all CPU cores for concurrent I/O-bound requests. In production, often use both: multiple cluster workers, each with a worker thread pool for CPU tasks.

4How does Node.js module resolution work? Explain the resolution algorithm for require() and ES modules.

CommonJS require() resolution algorithm:

  1. If the path starts with ./, ../, or / — resolve as a file/directory relative to the current file:
    • Try exact path → path.jspath.jsonpath.node
    • Try as directory: path/index.jspath/index.jsonpath/package.json (main field)
  2. Otherwise — bare specifier, walk up node_modules directories from the current file's location.
// Node.js resolution for require('express'):
// 1. /project/src/node_modules/express/
// 2. /project/node_modules/express/        ← found here
// 3. /node_modules/express/
// Loads package.json → main field → index.js

// ESM resolution — stricter, file extensions required
import './utils';           // ❌ must specify extension
import './utils.js';        // ✅
import './utils/index.js';  // ✅ explicit

// package.json exports field — controls what a package exposes
{
  "exports": {
    ".":               { "import": "./dist/index.mjs", "require": "./dist/index.cjs" },
    "./types":         "./dist/types.d.ts",
    "./feature":       "./dist/feature.js",
    "./internal/*":    null  // blocks access to internal paths
  }
}

The exports field in package.json overrides all other resolution for packages. It enables conditional exports (different entry points for CJS and ESM), subpath exports, and blocking access to internal files — a critical encapsulation tool for library authors.

5What is the Node.js buffer and how do you work with binary data?

Buffer is Node.js's class for binary data — a fixed-size chunk of memory allocated outside the V8 heap. It is a subclass of Uint8Array. Used everywhere: file I/O, network sockets, cryptographic operations, image processing.

// Creating buffers
const buf1 = Buffer.alloc(10);                       // 10 zero bytes (safe)
const buf2 = Buffer.allocUnsafe(10);                  // 10 uninitialized bytes (faster, may contain garbage)
const buf3 = Buffer.from('hello', 'utf8');            // from string
const buf4 = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // from bytes
const buf5 = Buffer.from(arrayBuffer, byteOffset, length); // from ArrayBuffer

// Reading and writing
buf3.toString('utf8');        // 'hello'
buf3.toString('hex');         // '68656c6c6f'
buf3.toString('base64');      // 'aGVsbG8='
buf3[0];                      // 72 — byte value
buf3.readUInt32BE(0);         // read 4 bytes as big-endian uint32

// Concatenating
const combined = Buffer.concat([buf3, Buffer.from(' world')]);

// Performance — slice vs subarray
const slice = buf3.subarray(0, 2); // view into original memory (no copy)
const copy  = Buffer.from(buf3.subarray(0, 2)); // actual copy

// Encoding/decoding
Buffer.from('SGVsbG8=', 'base64').toString('utf8');  // 'Hello'

// Safe constant-time comparison (for secrets — prevents timing attacks)
import { timingSafeEqual } from 'crypto';
timingSafeEqual(receivedHmac, expectedHmac);
6How do you build a performant HTTP server in Node.js? What are the common bottlenecks?

Common bottlenecks and solutions:

  • Blocking the event loop — any synchronous CPU-intensive operation (heavy regex, large JSON.parse, cryptographic operations) blocks ALL requests. Offload to Worker Threads or use async equivalents (crypto.pbkdf2 async, not sync).
  • Unbounded concurrency — accepting unlimited concurrent connections without backpressure can exhaust memory and file descriptors. Use connection limits, request queuing, and rate limiting.
  • Memory leaks — unbounded caches, event listener accumulation, large closures in request handlers. Profile with --expose-gc and heap snapshots.
  • Synchronous I/O in request path — never use fs.readFileSync, JSON.parse on huge bodies, or any sync operation in a request handler.
// Fastify — high-performance Node.js framework (2x faster than Express)
import Fastify from 'fastify';
const app = Fastify({
  logger: true,
  ajv: { customOptions: { removeAdditional: 'all' } }
});

// Schema validation — faster than manual + input sanitisation
const schema = {
  body: {
    type: 'object',
    required: ['name', 'email'],
    properties: {
      name:  { type: 'string', maxLength: 100 },
      email: { type: 'string', format: 'email' },
    }
  }
};

app.post('/users', { schema }, async (req, reply) => {
  const user = await db.createUser(req.body); // non-blocking
  return reply.status(201).send(user);
});

// Cluster for multi-core (combine with Fastify for max throughput)
import cluster from 'cluster';
import os from 'os';
if (cluster.isPrimary) {
  os.cpus().forEach(() => cluster.fork());
  cluster.on('exit', (worker) => cluster.fork()); // restart dead workers
} else {
  app.listen({ port: 3000, host: '0.0.0.0' });
}
7What are Deno and Bun? How do they differ from Node.js?
  • Deno — created by Node.js's original author (Ryan Dahl) to address Node's design regrets. Uses V8 + Rust (tokio async runtime). Key differences: secure by default (permissions required for file/network access), native TypeScript/JSX support without transpilation, URL-based module imports (no node_modules), compatibility layer for Node.js APIs (node: prefix). Deno 2.0 added full npm package compatibility.
  • Bun — a new JavaScript runtime built from scratch using JavaScriptCore (Safari's engine) and Zig. Prioritises performance: starts 4× faster than Node, has built-in bundler/transpiler/test runner, natively supports TypeScript and JSX, drops-in as a Node.js replacement for most use cases. Most npm packages work without modification. Fastest HTTP throughput of all three runtimes in benchmarks.

Comparison:

  • Compatibility — Node.js is the most compatible with the npm ecosystem. Bun is close. Deno has partial compatibility.
  • Performance — Bun wins most benchmarks (startup time, HTTP throughput). Deno and Node are comparable for most workloads.
  • Tooling — Bun ships a complete toolkit (test, bundle, package manager). Deno has deno test, deno bundle, deno fmt built in. Node requires separate tools.
  • Production readiness — Node.js is the most battle-tested. Bun is maturing rapidly (1.0 in 2023). Deno is stable for server-side workloads.
8How does Node.js handle errors? What is the difference between operational and programmer errors?
  • Operational errors — expected failures in the normal operation of a correctly-written program: network timeouts, file not found, database connection refused, invalid user input. Handle gracefully — log, respond with appropriate HTTP status, retry if appropriate.
  • Programmer errors (bugs) — mistakes in code: accessing properties of undefined, passing wrong type, infinite loops. These should crash the process and let the process manager (PM2, Kubernetes) restart it with a clean state. Trying to recover from bugs creates unpredictable behaviour.
// Custom operational error hierarchy
class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.name = 'AppError';
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

// Express error middleware — centralised error handling
app.use((err, req, res, next) => {
  if (err.isOperational) {
    return res.status(err.statusCode).json({ error: err.code, message: err.message });
  }
  // Programmer error — log fully and crash/restart
  console.error('UNHANDLED BUG:', err);
  process.exit(1); // let PM2/Kubernetes restart cleanly
});

// Catch unhandled rejections (Node 15+ crashes by default — this is good)
process.on('unhandledRejection', (reason) => {
  throw reason; // re-throw so uncaughtException handles it
});
process.on('uncaughtException', (err) => {
  console.error('Uncaught exception:', err);
  process.exit(1);
});
9How do you profile and debug a Node.js application in production?

CPU profiling:

# V8 built-in profiler — sample-based CPU profile
node --prof app.js                        # generates isolate-*.log
node --prof-process isolate-*.log         # human-readable output

# Or use clinic.js
npx clinic flame -- node app.js           # flame graph
npx clinic doctor -- node app.js          # diagnose common issues
npx clinic bubbleprof -- node app.js      # async latency analysis

Memory profiling / leak detection:

// Heap snapshot programmatically
import v8 from 'v8';
const snapshot = v8.writeHeapSnapshot(); // writes heapdump file

// Or send SIGUSR2 to trigger heapdump (with heapdump package)
process.on('SIGUSR2', () => heapdump.writeSnapshot());

// Inspector — remote debugging
node --inspect=0.0.0.0:9229 app.js
# Connect Chrome DevTools → chrome://inspect

Event loop lag monitoring:

// Detect event loop blocking in production
let lastCheck = Date.now();
setInterval(() => {
  const lag = Date.now() - lastCheck - 1000; // expected 1000ms
  if (lag > 100) metrics.record('event_loop_lag_ms', lag);
  lastCheck = Date.now();
}, 1000);

// Better: use Node.js diagnostic channels or perf_hooks
import { monitorEventLoopDelay } from 'perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
// histogram.mean, histogram.percentile(99)
10What is V8's hidden classes and inline caching? How do they affect JavaScript performance?

V8 optimises object property access through two mechanisms:

Hidden classes (Shapes) — V8 creates an internal "hidden class" for each unique object shape. Objects with the same properties in the same order share a hidden class. When two objects with the same shape are accessed, V8 uses the same compiled code path — enabling optimisation.

// GOOD — same shape, V8 creates one hidden class for both
function Point(x, y) { this.x = x; this.y = y; } // properties always in same order
const p1 = new Point(1, 2); // Hidden class C0
const p2 = new Point(3, 4); // Reuses C0

// BAD — different shapes, defeats optimisation
const obj1 = { x: 1, y: 2 };    // Hidden class A
const obj2 = { y: 2, x: 1 };    // Hidden class B (different property order)

// BAD — adding properties after construction changes the hidden class
const obj = {};        // Class empty
obj.x = 1;             // Transitions to class with x
obj.y = 2;             // Transitions to class with x, y (class chain)
delete obj.x;          // Transitions to dictionary mode (slow!) — avoid delete

Inline caching (ICs) — V8 caches the hidden class lookup for a property access site. The first time obj.x is accessed, V8 records the hidden class and property offset. On subsequent calls with the same hidden class, it skips the lookup — O(1) access.

Deoptimisation — if a function receives objects with different hidden classes (polymorphic call site), V8 deoptimises it and falls back to slower generic code. Keep object shapes consistent for hot functions.

Performance

6 questions
1What is memoization and when should you use it in JavaScript?

Memoization is caching a function's result based on its arguments — avoiding recomputation for the same inputs. A pure function (same inputs → same output, no side effects) is memoizable.

// Generic memoize — simple Map cache (caution: unbounded)
function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);   // works for JSON-serialisable args
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// With LRU cache (bounded — prevents memory leaks)
import LRU from 'lru-cache';
function memoizeLRU(fn, maxSize = 100) {
  const cache = new LRU({ max: maxSize });
  return function (...args) {
    const key = JSON.stringify(args);
    const cached = cache.get(key);
    if (cached !== undefined) return cached;
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// Fibonacci — classic memoization use case
const fib = memoize(function fibonacci(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2); // recursive calls also memoized
});

When to use: expensive pure computations (Fibonacci, combinatorics), repeated expensive lookups (regex compilation, formatted strings), React render optimisation (useMemo). When NOT to use: functions with side effects, functions that return different results for the same input (random, Date.now), functions called rarely (memoization overhead not worth it).

2How does JavaScript garbage collection work? What are the main algorithms (mark-and-sweep, generational GC)?

V8 uses a generational garbage collector based on the observation that most objects die young:

Young generation (Scavenge / Cheney's algorithm) — new allocations go here. Small (1–8 MB, configurable). Collected frequently (every few milliseconds). Objects that survive two collections are promoted to old generation. Very fast — only live objects are traced, dead ones are simply abandoned.

Old generation (Mark-Sweep-Compact) — long-lived objects. Larger heap (hundreds of MB). Collected less frequently. Uses a tri-colour marking algorithm:

  1. Mark — start from GC roots (global variables, stack variables), mark all reachable objects. Incremental and concurrent to reduce pause times.
  2. Sweep — reclaim memory of unmarked (unreachable) objects.
  3. Compact — optionally move live objects together to reduce fragmentation.

GC pause reduction: V8 performs incremental marking (small slices between JS execution) and concurrent marking (on background threads). Major GCs still cause some stop-the-world pauses. Reduce GC pressure by: avoiding object allocation in hot loops, reusing objects, using typed arrays for numeric data (no GC overhead), and avoiding creating closures unnecessarily in tight loops.

3What are the key JavaScript benchmarking pitfalls? How do you write meaningful benchmarks?

Common pitfalls:

  • JIT warm-up — V8's JIT compiler optimises code after it's run ~10–100 times. Benchmarking code before warm-up measures interpreted code, not optimised. Always warm up before measuring.
  • Dead code elimination — V8 can detect that a computation's result is unused and eliminate it. The benchmark measures nothing. Use the result.
  • Deoptimisation — varying input types can cause deoptimisation in hot paths, making the benchmark slower than production code.
  • Garbage collection — GC pauses mid-benchmark skew results. Run many iterations and use statistical analysis (mean, p99, stddev).
  • Too-short runs — single measurements are noisy. Run for at least 1 second per benchmark, hundreds of iterations.
// Use tinybench or benchmark.js for reliable micro-benchmarks
import { Bench } from 'tinybench';

const bench = new Bench({ time: 2000, iterations: 1000 }); // 2 seconds minimum

bench
  .add('Array.from({ length })', () => {
    return Array.from({ length: 1000 }, (_, i) => i * 2);
  })
  .add('for loop', () => {
    const arr = new Array(1000);
    for (let i = 0; i < 1000; i++) arr[i] = i * 2;
    return arr;
  });

await bench.warmup(); // critical — let V8 JIT before measuring
await bench.run();
console.table(bench.table()); // ops/sec, margin, samples
4How does JavaScript's JIT compilation work? What are the optimisation and deoptimisation triggers?

V8 has multiple compilation tiers:

  1. Parsing — source code → AST.
  2. Ignition (bytecode interpreter) — AST → bytecode. Runs immediately. Collects type feedback.
  3. Sparkplug (baseline JIT) — bytecode → unoptimised machine code. Minimal overhead, ~2× faster than Ignition.
  4. Maglev (mid-tier JIT) — uses type feedback for moderate optimisation.
  5. TurboFan (optimising JIT) — uses gathered type feedback to produce highly optimised native code. Assumes types are stable.

Optimisation triggers: a function called frequently with the same argument types gets optimised by TurboFan. Inline caches collect type feedback that TurboFan uses for speculative optimisation.

Deoptimisation triggers: TurboFan's assumptions are violated — the wrong type arrives, a hidden class changes, a try/catch is encountered in some V8 versions, or arguments object is used unexpectedly. Deoptimisation throws away the optimised code and falls back to Ignition/Sparkplug.

// Forces deoptimisation — avoid in hot paths
function badHot(x) {
  if (typeof x === 'number') return x * 2;
  return String(x); // polymorphic — different types cause deopt
}

// Good — monomorphic hot function
function goodHot(x) {
  return x * 2; // always called with numbers → TurboFan optimises heavily
}

// eval, with, and delete are optimisation killers in their scope
// Don't use them in performance-critical code
5What are ArrayBuffers and TypedArrays? When do you use them for performance-critical code?

ArrayBuffer is a fixed-size raw memory buffer. You access its contents through viewsTypedArray (e.g., Float64Array, Int32Array, Uint8Array) or DataView (for mixed types at specific offsets).

Why TypedArrays are faster than plain arrays:

  • All elements are the same type and size — stored contiguously in memory, like a C array. V8 can use SIMD instructions for batch operations.
  • No boxing — a plain number[] in JavaScript can contain objects, null, undefined — V8 must check types. TypedArrays hold only the numeric type, enabling more aggressive JIT optimisation.
  • Direct interop with WebAssembly, WebGL, Web Audio, and Web Workers (SharedArrayBuffer).
// Signal processing — much faster with TypedArrays than Array
const samples = new Float32Array(44100 * 10); // 10 seconds of audio at 44.1kHz
for (let i = 0; i < samples.length; i++) {
  samples[i] = Math.sin(2 * Math.PI * 440 * i / 44100); // 440 Hz tone
}

// Image processing
const imageData = ctx.getImageData(0, 0, width, height);
const pixels = new Uint8ClampedArray(imageData.data.buffer); // view of RGBA bytes
for (let i = 0; i < pixels.length; i += 4) {
  const gray = 0.299 * pixels[i] + 0.587 * pixels[i+1] + 0.114 * pixels[i+2];
  pixels[i] = pixels[i+1] = pixels[i+2] = gray; // greyscale
}

// SharedArrayBuffer — share memory across Worker Threads (no postMessage copy)
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 1000);
const sharedArray = new Int32Array(sharedBuffer);
// Pass sharedBuffer to worker — both access the same memory
// Use Atomics for thread-safe operations
Atomics.add(sharedArray, 0, 1); // atomic increment
6What are the JavaScript engine optimisations for common array operations? When do arrays become slow?

V8 represents arrays internally in different modes depending on their contents. Arrays transition between these modes as elements are added or changed:

  • SMI_ELEMENTS — all small integers. Most compact, fastest arithmetic.
  • DOUBLE_ELEMENTS — all doubles (floats). Stored as unboxed doubles — very fast.
  • OBJECT_ELEMENTS — mixed types or objects. Each element is a pointer. Slower, GC overhead.
  • Dictionary mode (HOLEY) — sparse arrays with holes (new Array(1000000)) or after delete arr[i]. Stored as a hash map — much slower.
// Fast — homogeneous types, SMI elements
const nums = [1, 2, 3, 4, 5]; // SMI_ELEMENTS

// Degrades to OBJECT_ELEMENTS — avoid mixing types in hot arrays
nums.push('hello'); // now OBJECT_ELEMENTS — slower!

// Creates HOLEY_SMI_ELEMENTS — avoid holes
const sparse = new Array(1000); // all holes — dictionary mode
sparse[999] = 42;               // still mostly holes

// Prefer pre-allocated typed arrays for numeric data
const typed = new Int32Array(1000); // always fast, no mode transitions

// delete creates holes — don't use on array indices
const arr = [1, 2, 3, 4, 5];
delete arr[2]; // creates hole at index 2 → HOLEY mode, slower
arr.splice(2, 1); // correct way — removes element, no hole

For performance-critical numeric code: use TypedArrays, avoid mixed-type arrays, avoid holes, avoid modifying array structure in hot loops. Use --allow-natives-syntax and %HasSmiElements(arr) in Node.js to inspect array internals during profiling.

Patterns & Architecture

8 questions
1What is functional programming in JavaScript? Explain pure functions, immutability, and function composition.

Functional programming (FP) treats computation as evaluation of mathematical functions, avoiding mutable state and side effects.

Pure functions — given the same input, always produce the same output. Have no side effects (no mutation of external state, no I/O). Easy to test (no setup/teardown), memoizable, and safe to run in parallel.

Immutability — data structures are never modified; instead, new structures with the changes are created. Prevents accidental mutation bugs in shared state.

Function composition — building complex operations from simpler functions. compose(f, g)(x) = f(g(x)). Pipe runs left-to-right; compose runs right-to-left.

// Pure functions
const add = (a, b) => a + b;          // pure
const addToList = (list, item) => [...list, item]; // pure — returns new array

// Impure — side effects
let count = 0;
const increment = () => ++count;       // modifies external state

// Composition
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe    = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

const processUser = pipe(
  validateUser,
  normaliseEmail,
  enrichWithDefaults,
  sanitiseForDB,
);
const result = processUser(rawUser); // each fn receives output of previous

// Immutable update patterns
const updateUser = (user, updates) => ({ ...user, ...updates }); // shallow
const updateNested = (state, id, update) => ({
  ...state,
  users: state.users.map(u => u.id === id ? { ...u, ...update } : u),
});
2What is the Observer pattern and how is it implemented natively in JavaScript? What is the difference between EventEmitter and a pub/sub system?

The Observer pattern defines a one-to-many dependency: when one object changes state, all its dependents (observers) are notified automatically.

EventEmitter (Node.js) — the subject (emitter) directly holds references to its subscribers. Observers know who they subscribe to. Tight coupling between emitter and listener.

Pub/Sub — an intermediary (message broker / event bus) decouples publishers and subscribers. Publishers don't know who the subscribers are; subscribers don't know who the publishers are. More scalable and loosely coupled.

// EventEmitter pattern
import { EventEmitter } from 'events';
class OrderService extends EventEmitter {
  async createOrder(data) {
    const order = await db.orders.create(data);
    this.emit('order:created', order);  // direct coupling
    return order;
  }
}
const orderSvc = new OrderService();
orderSvc.on('order:created', sendConfirmationEmail); // listener knows the emitter

// Pub/Sub — decoupled event bus
class EventBus {
  private channels = new Map<string, Set<Function>>();
  subscribe(channel, handler) {
    if (!this.channels.has(channel)) this.channels.set(channel, new Set());
    this.channels.get(channel).add(handler);
    return () => this.channels.get(channel)?.delete(handler); // unsubscribe fn
  }
  publish(channel, data) {
    this.channels.get(channel)?.forEach(h => h(data));
  }
}

const bus = new EventBus();
// Publisher doesn't know about EmailService
bus.publish('order.created', order);
// EmailService doesn't know about OrderService
const unsubscribe = bus.subscribe('order.created', sendConfirmationEmail);
3What is the Strategy pattern and how is it particularly natural in JavaScript?

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The client can vary the algorithm independently from the clients that use it.

In JavaScript, since functions are first-class citizens, strategies are simply functions passed as arguments — no class hierarchies needed:

// Sorting strategies
const strategies = {
  byName:  (a, b) => a.name.localeCompare(b.name),
  byAge:   (a, b) => a.age - b.age,
  byScore: (a, b) => b.score - a.score,   // descending
};

function sortUsers(users, strategy = strategies.byName) {
  return [...users].sort(strategy);         // non-mutating
}

sortUsers(users, strategies.byScore);
sortUsers(users, (a, b) => a.id - b.id);  // ad-hoc strategy

// Validation strategies
const validators = {
  email:    v => /\S+@\S+\.\S+/.test(v),
  minLen:   n => v => v.length >= n,
  maxLen:   n => v => v.length <= n,
  required: v => v != null && v !== '',
};

function validate(value, ...rules) {
  return rules.every(rule => rule(value));
}

validate(email, validators.required, validators.email, validators.maxLen(200));
4What is dependency injection in JavaScript? How do you implement it without a framework?

Dependency Injection (DI) — provide a module's dependencies from the outside rather than having it create them internally. Decouples code from specific implementations, making it testable and flexible.

// Without DI — hard to test, tightly coupled
class OrderService {
  constructor() {
    this.db = new PostgresDatabase();   // hardcoded dependency
    this.email = new SendGridClient();  // hardcoded dependency
  }
}

// With DI — constructor injection
class OrderService {
  constructor(db, emailService, logger = console) {
    this.db = db;
    this.email = emailService;
    this.logger = logger;
  }

  async createOrder(data) {
    const order = await this.db.orders.create(data);
    await this.email.sendConfirmation(order);
    this.logger.info('Order created', { orderId: order.id });
    return order;
  }
}

// Test — inject mocks
const mockDb = { orders: { create: jest.fn().mockResolvedValue({ id: '123' }) } };
const mockEmail = { sendConfirmation: jest.fn() };
const service = new OrderService(mockDb, mockEmail);

// Production
const service = new OrderService(postgresDb, sendgridClient, logger);

// Simple DI container (manual wiring)
function createContainer() {
  const db    = new PostgresDatabase(process.env.DB_URL);
  const email = new SendGridClient(process.env.SENDGRID_KEY);
  const log   = pino();
  return {
    orderService: new OrderService(db, email, log),
    userService:  new UserService(db, log),
  };
}
5What is the builder pattern and how do you implement it in JavaScript/TypeScript?

The Builder pattern constructs complex objects step by step via a fluent interface (method chaining). Each method returns this (or a new builder instance for immutability). Avoids constructors with many parameters (telescoping constructor anti-pattern).

class QueryBuilder {
  #table: string = '';
  #conditions: string[] = [];
  #columns: string[] = ['*'];
  #orderBy: string = '';
  #limitVal?: number;
  #offsetVal?: number;

  from(table: string): this { this.#table = table; return this; }
  select(...cols: string[]): this { this.#columns = cols; return this; }
  where(condition: string): this { this.#conditions.push(condition); return this; }
  orderBy(col: string, dir: 'ASC'|'DESC' = 'ASC'): this {
    this.#orderBy = `ORDER BY ${col} ${dir}`; return this;
  }
  limit(n: number): this { this.#limitVal = n; return this; }
  offset(n: number): this { this.#offsetVal = n; return this; }

  build(): string {
    if (!this.#table) throw new Error('Table is required');
    const parts = [
      `SELECT ${this.#columns.join(', ')}`,
      `FROM ${this.#table}`,
      this.#conditions.length ? `WHERE ${this.#conditions.join(' AND ')}` : '',
      this.#orderBy,
      this.#limitVal  ? `LIMIT ${this.#limitVal}`   : '',
      this.#offsetVal ? `OFFSET ${this.#offsetVal}` : '',
    ];
    return parts.filter(Boolean).join(' ');
  }
}

const query = new QueryBuilder()
  .from('users')
  .select('id', 'name', 'email')
  .where('age > 18')
  .where('active = true')
  .orderBy('created_at', 'DESC')
  .limit(20)
  .offset(40)
  .build();
// "SELECT id, name, email FROM users WHERE age > 18 AND active = true ORDER BY created_at DESC LIMIT 20 OFFSET 40"
6What is the Command pattern? How does it enable undo/redo functionality?

The Command pattern encapsulates a request as an object with an execute and undo method. Commands can be queued, logged, and reversed, enabling undo/redo, macro recording, and transactional operations.

interface Command {
  execute(): void;
  undo(): void;
}

class TextEditor {
  private content = '';
  private history: Command[] = [];
  private redoStack: Command[] = [];

  execute(command: Command): void {
    command.execute();
    this.history.push(command);
    this.redoStack = []; // clear redo on new command
  }

  undo(): void {
    const cmd = this.history.pop();
    if (!cmd) return;
    cmd.undo();
    this.redoStack.push(cmd);
  }

  redo(): void {
    const cmd = this.redoStack.pop();
    if (!cmd) return;
    cmd.execute();
    this.history.push(cmd);
  }
}

class InsertTextCommand implements Command {
  constructor(
    private editor: { content: string },
    private text: string,
    private position: number
  ) {}

  execute() {
    const { content } = this.editor;
    this.editor.content =
      content.slice(0, this.position) + this.text + content.slice(this.position);
  }

  undo() {
    const { content } = this.editor;
    this.editor.content =
      content.slice(0, this.position) + content.slice(this.position + this.text.length);
  }
}
7What is Domain-Driven Design (DDD) and how do you apply its concepts in a JavaScript/TypeScript backend?

DDD is an approach to software design that models the software around the business domain. Key concepts:

  • Entity — objects with a unique identity that persists over time (User, Order). Identity matters more than attributes.
  • Value Object — immutable objects defined by their attributes, not identity (Money, Address, EmailAddress). Two value objects with the same values are equal.
  • Aggregate — cluster of entities/value objects treated as a unit. Has an Aggregate Root (the entry point for all interactions). Invariants are enforced within the aggregate boundary.
  • Repository — abstraction over the persistence layer. Provides collection-like interface for aggregates.
  • Domain Service — business logic that doesn't naturally belong to an entity or value object.
  • Domain Event — a record of something that happened in the domain (OrderPlaced, PaymentProcessed).
// Value Object — immutable, equality by value
class Money {
  constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {
    if (amount < 0) throw new Error('Amount cannot be negative');
    Object.freeze(this);
  }
  add(other: Money): Money {
    if (this.currency !== other.currency) throw new Error('Currency mismatch');
    return new Money(this.amount + other.amount, this.currency);
  }
  equals(other: Money) { return this.amount === other.amount && this.currency === other.currency; }
}

// Entity
class Order {
  private items: OrderItem[] = [];
  private status: 'draft'|'confirmed'|'shipped' = 'draft';
  readonly domainEvents: DomainEvent[] = [];

  constructor(readonly id: OrderId, readonly customerId: CustomerId) {}

  addItem(productId: ProductId, qty: number, price: Money): void {
    if (this.status !== 'draft') throw new Error('Cannot modify confirmed order');
    this.items.push(new OrderItem(productId, qty, price));
  }

  confirm(): void {
    if (this.items.length === 0) throw new Error('Cannot confirm empty order');
    this.status = 'confirmed';
    this.domainEvents.push(new OrderConfirmed(this.id, this.total()));
  }

  total(): Money {
    return this.items.reduce((sum, item) => sum.add(item.lineTotal()), new Money(0, 'USD'));
  }
}
8What is the difference between imperative and declarative programming in JavaScript? Give concrete examples.

Imperative — specifies how to achieve a result step by step. Describes the control flow explicitly.

Declarative — specifies what the result should be, letting the system figure out how. Hides control flow details.

const orders = [
  { id: 1, status: 'shipped',  total: 120 },
  { id: 2, status: 'pending',  total: 80  },
  { id: 3, status: 'shipped',  total: 200 },
  { id: 4, status: 'cancelled', total: 50 },
];

// Imperative — explicit loops and mutation
let shippedTotal = 0;
const shippedIds = [];
for (let i = 0; i < orders.length; i++) {
  if (orders[i].status === 'shipped') {
    shippedTotal += orders[i].total;
    shippedIds.push(orders[i].id);
  }
}

// Declarative — describe the transformation
const shipped = orders.filter(o => o.status === 'shipped');
const shippedTotal = shipped.reduce((sum, o) => sum + o.total, 0);
const shippedIds   = shipped.map(o => o.id);

// SQL is declarative — you describe what, not how
// SELECT id FROM orders WHERE status = 'shipped'

// React JSX is declarative — you describe what UI should look like
// React figures out how to update the DOM
<OrderList orders={shippedOrders} />

JavaScript supports both paradigms. Declarative code is generally more readable, easier to reason about, and less error-prone (no off-by-one errors, no mutation). Functional array methods (filter, map, reduce) are the most common declarative pattern in JavaScript. Use imperative code for performance-critical loops where the overhead of function calls and intermediate allocations matters.

Testing

6 questions
1What is the difference between unit, integration, and end-to-end tests? How do you balance them in a JS project?
  • Unit tests — test a single function or class in isolation. All dependencies are mocked. Fast (<1ms), deterministic. Limited confidence — mocks diverge from real behaviour. Good for: pure functions, algorithms, business logic without I/O.
  • Integration tests — test multiple components working together, often with real infrastructure (real database, real HTTP). Slower but much higher confidence. Good for: repository methods against a test database, API endpoint tests against a real Express/Fastify server.
  • E2E tests — test the entire system from a user's perspective. Browser automation (Playwright, Cypress). Slowest, most brittle, highest confidence. Good for: critical user journeys (checkout, signup, login).

Balance for a Node.js API:

  • Many unit tests for domain logic and pure functions.
  • Integration tests for each route against a test database (prefer Postgres with transactions rolled back after each test, or Docker containers).
  • A handful of E2E tests for critical paths against a staging environment.

TypeScript's static type system already catches an entire class of bugs that unit tests would otherwise cover — factor this into your test investment decisions.

2How do you write effective tests with Vitest or Jest? What are mocks, spies, and stubs?
  • Stub — a simple replacement that returns predetermined values. No assertions on how it was called.
  • Mock — a replacement that additionally asserts it was called correctly (call count, arguments). Has built-in expectations.
  • Spy — wraps the real implementation, recording calls. The real function still runs. Use to assert on behaviour of existing code without replacing it.
import { describe, it, expect, vi, beforeEach } from 'vitest';

describe('OrderService', () => {
  let db, emailService, service;

  beforeEach(() => {
    db = {
      orders: {
        create: vi.fn().mockResolvedValue({ id: '123', total: 100 }),
        findById: vi.fn(),
      }
    };
    emailService = { sendConfirmation: vi.fn().mockResolvedValue(undefined) };
    service = new OrderService(db, emailService);
  });

  it('creates an order and sends confirmation', async () => {
    const order = await service.createOrder({ items: [{ id: 1, qty: 2 }] });

    expect(order.id).toBe('123');
    expect(db.orders.create).toHaveBeenCalledOnce();
    expect(db.orders.create).toHaveBeenCalledWith(
      expect.objectContaining({ items: expect.any(Array) })
    );
    expect(emailService.sendConfirmation).toHaveBeenCalledWith(order);
  });

  it('propagates database errors', async () => {
    db.orders.create.mockRejectedValue(new Error('DB connection failed'));
    await expect(service.createOrder({})).rejects.toThrow('DB connection failed');
  });
});

Vitest vs Jest: Vitest is a Vite-native test runner — much faster due to ESM native support, no transpilation, and sharing Vite's config. Almost identical API to Jest (designed as a drop-in replacement). Prefer Vitest for new projects using Vite, Nuxt, or SvelteKit. Jest is still dominant for legacy projects and has a larger plugin ecosystem.

3What is property-based testing and how does it complement example-based testing?

Example-based testing — you specify the inputs and expected outputs manually. You only find bugs for the examples you thought to write.

Property-based testing — define properties (invariants) that should hold for all valid inputs. The framework generates hundreds of random inputs and tries to find a counterexample that falsifies the property.

import { fc, test } from '@fast-check/vitest';

// Example-based — only tests these specific cases
test('reverse of reverse is identity', () => {
  expect(reverse(reverse([1, 2, 3]))).toEqual([1, 2, 3]);
});

// Property-based — tests for ALL arrays of integers
test.prop([fc.array(fc.integer())])('reverse of reverse is identity', (arr) => {
  expect(reverse(reverse(arr))).toEqual(arr);
});

test.prop([fc.array(fc.integer())])('sort is idempotent', (arr) => {
  const sorted = arr.slice().sort((a, b) => a - b);
  expect(sorted.slice().sort((a, b) => a - b)).toEqual(sorted);
});

test.prop([fc.string(), fc.string()])('string concat length', (a, b) => {
  expect((a + b).length).toBe(a.length + b.length);
});

// Shrinking — when fast-check finds a failing case, it automatically
// reduces it to the smallest failing example, making debugging easier

Property-based tests find edge cases you didn't think of (empty arrays, negative numbers, strings with unicode, very long inputs). Use alongside example-based tests — properties verify general invariants, examples document specific expected behaviour.

4How do you test Node.js APIs effectively? What is the best approach for database integration tests?

API testing with supertest:

import request from 'supertest';
import { app } from '../src/app';
import { db } from '../src/db';

describe('POST /api/users', () => {
  beforeEach(async () => {
    await db.query('BEGIN'); // wrap each test in a transaction
  });
  afterEach(async () => {
    await db.query('ROLLBACK'); // rollback — no test pollution
  });

  it('creates a user and returns 201', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${testToken}`)
      .send({ name: 'Alice', email: 'alice@test.com' })
      .expect(201);

    expect(res.body.id).toBeDefined();
    expect(res.body.email).toBe('alice@test.com');
    // Verify persisted in DB
    const dbUser = await db.users.findById(res.body.id);
    expect(dbUser).toBeTruthy();
  });

  it('returns 409 for duplicate email', async () => {
    await db.users.create({ email: 'alice@test.com' });
    await request(app)
      .post('/api/users')
      .send({ name: 'Alice2', email: 'alice@test.com' })
      .expect(409);
  });
});

Database strategies:

  • Transaction rollback — wrap each test in a transaction and rollback. Fast, no cleanup needed. Works for most SQL databases. Doesn't work for tests that need to commit (testing triggers, replication).
  • Docker containers per test run — testcontainers library starts a fresh DB in Docker. Clean slate. Slower setup (~5s) but isolated. Use for CI.
  • Test schema/database — a dedicated test database. Truncate tables after each test. Less isolated but fast.
5What is test-driven development (TDD)? What is the red-green-refactor cycle and when is TDD most valuable?

TDD — write a failing test first, then write the minimum code to make it pass, then refactor. The discipline that tests drive the design.

Red-Green-Refactor:

  1. Red — write a test for the next small piece of functionality. It must fail (confirms the test is actually testing something).
  2. Green — write the simplest code that makes the test pass. Don't over-engineer.
  3. Refactor — clean up the code while keeping all tests green. Remove duplication, improve naming, extract functions.

TDD is most valuable for:

  • Complex business logic with many edge cases — the tests become executable specifications.
  • Algorithms and data structures where correctness is critical.
  • APIs and interfaces that need to be designed to be usable — using the API in a test first reveals awkward interfaces before implementation.

TDD is less suited for:

  • UI/UX exploration — hard to write tests for something you don't yet know what it should look like.
  • Integration with external systems — tests require real or mocked infrastructure.
  • Throw-away prototyping — when the goal is to learn, not to deliver production code.
6What is code coverage and what are its limitations? How do you use it effectively?

Code coverage measures which lines/branches/functions are executed during tests. Vitest and Jest produce coverage reports with --coverage. Types:

  • Line coverage — which lines executed.
  • Branch coverage — which branches of conditionals taken (both true and false).
  • Function coverage — which functions called.
  • Statement coverage — which statements executed (finer than line coverage).

Limitations — the most important thing to understand about coverage:

  • 100% coverage does not mean the code is correct. Tests can execute every line without asserting anything meaningful.
  • Coverage measures what code is run, not what is verified. A test that calls a function but ignores its return value counts as 100% covered.
  • Chasing coverage numbers leads to low-quality tests — tests written to cover lines, not to verify behaviour.
// vitest.config.ts
export default {
  test: {
    coverage: {
      provider: 'v8',       // or 'istanbul'
      thresholds: {
        lines: 80,
        branches: 75,
        functions: 80,
        statements: 80,
      },
      exclude: ['**/*.config.*', 'src/migrations/**'],
    },
  },
};

Effective use: use coverage to find gaps (code paths with no tests), not to hit a number. Focus on branch coverage — uncovered branches often represent unhandled edge cases. Mutation testing (Stryker) is a stronger quality signal — it verifies tests actually catch bugs, not just execute lines.

Security

6 questions
1What is prototype pollution and how do you defend against it in Node.js applications?

Prototype pollution is a vulnerability where an attacker can add or modify properties on Object.prototype through carefully crafted input. Any code that later accesses those properties on ordinary objects is affected — potentially enabling denial of service, authentication bypass, or remote code execution.

// Vulnerable pattern — deep merge without sanitisation
function deepMerge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object') {
      target[key] = target[key] || {};
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}

// Attacker-controlled input:
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');
deepMerge({}, payload);

// Now ALL objects have isAdmin:
const user = {};
console.log(user.isAdmin); // true — prototype polluted!

Defences:

  • Sanitise keys — check for __proto__, constructor, prototype in any user-supplied key:
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = target[key] || {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}

// Use Object.create(null) for hash maps — no prototype to pollute
const safe = Object.create(null);
safe['__proto__'] = 'harmless'; // just a property on the object

// Use structuredClone — safe deep clone, no prototype pollution
// Validate with JSON Schema before processing user input
// Use libraries like lodash 4.17.21+ which patch this vulnerability
2How do you prevent SQL injection and other injection attacks in a Node.js backend?

SQL injection — user input is interpreted as SQL code rather than data. The gold standard defence is parameterised queries (prepared statements).

// NEVER — string interpolation into SQL
const query = `SELECT * FROM users WHERE email = '${userInput}'`;
// Attacker input: ' OR 1=1; DROP TABLE users; --

// ALWAYS — parameterised queries
import { Pool } from 'pg';
const pool = new Pool();

// pg — $1 placeholders, values array
const { rows } = await pool.query(
  'SELECT * FROM users WHERE email = $1 AND active = $2',
  [userInput, true]  // never interpolated into the query string
);

// Drizzle ORM / Prisma — parameterised by default
const user = await db.select().from(users).where(eq(users.email, userInput));

// Command injection — avoid exec/eval with user input
import { execFile } from 'child_process';  // NOT exec
execFile('convert', ['-resize', '100x100', inputFile, outputFile], callback);
// execFile takes args as an array — no shell interpolation

// Path traversal — sanitise file paths
import { resolve, join } from 'path';
const safePath = resolve('/allowed/dir', userInput);
if (!safePath.startsWith('/allowed/dir')) throw new Error('Path traversal detected');
3How do you implement secure authentication in a Node.js API? Cover password hashing, JWT, and session management.

Password hashing: never store plaintext or reversible-encrypted passwords. Use bcrypt, scrypt, or Argon2 — all are slow by design, preventing brute-force attacks.

import bcrypt from 'bcrypt';
const ROUNDS = 12; // 2^12 iterations — tune for ~100ms on your hardware

// Registration
const hash = await bcrypt.hash(password, ROUNDS);
await db.users.create({ email, passwordHash: hash });

// Login
const match = await bcrypt.compare(submittedPassword, storedHash);
if (!match) throw new UnauthorisedError('Invalid credentials');
// Constant-time comparison — prevents timing attacks

JWT security:

import jwt from 'jsonwebtoken';

// Sign with strong secret (256-bit min for HS256) or RSA keypair
const token = jwt.sign(
  { sub: user.id, role: user.role }, // minimal claims — no sensitive data
  process.env.JWT_SECRET,
  { expiresIn: '15m', algorithm: 'HS256' }
);

// Verify — always specify algorithms to prevent algorithm confusion
try {
  const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} catch (e) { /* TokenExpiredError, JsonWebTokenError */ }

// Store tokens: access token in memory (not localStorage — XSS vulnerable)
// Refresh token in HttpOnly Secure SameSite=Strict cookie
// Implement refresh token rotation — invalidate on use, detect reuse

Session management best practices: regenerate session ID after login (prevents session fixation), set absolute and idle timeouts, invalidate server-side on logout (don't rely only on client deleting the cookie), use secure session store (Redis with TTL, not in-memory).

4What is rate limiting and how do you implement it in a Node.js API? How do you handle distributed rate limiting?

Rate limiting protects against brute-force attacks, credential stuffing, API abuse, and DoS. Three main algorithms:

  • Fixed window — allow N requests per time window. Simple but allows burst at window boundaries.
  • Sliding window — smoother, counts requests in the last N seconds at any point.
  • Token bucket — a bucket fills at a constant rate (e.g., 10 tokens/second). Each request consumes a token. Allows bursts up to bucket size. Natural for bursty traffic.
// Express with express-rate-limit
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

// Global rate limit
app.use(rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({ client: redis }), // distributed — works across multiple instances
}));

// Tighter limit for auth endpoints
app.use('/api/auth', rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,           // 5 attempts per 15 min
  skipSuccessfulRequests: true, // only count failed attempts
  store: new RedisStore({ client: redis, prefix: 'rl:auth:' }),
}));

// Key strategy — rate limit by IP + user ID for authenticated routes
// to prevent one bad actor from affecting all users

Distributed rate limiting requires a shared store (Redis) — otherwise each Node.js instance has its own counter and the limit is effectively multiplied by the number of instances. Always use Redis (or similar) for production rate limiting in a horizontally-scaled deployment.

5What is supply chain security for JavaScript? What threats exist in the npm ecosystem and how do you mitigate them?

The npm ecosystem is a significant attack surface — over 1 million packages, deep dependency trees (a single package can have 1000+ transitive dependencies).

Common threats:

  • Malicious packages — typosquatting (publishing lodahs hoping developers mistype lodash), dependency confusion attacks (publishing a package with the same name as an internal package).
  • Compromised maintainer accounts — an attacker gains control of a popular package's npm account and publishes a malicious version. Real incidents: event-stream (2018), ua-parser-js (2021), node-ipc (2022).
  • Postinstall scripts — packages with postinstall scripts execute arbitrary code during npm install.

Mitigations:

  • Lock files — commit package-lock.json or yarn.lock. Use npm ci (not npm install) in CI — installs exact locked versions.
  • Audit — run npm audit in CI, fail on high/critical vulnerabilities. Automate with GitHub Dependabot or Snyk.
  • Minimise dependencies — every dependency is a potential attack vector. Prefer small, well-maintained packages. Question whether you really need that package.
  • Pin versions — use exact versions ("lodash": "4.17.21") rather than ranges in production.
  • Ignore postinstallnpm install --ignore-scripts to skip postinstall scripts in production builds.
  • Private registry — use npm Enterprise or Verdaccio to proxy and audit packages before they reach your developers.
6How do you securely handle secrets and environment variables in Node.js applications?

What not to do:

  • Commit secrets to Git — even in private repos (history is permanent; secrets rotated but committed version is visible in git log).
  • Log environment variables — error reporters and log aggregators often capture process env in crash reports.
  • Expose secrets through API responses or error messages.
  • Use the same secrets across environments.

Best practices:

// .env — development only, never committed
DATABASE_URL=postgres://localhost/myapp_dev
JWT_SECRET=local-dev-secret-not-real

// .gitignore — always ignore .env files
.env
.env.local
.env.*.local

// Validate env at startup — fail fast if missing
import { z } from 'zod';
const Env = z.object({
  DATABASE_URL:  z.string().url(),
  JWT_SECRET:    z.string().min(32), // enforce minimum secret length
  PORT:          z.coerce.number().default(3000),
  NODE_ENV:      z.enum(['development', 'test', 'production']),
  REDIS_URL:     z.string().url(),
});

export const env = Env.parse(process.env); // throws on startup if invalid

// Production — inject secrets from secret management
// AWS: use SSM Parameter Store or Secrets Manager — fetch at startup
// Kubernetes: secrets injected as env vars or mounted files
// Never hardcode in Dockerfile or docker-compose.yml

Secret scanning: enable GitHub secret scanning and push protection. Use gitleaks as a pre-commit hook to prevent accidental commits. Rotate any secret that may have been exposed — assume it is compromised.