Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

JavaScript Interview Questions (Fundamentals)

A comprehensive guide to JavaScript interview questions, organized by difficulty level. This collection covers fundamental concepts to advanced topics commonly asked in web development interviews. Every question includes what the interviewer is really testing, detailed answers with real-world context, red flag responses, and follow-up chains that mirror how actual interviews probe for depth.

Easy Level Questions

What interviewers are really testing: Whether you understand JavaScript’s role in the broader ecosystem beyond “it makes web pages interactive” — they want to hear about the runtime model, single-threaded nature, and its evolution from a browser toy to a full-stack language.Answer:JavaScript is a high-level, dynamically-typed, single-threaded programming language that was originally designed to add interactivity to web pages but has evolved into one of the most versatile languages in software engineering.Why it matters beyond the textbook definition:
  • Single-threaded with an event loop: Unlike Java or C++, JS runs on one thread but uses an event-driven, non-blocking I/O model. This is the architectural foundation that makes Node.js capable of handling 10,000+ concurrent connections on a single process — something that a thread-per-request model like traditional Java servlets struggles with at scale.
  • Interpreted and JIT-compiled: Modern engines like V8 (Chrome/Node.js), SpiderMonkey (Firefox), and JavaScriptCore (Safari) use Just-In-Time compilation. V8 compiles JavaScript directly to native machine code before executing it, which is why Node.js can compete with compiled languages for I/O-heavy workloads.
  • Prototype-based inheritance: Unlike classical OOP languages, JavaScript uses prototypal inheritance. Every object has a hidden [[Prototype]] link. This is fundamental to understanding how the language works under the hood.
Common Uses with real-world scale:
  • Frontend web development: React (Meta), Angular (Google), Vue — these frameworks power apps serving billions of users. Netflix’s entire TV UI runs on React.
  • Backend development: Node.js powers PayPal’s backend (which saw a 35% decrease in average response time after migrating from Java), LinkedIn’s mobile backend, and Walmart’s server-side rendering.
  • Mobile apps: React Native (Instagram, Discord, Shopify), Ionic, NativeScript.
  • Desktop apps: Electron powers VS Code, Slack, and Discord desktop clients.
  • Serverless/Edge computing: Cloudflare Workers, AWS Lambda, Vercel Edge Functions — all run JavaScript at the edge with sub-millisecond cold starts.
  • IoT and embedded: Johnny-Five framework for Arduino, Espruino for microcontrollers.
Red flag answer: “JavaScript is a scripting language for making websites interactive.” This is a 2005-era answer that misses the last 15+ years of evolution. It signals the candidate hasn’t worked with JS in production beyond simple DOM manipulation.Follow-up questions:Q: What does “single-threaded” actually mean in practice, and how does JavaScript handle concurrency if it only has one thread?JavaScript’s single thread means only one piece of code executes at any given moment on the main thread. However, concurrency is achieved through the event loop and Web APIs (in browsers) or libuv (in Node.js). When you call fetch() or setTimeout(), the actual I/O work is delegated to the OS kernel or a thread pool (libuv maintains a default pool of 4 threads in Node.js). The main thread is free to process other code. When the I/O completes, a callback is placed in the task queue, and the event loop picks it up when the call stack is empty. This is why Node.js can handle 50K concurrent WebSocket connections on a single process — it’s not creating 50K threads, it’s multiplexing I/O events on one thread.Q: If JavaScript is single-threaded, what are Web Workers and how do they fit in?Web Workers provide true multi-threading in browsers. A Worker runs in a separate OS thread with its own global scope — it cannot access the DOM or share memory directly with the main thread (they communicate via postMessage()). This is by design to avoid race conditions. In Node.js, the equivalent is worker_threads. A practical use case: Figma uses Web Workers extensively to run their design engine computations off the main thread so the UI stays responsive at 60fps. The key constraint is that communication between workers uses structured cloning (serialization), so passing large objects has overhead. SharedArrayBuffer exists for shared memory but requires careful synchronization with Atomics.Q: How does JavaScript compare to TypeScript, and when would you choose one over the other?TypeScript is a strict superset of JavaScript that adds static type checking at compile time. Every valid JS file is valid TS, but not vice versa. In practice, you’d choose TypeScript for any project with more than 2-3 developers or any codebase expected to live longer than 6 months. The type system catches entire categories of bugs at compile time — Airbnb reported that 38% of bugs they studied could have been prevented by TypeScript. The tradeoff is slightly slower development velocity on small prototypes and a learning curve for the type system’s advanced features (conditional types, mapped types, template literal types). For a weekend hackathon, plain JS is fine. For a production API serving millions of requests, TypeScript is practically non-negotiable in the modern ecosystem.
What interviewers are really testing: Whether you know template literals go far beyond string interpolation — tagged templates are a powerful metaprogramming feature used by libraries like styled-components, GraphQL (gql tag), and lit-html.Answer:Template literals (introduced in ES6) use backticks ` instead of quotes and support string interpolation with ${expression} syntax, multi-line strings, and tagged templates for advanced string processing.Basic string interpolation:
let a = 10;
let b = 20;

// Old way - concatenation (error-prone, hard to read)
console.log("The sum of " + a + " and " + b + " is " + (a + b));

// Template literal - clean and readable
console.log(`The sum of ${a} and ${b} is ${a + b}`);
Multi-line strings (no more \n):
// Old way
const html = '<div>\n' +
  '  <h1>Title</h1>\n' +
  '  <p>Content</p>\n' +
  '</div>';

// Template literal - preserves formatting
const html = `
  <div>
    <h1>Title</h1>
    <p>Content</p>
  </div>
`;
Expressions inside ${} — anything that evaluates:
const user = { name: 'Alice', age: 30 };
console.log(`${user.name} is ${user.age > 18 ? 'an adult' : 'a minor'}`);
// "Alice is an adult"

console.log(`Total: $${(19.99 * 3).toFixed(2)}`);
// "Total: $59.97"
Tagged templates — the advanced feature most candidates miss:
function highlight(strings, ...values) {
  return strings.reduce((result, str, i) => {
    return result + str + (values[i] ? `<mark>${values[i]}</mark>` : '');
  }, '');
}

const name = 'Alice';
const role = 'admin';
const output = highlight`User ${name} has role ${role}`;
// "User <mark>Alice</mark> has role <mark>admin</mark>"
Real-world tagged template usage:
  • styled-components: styled.div`color: ${props => props.primary ? 'blue' : 'gray'};` — generates CSS-in-JS
  • GraphQL: gql`query { users { name } }` — parses GraphQL queries at build time
  • SQL injection prevention: Libraries like sql use tagged templates to auto-parameterize queries: sql`SELECT * FROM users WHERE id = ${userId}` becomes a parameterized query, not a raw string
Red flag answer: “Template literals use backticks and dollar signs for variables.” This only covers Layer 1. Candidates who don’t mention tagged templates, multi-line support, or expression evaluation are showing surface-level knowledge.Follow-up questions:Q: How would tagged templates help prevent XSS or SQL injection?Tagged templates give you a function that receives the static string parts and dynamic values separately. This separation is the key insight — you can sanitize every dynamic value before interpolating it. For example, a hypothetical safeHTML tagged template would receive strings = ['<div>', '</div>'] and values = [userInput] separately, allowing you to HTML-encode userInput before combining. Libraries like lit-html do exactly this for safe DOM rendering. For SQL, slonik and sql-template-tag use this pattern to ensure every ${variable} is treated as a parameterized value, never as raw SQL. It’s an architectural pattern that makes the secure path the default path.Q: What happens if you nest template literals?It works perfectly. Since everything inside ${} is an expression, and a template literal is an expression, you can nest them: `Hello ${`world ${name}`}` evaluates to "Hello world Alice". This is commonly used in conditional rendering patterns in React JSX alternatives or when building complex strings conditionally. However, deep nesting hurts readability — if you’re nesting more than one level, extract to a variable or function.Q: How do template literals affect performance compared to string concatenation?In modern engines (V8, SpiderMonkey), the performance difference is negligible for typical use cases — both are optimized heavily. V8 internally optimizes template literals similarly to concatenation. Where you see a real difference is in tagged templates with complex processing logic, since the tag function runs on every evaluation. In hot loops processing millions of iterations, benchmark first. But for 99% of real-world code, readability wins over micro-optimization. The bigger performance concern is creating strings in hot paths at all versus using string builders or array joins for very large string assembly.
What interviewers are really testing: Whether you understand the two-phase execution model of JavaScript (creation phase vs. execution phase), the Temporal Dead Zone, and how this knowledge prevents real bugs in production code.Answer:Hoisting is JavaScript’s behavior during the creation phase where variable and function declarations are processed before any code runs. The key insight is that JavaScript doesn’t physically move code — the engine creates memory space for declarations during compilation, then executes code top-to-bottom.The two-phase model (what actually happens):
  1. Creation phase: The engine scans the code and allocates memory for all declarations. var variables get undefined, let/const get marked as “uninitialized,” and function declarations get the entire function body.
  2. Execution phase: Code runs line by line, assigning values and executing statements.
Function hoisting (fully hoisted):
greet(); // Works! Outputs: "Hello"

function greet() {
    console.log("Hello");
}
The entire function body is available before execution. This is why you can call functions before their declaration in JavaScript — a deliberate language design choice.Variable hoisting — the trap:
console.log(x); // undefined (declared, initialized to undefined)
var x = 10;

console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;

console.log(z); // ReferenceError: Cannot access 'z' before initialization
const z = 30;
The Temporal Dead Zone (TDZ) — the critical concept:
// TDZ for 'name' starts here
console.log(name); // ReferenceError -- we're in the TDZ
let name = 'Alice'; // TDZ ends here, 'name' is initialized

// This catches real bugs:
let count = 1;
if (true) {
    // TDZ for inner 'count' starts here
    console.log(count); // ReferenceError, NOT 1!
    let count = 2; // TDZ ends
}
The TDZ exists from the start of the block until the let/const declaration is reached. This is intentional — it prevents you from accidentally using a variable before it’s been assigned a meaningful value.Function expressions are NOT fully hoisted:
sayHello(); // TypeError: sayHello is not a function
var sayHello = function() {
    console.log("Hello");
};
// 'sayHello' is hoisted as undefined (it's a var),
// but the function assignment hasn't happened yet

greetArrow(); // ReferenceError: Cannot access before initialization
const greetArrow = () => console.log("Hi");
// const is in TDZ
The classic interview gotcha — hoisting in loops:
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// Outputs: 3, 3, 3 (not 0, 1, 2!)
// 'var i' is function-scoped, so there's only ONE 'i'

for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// Outputs: 0, 1, 2
// 'let i' creates a new binding per iteration
Red flag answer: “Hoisting means JavaScript moves your code to the top of the file.” This is a misconception. Nothing moves. The engine processes declarations during compilation. A candidate who says this doesn’t understand the execution model. Another red flag: not knowing what TDZ is or that let/const are technically hoisted too (just not initialized).Follow-up questions:Q: If let and const are also hoisted, why do people say “only var is hoisted”?This is one of the most common misconceptions. All declarations (var, let, const, function, class) are hoisted. The difference is initialization. var is hoisted AND initialized to undefined. let/const are hoisted but left uninitialized — accessing them before declaration throws a ReferenceError because they’re in the Temporal Dead Zone. People say “only var is hoisted” because var’s hoisting has visible effects (returns undefined instead of throwing), while let/const’s hoisting is only observable through the TDZ error message (it says “Cannot access before initialization” rather than “is not defined” — proving the engine knows the variable exists).Q: How does hoisting interact with ES modules?ES modules are always in strict mode and have their own scope. Imports are hoisted and are read-only live bindings — they’re available throughout the module even before the import statement lexically. However, the imported values might not be initialized yet if there are circular dependencies. This is why circular imports can give you undefined for a value that’s later assigned. Node.js CommonJS (require) doesn’t hoist — it executes synchronously at the point of the call. This behavioral difference is a common source of bugs when migrating from CJS to ESM.Q: Can you describe a real production bug caused by hoisting?Classic scenario: a developer declares var config at the top of a module and a var config inside a callback. Because var is function-scoped (not block-scoped), the inner var config doesn’t shadow — it’s the same variable. In an async callback, the outer config gets overwritten unexpectedly, causing the entire module to use wrong configuration. At a company I know of, this exact pattern caused a payment processing module to use test API keys in production for 47 minutes because a var in a conditional block leaked into the module scope. The fix was migrating to const/let. This is why the industry standard is to ban var entirely via ESLint rules (no-var).
What interviewers are really testing: Scope understanding, mutation vs. reassignment distinction for const, and whether you’ve internalized modern JS best practices in real codebases.Answer:
Featurevarletconst
ScopeFunction scopeBlock scopeBlock scope
ReassignmentYesYesNo
Re-declarationYes (silently)No (SyntaxError)No (SyntaxError)
Hoisting behaviorHoisted, initialized to undefinedHoisted, TDZ until declarationHoisted, TDZ until declaration
Global object propertyYes (var x creates window.x)NoNo
The scope difference in action:
if (true) {
    var a = 10;    // leaks out of the block
    let b = 20;    // stays in the block
    const c = 30;  // stays in the block
}

console.log(a); // 10 (accessible - function scoped)
console.log(b); // ReferenceError (block scoped)
console.log(c); // ReferenceError (block scoped)
The critical nuance — const prevents reassignment, NOT mutation:
const user = { name: 'Alice', age: 30 };

// This WORKS -- mutating the object
user.name = 'Bob';
user.role = 'admin';
console.log(user); // { name: 'Bob', age: 30, role: 'admin' }

// This FAILS -- reassigning the binding
user = { name: 'Charlie' }; // TypeError: Assignment to constant variable

// Same with arrays:
const numbers = [1, 2, 3];
numbers.push(4);        // Works: [1, 2, 3, 4]
numbers[0] = 99;        // Works: [99, 2, 3, 4]
numbers = [5, 6, 7];    // TypeError

// To make an object truly immutable:
const frozen = Object.freeze({ name: 'Alice', age: 30 });
frozen.name = 'Bob'; // Silently fails (or throws in strict mode)
// Note: Object.freeze is SHALLOW -- nested objects are still mutable
var and the global object — a real security concern:
var apiKey = 'secret123';
console.log(window.apiKey); // 'secret123' -- exposed on the global object!

let safeKey = 'secret456';
console.log(window.safeKey); // undefined -- not on the global object
In a browser, any script on the page can access window.apiKey. This is an actual attack vector.Best practices enforced in production codebases:
  1. Use const by default — it communicates intent (“this binding won’t change”) and prevents accidental reassignment. About 90%+ of variables in well-written code should be const.
  2. Use let when you genuinely need to reassign — loop counters, accumulators, state that changes.
  3. Never use var — configure ESLint with no-var rule. There’s no modern use case where var is preferable.
  4. Most major style guides (Airbnb, Google, StandardJS) enforce const > let > var.
Red flag answer: “const means the value can’t change.” This is wrong and shows the candidate hasn’t worked with objects/arrays using const. The binding is constant, not the value. Another red flag: not knowing that var creates properties on the global object.Follow-up questions:Q: If const doesn’t make objects immutable, how do you achieve true immutability in JavaScript?There are several layers. Object.freeze() makes an object’s own properties non-writable and non-configurable, but it’s shallow — nested objects are still mutable. For deep freeze, you’d need a recursive function or a library like Immer (used by Redux Toolkit). In practice, most teams use Immer’s produce() function which gives you an immutable update pattern with mutable-looking syntax. TypeScript’s readonly modifier provides compile-time immutability but has no runtime effect. For truly immutable data structures with structural sharing (efficient memory use), libraries like Immutable.js provide persistent data structures — but the ecosystem has largely moved toward Immer because it works with plain JS objects.Q: Why does let inside a for loop create a new binding per iteration while var doesn’t?This is specified in the ECMAScript standard (Section 14.7.4.2). When the engine encounters for (let i = ...), it creates a new lexical environment for each iteration and copies the current value of i into it. With var, there’s only one variable shared across all iterations because var is function-scoped. This is why the classic setTimeout in a for loop puzzle exists — with var, all closures share the same i (which ends up at its final value), while with let, each closure captures its own i. Before let existed, the workaround was wrapping in an IIFE: (function(j) { setTimeout(() => console.log(j), 100); })(i);Q: Are there any edge cases where var actually behaves differently from let in a way that matters beyond scope?Yes. One surprising case is the switch statement: all case clauses share a single block scope, so let x = 1 in case A and let x = 2 in case B causes a SyntaxError (duplicate declaration in same scope). With var, both work fine since they’re the same function-scoped variable. The fix is wrapping each case in braces: case A: { let x = 1; ... }. Another edge case: var declarations in catch blocks leak out (catch(e) { var leaked = true; }leaked is accessible outside), while let stays contained. In eval(), var creates variables in the calling scope, while let stays within the eval scope.
What interviewers are really testing: Whether you know all 7 primitives (including Symbol and BigInt, which many candidates forget), understand pass-by-value vs. pass-by-reference, and can articulate the quirks of the type system (like typeof null === 'object').Answer:JavaScript has 7 primitive types and 1 structural type (Object). Everything else (Arrays, Functions, Dates, RegExp, Map, Set) is an Object underneath.Primitive Data Types (immutable, passed by value):
  1. Number: 64-bit IEEE 754 floating-point. There’s no separate integer type.
let age = 25;
let pi = 3.14;
let hex = 0xFF;             // 255
let infinity = Infinity;
let notANumber = NaN;       // typeof NaN === 'number' (the irony)
console.log(0.1 + 0.2);    // 0.30000000000000004 (IEEE 754 gotcha)
console.log(0.1 + 0.2 === 0.3); // false!
The 0.1 + 0.2 problem has caused real production bugs in financial calculations. Stripe, Shopify, and every payment system uses integer cents (e.g., $10.50 = 1050) to avoid floating-point errors.
  1. String: UTF-16 encoded sequence of characters. Strings are immutable.
let name = 'John';
name[0] = 'X';        // Silently fails -- strings are immutable
console.log(name);     // Still 'John'
console.log(name.length); // 4 -- but beware of emoji:
console.log('😀'.length); // 2 (surrogate pair in UTF-16)
  1. Boolean: true or false. Understand falsy values: false, 0, -0, '', null, undefined, NaN, 0n. Everything else is truthy (including [], {}, 'false', '0').
  2. Undefined: Automatically assigned to declared-but-unassigned variables, missing function parameters, and missing object properties.
  3. Null: Intentional absence of value. typeof null === 'object' is a famous bug from JavaScript’s first implementation in 1995 — values were tagged with type bits, and null’s tag matched object’s tag. It was never fixed for backward compatibility.
  4. Symbol (ES6): Guaranteed unique identifier, primarily used as object property keys to avoid name collisions.
const id = Symbol('id');
const anotherId = Symbol('id');
console.log(id === anotherId); // false -- always unique

// Real use: framework-internal properties that don't clash
const INTERNAL_STATE = Symbol('state');
obj[INTERNAL_STATE] = 'active'; // Won't clash with user-defined keys
// Symbol.iterator, Symbol.asyncIterator -- built-in well-known symbols
  1. BigInt (ES2020): Arbitrary-precision integers for values beyond Number.MAX_SAFE_INTEGER (2^53 - 1 = 9,007,199,254,740,991).
const huge = 9007199254740991n;
console.log(huge + 1n); // 9007199254740992n (correct)
console.log(9007199254740991 + 1); // 9007199254740992 (correct by luck)
console.log(9007199254740991 + 2); // 9007199254740992 (WRONG!)

// Cannot mix BigInt and Number:
// console.log(1n + 1); // TypeError
console.log(1n + BigInt(1)); // 2n
Real-world use: database IDs (Twitter/X uses snowflake IDs that exceed MAX_SAFE_INTEGER), cryptocurrency calculations, high-precision timestamps.Non-Primitive (Object) — passed by reference:
// All of these are Objects:
let obj = { name: 'John' };
let arr = [1, 2, 3];           // typeof [] === 'object'
let fn = function() {};         // typeof function(){} === 'function' (special case)
let date = new Date();          // typeof new Date() === 'object'
let regex = /abc/;              // typeof /abc/ === 'object'
let map = new Map();
let set = new Set();
Pass-by-value vs. pass-by-reference:
// Primitives: copied
let a = 10;
let b = a;
b = 20;
console.log(a); // 10 (unchanged)

// Objects: reference copied
let obj1 = { x: 10 };
let obj2 = obj1;
obj2.x = 20;
console.log(obj1.x); // 20 (changed! same reference)
Red flag answer: Forgetting Symbol or BigInt, or saying “arrays are a separate data type.” Arrays are objects with integer keys and a special length property. Another red flag: not knowing about the 0.1 + 0.2 problem — this comes up in every financial or e-commerce codebase.Follow-up questions:Q: How do you reliably check if something is an array, since typeof [] returns 'object'?Use Array.isArray(value) — it’s the only reliable way. typeof returns 'object' for arrays. instanceof Array fails across different realms (iframes, different global contexts) because each realm has its own Array constructor. Array.isArray was specifically created to solve this cross-realm problem. Under the hood, it checks the internal [[Class]] slot of the object.Q: Explain how JavaScript’s type coercion works and give an example of a production bug it could cause.JavaScript coerces types implicitly in many contexts. The rules follow the Abstract Equality Comparison Algorithm (Section 7.2.14 of the spec). For example, '5' == 5 is true because the string is coerced to a number. The dangerous real-world scenario: a form input returns the string '0', and you check if (quantity) — this is truthy (non-empty string), even though the intent was to check for a non-zero quantity. The fix: explicit comparison if (quantity !== '0' && quantity !== '') or better yet, parse it: if (Number(quantity) > 0). Another classic: [] == false is true, but if ([]) is truthy. The coercion paths are different — == triggers ToPrimitive, while if() calls ToBoolean. This is why === is the default in every major linter configuration.Q: When would you actually use Symbol in production code?Three main scenarios: (1) Library/framework internal properties that shouldn’t conflict with user code — React uses Symbol.for('react.element') to tag React elements. (2) Implementing iteration protocols — making custom objects iterable by implementing Symbol.iterator. (3) Defining custom behavior for operators via well-known symbols like Symbol.toPrimitive (controls type coercion), Symbol.hasInstance (controls instanceof), and Symbol.species (controls which constructor is used for derived objects). In day-to-day app code, you rarely create Symbols directly, but you use them implicitly every time you write a for...of loop.
What interviewers are really testing: Beyond basic array usage, they want to know if you understand that arrays are objects, performance characteristics of different operations, and which array methods mutate vs. return new arrays.Answer:An array is an ordered, indexed collection of values implemented as a special object with integer keys, a length property, and methods inherited from Array.prototype. Unlike arrays in C or Java, JavaScript arrays are dynamically sized and can hold mixed types (though this is rarely desirable).Creating arrays:
// Array literal (preferred)
const fruits = ['apple', 'banana', 'orange'];

// Array constructor (avoid -- has a gotcha)
const numbers = new Array(1, 2, 3);
const confusing = new Array(3); // Creates [empty x 3], NOT [3]!

// Array.from -- converting iterables/array-likes to arrays
const chars = Array.from('hello'); // ['h', 'e', 'l', 'l', 'o']
const divs = Array.from(document.querySelectorAll('div'));

// Array.of -- fixes the constructor gotcha
const three = Array.of(3); // [3], not [empty x 3]
Accessing and iterating:
const fruits = ['apple', 'banana', 'orange'];

// Direct index access - O(1)
console.log(fruits[0]);             // 'apple'
console.log(fruits[fruits.length - 1]); // 'orange' (last element)
console.log(fruits.at(-1));         // 'orange' (ES2022 -- cleaner)

// for...of (preferred for values)
for (const fruit of fruits) {
    console.log(fruit);
}

// forEach (no break/continue support)
fruits.forEach((fruit, index) => {
    console.log(`${index}: ${fruit}`);
});

// for...in (AVOID for arrays -- iterates enumerable properties, not just indices)
// Can include prototype properties and non-integer keys
Mutating vs. non-mutating methods (critical interview knowledge):
// MUTATING (change the original array):
// push, pop, shift, unshift, splice, sort, reverse, fill

// NON-MUTATING (return new array):
// map, filter, reduce, slice, concat, flat, flatMap, toSorted, toReversed

const nums = [3, 1, 4, 1, 5];
nums.sort();                    // MUTATES: nums is now [1, 1, 3, 4, 5]
const sorted = nums.toSorted(); // ES2023: returns new sorted array, original unchanged
Performance considerations:
const arr = [1, 2, 3, 4, 5];

arr.push(6);      // O(1) amortized -- fast, adds to end
arr.pop();        // O(1) -- fast, removes from end
arr.unshift(0);   // O(n) -- slow, shifts every element right
arr.shift();      // O(n) -- slow, shifts every element left
arr.splice(2, 1); // O(n) -- slow for large arrays at the beginning

// includes/indexOf: O(n) -- linear scan
// For frequent lookups, use a Set instead: O(1) average
const set = new Set(arr);
set.has(3); // O(1) vs arr.includes(3) which is O(n)
Red flag answer: “Arrays store elements of the same type accessed by index.” This describes C arrays, not JavaScript arrays. JavaScript arrays are objects, can hold mixed types, are dynamically sized, and have prototype methods. Another red flag: not knowing which methods mutate the original array — this causes bugs in React state management (where mutation doesn’t trigger re-renders).Follow-up questions:Q: What’s the difference between for...in and for...of for arrays, and why does it matter?for...of iterates over values using the iterator protocol (calls Symbol.iterator). for...in iterates over enumerable property keys, including inherited ones and non-integer properties. For arrays, for...in can yield unexpected results: if someone adds Array.prototype.customMethod = ..., for...in will include 'customMethod' in iteration. It also returns string keys, not numbers: '0', '1', not 0, 1. The rule: use for...of for arrays (and any iterable), for...in for plain objects (and even then, pair it with hasOwnProperty).Q: How would you efficiently remove duplicates from an array?The cleanest modern approach: [...new Set(array)] — this works for primitives and runs in O(n) time. For objects, you need a custom approach since Set compares by reference, not by value. Common patterns: array.filter((item, index, self) => self.findIndex(t => t.id === item.id) === index) — this is O(n^2) though. For performance, use a Map: create a Map keyed by the unique property, then take the values. In production, if deduplication is a frequent operation on large datasets, consider keeping data in a Map or Set from the start rather than converting back and forth.Q: What are typed arrays and when would you use them over regular arrays?Typed arrays (Int8Array, Uint32Array, Float64Array, etc.) provide raw binary data buffers with fixed types and sizes. They’re backed by ArrayBuffer and are essential for WebGL/GPU computing, Web Audio API, binary protocol parsing (WebSocket binary frames), image/video processing (Canvas getImageData returns a Uint8ClampedArray), and WebAssembly interop. They’re much more memory-efficient than regular arrays for numeric data — a Float64Array(1000) uses exactly 8KB, while a regular array of 1000 numbers uses significantly more due to object overhead per element. Typed arrays don’t have most Array methods (push, map returns typed array, no splice), so they’re specialized tools, not general-purpose replacements.
What interviewers are really testing: Whether you understand the Abstract Equality Comparison Algorithm, can predict coercion outcomes, and know why === is the default in production code.Answer:=== (strict equality) compares both type and value with no coercion. == (loose equality) performs type coercion before comparing, following a complex algorithm defined in the ECMAScript spec (Section 7.2.14).
OperatorNameType CoercionExample
==Abstract equalityYes'5' == 5 is true
===Strict equalityNo'5' === 5 is false
The coercion rules (simplified):
// String vs Number: string converted to number
'5' == 5           // true ('5' -> 5)
'' == 0            // true ('' -> 0)

// Boolean vs anything: boolean converted to number first
true == 1          // true (true -> 1)
false == 0         // true (false -> 0)
true == 'true'     // false! (true -> 1, 'true' -> NaN)

// null and undefined: equal to each other, nothing else
null == undefined  // true (special rule)
null == 0          // false
null == ''         // false
null == false      // false

// Object vs primitive: object converted via ToPrimitive (valueOf, then toString)
[1] == 1           // true ([1].valueOf() -> [1], [1].toString() -> '1', '1' -> 1)
[] == false        // true ([] -> '' -> 0, false -> 0)
[] == 0            // true

// The infamous gotchas:
'' == false        // true
' \t\n' == 0      // true (whitespace string coerces to 0)
[] == ![]          // true (yes, really)
Why [] == ![] is true (the ultimate interview trick):
// Step 1: ![] evaluates first. [] is truthy, so ![] = false
// Step 2: [] == false
// Step 3: false -> 0 (boolean to number)
// Step 4: [] == 0
// Step 5: [].toString() -> '' (ToPrimitive)
// Step 6: '' == 0
// Step 7: '' -> 0 (string to number)
// Step 8: 0 == 0 -> true
Strict equality — predictable and safe:
console.log('5' === 5);           // false (different types, stop)
console.log(true === 1);          // false
console.log(null === undefined);  // false
console.log(NaN === NaN);         // false (NaN is never equal to anything, including itself)

// To check for NaN:
console.log(Number.isNaN(NaN));   // true (use this, not isNaN())
console.log(Object.is(NaN, NaN)); // true (Object.is handles edge cases)
Object.is() — the most precise comparison:
Object.is(NaN, NaN);   // true (unlike ===)
Object.is(0, -0);      // false (=== treats 0 and -0 as equal)
Object.is(null, null);  // true
// React uses Object.is internally for state comparison in hooks
Best practice: Always use === and !==. The only acceptable == use case is value == null which conveniently checks for both null and undefined in one expression. ESLint’s eqeqeq rule enforces this.Red flag answer: “Double equals checks value, triple equals checks value and type.” This is a surface-level description that doesn’t demonstrate understanding of the coercion algorithm. Candidates who can’t explain WHY '' == false is true or who’ve never heard of Object.is() are missing practical depth.Follow-up questions:Q: Why is NaN !== NaN in JavaScript, and how does this affect real code?NaN (Not-a-Number) is defined by IEEE 754 to be not equal to anything, including itself. This is a deliberate standard decision — NaN represents an undefined or unrepresentable computation result, and comparing two undefined results shouldn’t be considered equal. In practice, this means you can’t do if (result === NaN) to check for NaN. Use Number.isNaN(value) (not the global isNaN() which coerces first — isNaN('hello') returns true because it coerces to Number('hello') which is NaN). A real bug: parsing user input with parseFloat('abc') returns NaN, and checking parsedValue === NaN is always false, so the invalid input silently passes through validation. Always use Number.isNaN.Q: When, if ever, would you deliberately use == instead of ===?The only widely accepted case: value == null which is equivalent to value === null || value === undefined. This is a common pattern to check if a value is “empty” (null or undefined) in one concise expression. Some coding standards (notably the eslint-config-standard preset) allow this single exception. Libraries like Lodash use this pattern extensively. Beyond this, always use ===. Some legacy codebases use == for string-number comparison in form handling (if (input == 42)), but the modern approach is to explicitly parse: if (Number(input) === 42).Q: How does React use equality comparison internally, and why does it matter for rendering?React uses Object.is() (not ===) to compare state values in hooks like useState and useReducer. The practical difference: Object.is(0, -0) is false while 0 === -0 is true. For state updates, if Object.is(oldState, newState) returns true, React skips the re-render. This is why setState(prevState) with the same reference doesn’t re-render, but setState({...prevState}) always re-renders (new object reference). For useMemo and useCallback dependency arrays, React compares each dependency with Object.is. Understanding this prevents unnecessary re-renders — a common performance issue in React apps where developers create new object/array references on every render without realizing it.
What interviewers are really testing: Whether you know the critical difference between the global isNaN() and Number.isNaN(), and that the global version has a coercion bug that has caused real production issues.Answer:isNaN() checks if a value is NaN, but there are two versions with critically different behavior:Global isNaN() — the broken version:
// Coerces the argument to Number first, THEN checks for NaN
isNaN('hello');      // true  (Number('hello') -> NaN)
isNaN(undefined);    // true  (Number(undefined) -> NaN)
isNaN({});           // true  (Number({}) -> NaN)
isNaN('123');        // false (Number('123') -> 123)
isNaN('');           // false (Number('') -> 0)
isNaN(true);         // false (Number(true) -> 1)
isNaN(null);         // false (Number(null) -> 0)
isNaN('123abc');     // true  (Number('123abc') -> NaN)
The problem: isNaN('hello') returns true, but 'hello' is NOT NaN — it’s a string. The global function answers: “Would this value be NaN if I tried to make it a number?” That’s usually not what you want.Number.isNaN() — the correct version (ES6):
// No coercion. Only returns true for the actual NaN value.
Number.isNaN(NaN);         // true
Number.isNaN('hello');     // false (it's a string, not NaN)
Number.isNaN(undefined);   // false
Number.isNaN(123);         // false
Number.isNaN('123');       // false
Why NaN exists and where it appears in real code:
parseInt('abc');           // NaN
Math.sqrt(-1);             // NaN
0 / 0;                     // NaN
undefined + 1;             // NaN
Number('not a number');    // NaN

// NaN is "contagious" -- any operation with NaN produces NaN
NaN + 5;    // NaN
NaN * 100;  // NaN
The typeof paradox:
typeof NaN; // 'number' -- NaN is technically a numeric type
// It represents "a numeric value that is not representable"
// Think of it as "invalid number" rather than "not a number"
Production-safe number validation pattern:
function isValidNumber(value) {
    const parsed = Number(value);
    return !Number.isNaN(parsed) && Number.isFinite(parsed);
}

isValidNumber('42');       // true
isValidNumber('3.14');     // true
isValidNumber('hello');    // false
isValidNumber(Infinity);   // false
isValidNumber('');         // true (Number('') is 0 -- may want to handle this)
Red flag answer: Using isNaN() without mentioning Number.isNaN(), or not knowing that typeof NaN === 'number'. In code reviews, using the global isNaN() is a smell because it hides coercion bugs.Follow-up questions:Q: How would you safely validate that a user input from a form is a valid number?Form inputs are always strings. The robust approach: first check for empty/whitespace (value.trim() === ''), then use Number(value) for conversion (not parseInt which stops at the first non-numeric character — parseInt('42abc') returns 42!), then check Number.isFinite(result) (which rejects NaN, Infinity, and -Infinity). For specific formats, use a regex first. For currency, strip currency symbols and commas before parsing, and use integer cents to avoid floating-point issues. In React, libraries like Zod or Yup handle this with schemas: z.coerce.number().positive().finite().Q: What’s the difference between Number(), parseInt(), and parseFloat() for string-to-number conversion?Number(value) converts the entire string or returns NaN if any part is invalid: Number('42px') is NaN. parseInt(string, radix) parses from the beginning and stops at the first non-numeric character: parseInt('42px') is 42, parseInt('0xFF', 16) is 255. Always pass the radix (second argument) — without it, parseInt('08') historically returned 0 in some engines (octal interpretation). parseFloat(string) is similar but handles decimals: parseFloat('3.14meters') is 3.14. In production, Number() is safest because it’s strictest. Use parseInt/parseFloat only when you deliberately want to extract a number from a string with trailing non-numeric content.
What interviewers are really testing: Whether you understand the semantic difference (intentional vs. default absence), the typeof null bug, and how these values interact with APIs, default parameters, and optional chaining.Answer:Both represent “absence of value” but with different semantics and use cases:
Featureundefinednull
MeaningSystem-assigned: “no value yet”Developer-assigned: “intentionally empty”
typeof'undefined''object' (historical bug since 1995)
In JSONNot valid (omitted from serialization)Valid JSON value
Default paramsTriggers defaultDoes NOT trigger default
Numeric coercionNaN0
Where undefined appears automatically:
let x;                    // declared but not assigned
console.log(x);           // undefined

function greet(name) {
    console.log(name);    // undefined if not passed
}
greet();

const obj = { a: 1 };
console.log(obj.b);      // undefined (missing property)

function noReturn() {}
console.log(noReturn());  // undefined (no return statement)
Where you use null intentionally:
let user = null;           // "We don't have a user yet"
let data = null;           // "We're about to fetch this"
element.onclick = null;    // "Remove the click handler"

// Common API pattern:
async function fetchUser(id) {
    const user = await db.users.findById(id);
    return user || null;   // Explicitly return null for "not found"
}
The critical default parameter gotcha:
function greet(name = 'stranger') {
    console.log(`Hello, ${name}!`);
}

greet(undefined);  // "Hello, stranger!" -- undefined triggers the default
greet(null);       // "Hello, null!"      -- null does NOT trigger the default
greet();           // "Hello, stranger!" -- missing arg is undefined
This matters in React components: <Component title={null} /> won’t use the default prop value, while <Component /> (where title is undefined) will.Optional chaining and nullish coalescing (modern patterns):
const user = { name: 'Alice', address: null };

// Optional chaining (?.) -- short-circuits on null OR undefined
console.log(user.address?.city);          // undefined (doesn't throw)
console.log(user.profile?.avatar?.url);   // undefined

// Nullish coalescing (??) -- fallback only for null/undefined
const port = process.env.PORT ?? 3000;    // Uses 3000 only if PORT is null/undefined
const port2 = process.env.PORT || 3000;   // Uses 3000 if PORT is ANY falsy value (including 0, '')

// This difference matters:
const count = 0;
console.log(count || 10);  // 10 (0 is falsy -- probably a bug!)
console.log(count ?? 10);  // 0  (0 is not null/undefined -- correct!)
JSON serialization behavior:
const obj = { a: 1, b: undefined, c: null };
JSON.stringify(obj); // '{"a":1,"c":null}'
// undefined properties are OMITTED, null is preserved
// This has API implications -- backend might treat missing vs null differently
Red flag answer: “Undefined means the variable doesn’t exist and null means it’s empty.” Undefined doesn’t mean the variable doesn’t exist — it means it exists but has no value. A non-existent variable throws a ReferenceError. Also: not knowing typeof null === 'object' or the default parameter behavior difference.Follow-up questions:Q: Why does typeof null return 'object' and will it ever be fixed?In the original JavaScript implementation (Brendan Eich, 1995, 10 days), values were stored as a type tag + value. Objects had type tag 0, and null was represented as the null pointer (0x00), which also had a 0 type tag. So typeof null checks the type tag, sees 0, and returns 'object'. A fix was proposed for ES6 (typeof null === 'null') but was rejected because it would break too much existing code on the web. It will never be fixed. In practice, to check for null specifically, use value === null.Q: How does the optional chaining operator handle method calls and bracket notation?Optional chaining works in three forms: obj?.prop (property access), obj?.[expr] (computed property), and obj?.method() (method call). For method calls, obj?.method() will return undefined if obj is null/undefined OR if method doesn’t exist on obj. The entire chain short-circuits: a?.b.c.d — if a is null, it returns undefined immediately without evaluating .b.c.d. A subtle gotcha: obj?.method() will still throw if method exists but isn’t a function. It only guards against null/undefined, not against the property being the wrong type.Q: In API design, when should a field be null vs. omitted (undefined) vs. an empty value?This is an important API contract question. Convention: null means “this field exists in the schema but has no value for this record” (e.g., middleName: null). Omitted/undefined means “this field wasn’t requested or doesn’t apply” (e.g., not including address when it wasn’t in the query’s select clause). Empty string "" means “this field has a value and it’s an empty string” (different from null). In GraphQL, this distinction is explicit: fields can be nullable, and you can query specific fields. In REST APIs, use null for “intentionally empty” and omit the field for “not applicable.” JSON Merge Patch (RFC 7396) uses null specifically to mean “delete this field” — so the semantic difference has protocol-level implications.
What interviewers are really testing: Knowledge of typeof’s quirks (null returns ‘object’, functions return ‘function’, arrays return ‘object’), and what alternatives exist for more precise type checking.Answer:typeof is a unary operator that returns a string indicating the type of the operand. It’s fast, safe (doesn’t throw on undeclared variables), but has several well-known limitations.The complete typeof table:
// Primitives
typeof 42                 // 'number'
typeof 3.14              // 'number'
typeof NaN               // 'number'   (yes, "Not a Number" is a number type)
typeof 'hello'           // 'string'
typeof true              // 'boolean'
typeof undefined         // 'undefined'
typeof Symbol('id')      // 'symbol'
typeof 123n              // 'bigint'

// Objects and the gotchas
typeof null              // 'object'   (the famous bug)
typeof {}                // 'object'
typeof []                // 'object'   (arrays are objects)
typeof new Date()        // 'object'
typeof /regex/           // 'object'
typeof new Map()         // 'object'
typeof function(){}      // 'function' (special case -- technically an object)
typeof class Foo {}      // 'function' (classes are syntactic sugar for functions)
The one superpower of typeof — safe check on undeclared variables:
// This throws:
// console.log(someUndeclaredVar); // ReferenceError

// This doesn't:
console.log(typeof someUndeclaredVar); // 'undefined' (safe!)

// Useful pattern for feature detection:
if (typeof window !== 'undefined') {
    // We're in a browser environment
}
if (typeof process !== 'undefined') {
    // We're in Node.js
}
Better alternatives for precise type checking:
// For arrays
Array.isArray([1, 2, 3]);  // true
Array.isArray('hello');     // false

// For null
value === null;             // the only reliable way

// For NaN
Number.isNaN(value);        // true only for NaN itself

// For plain objects
Object.prototype.toString.call(value);
// Returns '[object Array]', '[object Null]', '[object Date]', etc.
// This is the most precise type check available

function typeOf(value) {
    return Object.prototype.toString.call(value)
        .slice(8, -1).toLowerCase();
}
typeOf([]);        // 'array'
typeOf(null);      // 'null'
typeOf(new Date()) // 'date'
typeOf(/regex/);   // 'regexp'

// instanceof (checks prototype chain, but fails cross-realm)
[] instanceof Array;     // true
({}) instanceof Object;  // true
Production patterns using typeof:
// Safe environment detection (works in SSR/Node/Browser)
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
const isNode = typeof process !== 'undefined' && process.versions?.node;

// Defensive function parameter checking
function processConfig(config) {
    if (typeof config === 'string') {
        config = JSON.parse(config);
    }
    if (typeof config !== 'object' || config === null) {
        throw new TypeError('Config must be an object');
    }
    // proceed
}

// Feature detection
if (typeof IntersectionObserver !== 'undefined') {
    // Use IntersectionObserver for lazy loading
} else {
    // Fallback to scroll events
}
Red flag answer: Using typeof to check for arrays (typeof arr === 'array' — this doesn’t work, returns 'object'). Or not knowing that typeof null returns 'object'. Also, using typeof when instanceof or Array.isArray would be more appropriate.Follow-up questions:Q: Why does typeof return 'function' for functions but 'object' for everything else that’s an object?Functions are objects in JavaScript (they have properties, can be assigned to variables, etc.), but typeof treats them specially because functions are “callable objects.” The spec defines a [[Call]] internal method on function objects, and typeof checks for this. This special-casing was a practical design choice — checking if something is callable is one of the most common type checks in JavaScript. Interestingly, typeof class Foo {} also returns 'function' because classes are syntactic sugar over constructor functions.Q: How would you build a robust type-checking utility for a production library?The gold standard is Object.prototype.toString.call(value) which returns [object Type] for all built-in types. Wrap it: const getType = v => Object.prototype.toString.call(v).slice(8, -1). This correctly distinguishes Array, Null, Date, RegExp, Map, Set, etc. For custom classes, you can override this by implementing Symbol.toStringTag. Libraries like Lodash provide isPlainObject, isArray, isFunction etc. that handle edge cases including cross-realm checks. In TypeScript, you’d pair runtime checks with type guards: function isString(value: unknown): value is string — giving you runtime safety and compile-time narrowing.Q: What’s the difference between typeof and instanceof, and when does instanceof fail?typeof checks the value’s type tag (a primitive operation). instanceof checks the prototype chain — it asks “does Constructor.prototype appear anywhere in the object’s prototype chain?” instanceof fails across realms: if you pass an array from an iframe to the parent window, arr instanceof Array is false because the iframe has its own Array constructor. Symbol.hasInstance lets you customize instanceof behavior. Also, instanceof doesn’t work with primitives: 'hello' instanceof String is false (it’s a primitive, not a String object). Use typeof for primitives, Array.isArray for arrays, and instanceof for custom classes within the same realm.

Medium Level Questions

What interviewers are really testing: Whether you understand functional programming concepts, immutability patterns, and can articulate when to use map() vs. forEach() vs. reduce() vs. for loops — and the performance implications of each.Answer:map() creates a new array by applying a transformation function to each element of the source array. It’s the most fundamental functional programming method in JavaScript — it transforms data without side effects (pure function pattern).Core behavior:
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(num => num * 2);
console.log(doubled);  // [2, 4, 6, 8, 10]
console.log(numbers);  // [1, 2, 3, 4, 5] -- original unchanged
The callback receives three arguments:
const result = ['a', 'b', 'c'].map((value, index, array) => {
    return `${index}: ${value} (of ${array.length})`;
});
// ['0: a (of 3)', '1: b (of 3)', '2: c (of 3)']
Real-world patterns:
// API response transformation (extremely common in React)
const users = apiResponse.map(user => ({
    id: user.id,
    displayName: `${user.firstName} ${user.lastName}`,
    avatar: user.profilePic || '/default-avatar.png',
    isActive: user.lastLogin > Date.now() - 86400000,
}));

// Rendering lists in React
const UserList = ({ users }) => (
    <ul>
        {users.map(user => (
            <li key={user.id}>{user.displayName}</li>
        ))}
    </ul>
);
When to use map() vs. alternatives:
// map(): when you need a TRANSFORMED array (same length, different values)
const names = users.map(u => u.name);

// filter(): when you need a SUBSET (fewer items, same shape)
const active = users.filter(u => u.isActive);

// reduce(): when you need a SINGLE VALUE (aggregation, grouping)
const totalAge = users.reduce((sum, u) => sum + u.age, 0);

// forEach(): when you need SIDE EFFECTS (logging, DOM manipulation)
// forEach returns undefined -- if you're not using the return value, don't use map
users.forEach(u => console.log(u.name));

// for...of: when you need break/continue, or async iteration
for (const user of users) {
    if (user.isAdmin) break;
    await processUser(user);
}
Common mistake — using map for side effects:
// BAD: using map when you don't need the returned array
users.map(user => {
    console.log(user.name);  // side effect only, map is wasteful
});
// Creates an array of undefined values that's immediately discarded

// GOOD: use forEach for side effects
users.forEach(user => {
    console.log(user.name);
});
Performance note: For very large arrays (100K+ elements), map() creates a new array in memory. If you’re chaining map().filter().map(), each intermediate array is allocated and then garbage collected. For hot paths, a single reduce() or a for loop that does everything in one pass is more efficient. Libraries like Lodash offer _.chain with lazy evaluation, and transducers (from Ramda) compose transformations without intermediate arrays.Red flag answer: “map loops through an array.” That describes forEach. The key distinction is that map returns a new array of transformed values. Candidates who can’t distinguish map from forEach, or who use map for side effects, show a gap in functional programming understanding.Follow-up questions:Q: What’s the difference between map() and flatMap()?flatMap() is equivalent to map() followed by flat(1) — it maps each element, then flattens the result by one level. This is useful when your mapping function returns arrays and you want a single flat result. Example: ['hello world', 'foo bar'].flatMap(s => s.split(' ')) gives ['hello', 'world', 'foo', 'bar'] instead of [['hello', 'world'], ['foo', 'bar']]. Real use case: extracting tags from blog posts where each post has multiple tags: posts.flatMap(p => p.tags) gives a flat array of all tags across all posts. flatMap is more efficient than .map().flat() because it only makes one pass.Q: How does map() handle sparse arrays (holes)?map() preserves holes in sparse arrays — it skips empty slots. [1, , 3].map(x => x * 2) returns [2, empty, 6], not [2, undefined, 6]. The callback is never called for the empty slot. This is different from [1, undefined, 3].map(x => x * 2) which returns [2, NaN, 6] because undefined is an actual value. In practice, sparse arrays are rare and usually a bug. If you encounter them, use Array.from to convert holes to undefined first.Q: Can you implement Array.prototype.map from scratch?
Array.prototype.myMap = function(callback, thisArg) {
    if (typeof callback !== 'function') {
        throw new TypeError(callback + ' is not a function');
    }
    const result = new Array(this.length);
    for (let i = 0; i < this.length; i++) {
        if (i in this) { // Skip holes in sparse arrays
            result[i] = callback.call(thisArg, this[i], i, this);
        }
    }
    return result;
};
Key details: it checks i in this to skip sparse array holes, it accepts a thisArg parameter for the callback’s this context, and it creates the result array with the same length upfront. This is a common senior-level interview question that tests your understanding of the method’s contract.
What interviewers are really testing: Understanding of DOM event propagation phases, practical implications for event delegation, stopPropagation vs stopImmediatePropagation, and performance optimization in complex UIs.Answer:Event propagation in the DOM has three phases, executed in order:
  1. Capturing phase (top to bottom): Event travels from window down to the target element
  2. Target phase: Event reaches the target element
  3. Bubbling phase (bottom to top): Event travels back up from target to window
Visual model:
Click on <button> inside <div> inside <body>:

CAPTURING:  window -> document -> html -> body -> div -> button
TARGET:     button (event fires on the actual target)
BUBBLING:   button -> div -> body -> html -> document -> window
Default behavior is bubbling:
// HTML: <div id="outer"><div id="inner">Click me</div></div>

document.getElementById('outer').addEventListener('click', () => {
    console.log('Outer (bubble)');
});
document.getElementById('inner').addEventListener('click', () => {
    console.log('Inner (bubble)');
});

// Click on inner div outputs:
// "Inner (bubble)"
// "Outer (bubble)"  -- event bubbled up
Enabling capturing (third argument true or { capture: true }):
document.getElementById('outer').addEventListener('click', () => {
    console.log('Outer (capture)');
}, true);  // capture phase

document.getElementById('outer').addEventListener('click', () => {
    console.log('Outer (bubble)');
});  // default: bubble phase

document.getElementById('inner').addEventListener('click', () => {
    console.log('Inner');
});

// Click on inner:
// "Outer (capture)"  -- capture fires first (top-down)
// "Inner"            -- target phase
// "Outer (bubble)"   -- bubble fires last (bottom-up)
Stopping propagation:
// stopPropagation: stops the event from continuing to the next element
// but other listeners on the SAME element still fire
inner.addEventListener('click', (e) => {
    e.stopPropagation();
    console.log('Handler 1');
});
inner.addEventListener('click', () => {
    console.log('Handler 2'); // Still fires!
});

// stopImmediatePropagation: stops everything -- no more handlers, no bubbling
inner.addEventListener('click', (e) => {
    e.stopImmediatePropagation();
    console.log('Handler 1');
});
inner.addEventListener('click', () => {
    console.log('Handler 2'); // Does NOT fire
});
event.target vs event.currentTarget:
document.getElementById('outer').addEventListener('click', (e) => {
    console.log('target:', e.target.id);         // What was actually clicked
    console.log('currentTarget:', e.currentTarget.id); // What this listener is on
});
// Click on inner: target = 'inner', currentTarget = 'outer'
Real-world implications:
  • React’s event system: React doesn’t attach listeners to individual elements. It uses event delegation on the root container (since React 17, it’s the root DOM node, not document). All events bubble up to the root where React’s synthetic event system handles dispatch. This is why e.stopPropagation() in React stops propagation within React’s tree, not the native DOM.
  • Performance in complex UIs: A dropdown with 1,000 items doesn’t need 1,000 click listeners. One listener on the container using event delegation handles them all.
Red flag answer: “Bubbling goes up and capturing goes down.” This is correct but incomplete. Candidates should know about the three phases, target vs currentTarget, stopPropagation vs stopImmediatePropagation, and practical use cases like event delegation.Follow-up questions:Q: Not all events bubble. Which common events don’t bubble, and how do you handle them?focus, blur, mouseenter, mouseleave, load, unload, scroll (on specific elements), and resize do not bubble. The focusin/focusout events were created as bubbling alternatives to focus/blur. For events that don’t bubble, you can still use the capturing phase to intercept them at a parent level: parent.addEventListener('focus', handler, true). This is how React handles focus events via delegation — it listens during the capture phase. The scroll event on a specific element doesn’t bubble, but you can listen for it in the capture phase on a parent. load events on images and scripts also don’t bubble.Q: How does event.preventDefault() differ from event.stopPropagation()?Completely orthogonal concepts. preventDefault() stops the browser’s default behavior for that event (e.g., preventing a link from navigating, preventing a form from submitting, preventing a checkbox from toggling). stopPropagation() stops the event from reaching other elements but doesn’t prevent the default behavior. You can use both together or independently. A common mistake: calling stopPropagation() on a form submit thinking it prevents submission — it doesn’t; you need preventDefault() for that. Also, return false in a jQuery event handler does BOTH (prevents default + stops propagation), but in vanilla JS, return false from an addEventListener handler does nothing.Q: What’s the { passive: true } option and why does Chrome warn about it?{ passive: true } tells the browser that the event handler will never call preventDefault(). This matters for touch/scroll events because the browser normally has to wait for the handler to complete to know if it should scroll or not (because the handler might call preventDefault()). With passive: true, the browser can scroll immediately without waiting, resulting in smoother scrolling. Chrome added this as the default for touchstart and touchmove on the document level. If you add a non-passive touch handler that calls preventDefault(), Chrome will ignore the preventDefault() and log a console warning. This was a deliberate performance optimization — Google found that most touch handlers never call preventDefault(), so making passive the default improved scroll performance across the web. To opt out: { passive: false }.
What interviewers are really testing: Whether you understand functional programming as a paradigm, can explain how functions as first-class citizens enable composition, and have used higher-order patterns in real application code (middleware, decorators, React HOCs).Answer:A higher-order function (HOF) is a function that either takes a function as an argument, returns a function, or both. This is possible because JavaScript treats functions as first-class citizens — they’re values that can be assigned to variables, passed as arguments, and returned from other functions.Functions as arguments (callback pattern):
// Array methods are the most common HOFs
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(num => num * 2);     // map is a HOF
const evens = numbers.filter(num => num % 2 === 0); // filter is a HOF
const sum = numbers.reduce((acc, num) => acc + num, 0); // reduce is a HOF

// Custom HOF
function operate(a, b, operation) {
    return operation(a, b);
}
console.log(operate(5, 3, (x, y) => x + y));      // 8
console.log(operate(5, 3, (x, y) => x * y));      // 15
Functions that return functions (factory pattern):
function createMultiplier(multiplier) {
    return function(number) {
        return number * multiplier;
    };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5));  // 10
console.log(triple(5));  // 15
Real-world higher-order function patterns:1. Middleware (Express.js):
// Express middleware is a HOF pattern
function authMiddleware(requiredRole) {
    return function(req, res, next) {
        if (req.user?.role !== requiredRole) {
            return res.status(403).json({ error: 'Forbidden' });
        }
        next();
    };
}

app.get('/admin', authMiddleware('admin'), (req, res) => {
    res.json({ message: 'Welcome, admin' });
});
2. Function composition:
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);

const addTax = (price) => price * 1.1;
const formatCurrency = (amount) => `$${amount.toFixed(2)}`;
const applyDiscount = (price) => price * 0.9;

const calculatePrice = pipe(applyDiscount, addTax, formatCurrency);
console.log(calculatePrice(100)); // "$99.00"
3. Debounce/Throttle (performance-critical HOFs):
function debounce(fn, delay) {
    let timeoutId;
    return function(...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn.apply(this, args), delay);
    };
}

const handleSearch = debounce((query) => {
    fetch(`/api/search?q=${query}`);
}, 300);

// Without debounce: 50 keystrokes = 50 API calls
// With debounce: 50 keystrokes = 1 API call (300ms after last keystroke)
4. Memoization:
function memoize(fn) {
    const cache = new Map();
    return function(...args) {
        const key = JSON.stringify(args);
        if (cache.has(key)) return cache.get(key);
        const result = fn.apply(this, args);
        cache.set(key, result);
        return result;
    };
}

const expensiveCalc = memoize((n) => {
    console.log('Computing...');
    return n * n;
});
expensiveCalc(5); // "Computing..." -> 25
expensiveCalc(5); // 25 (cached, no log)
Red flag answer: “A higher-order function takes a function as a parameter” (only half the definition). Or only giving map/filter/reduce as examples without any real-world patterns. Strong candidates connect HOFs to middleware, React HOCs, decorators, memoization, and function composition.Follow-up questions:Q: How do React Higher-Order Components (HOCs) use this pattern, and why has the community moved toward hooks?React HOCs are functions that take a component and return a new enhanced component: const Enhanced = withAuth(MyComponent). They use the HOF pattern to inject props, handle authentication, add logging, etc. The community moved toward hooks because HOCs have problems: “wrapper hell” (deep component nesting visible in DevTools), naming collisions when multiple HOCs inject the same prop name, and difficulty with TypeScript types. Hooks like useAuth() achieve the same code reuse but within the component, without wrapping. That said, HOCs still exist in codebases — React.memo() is essentially a HOC, and connect() from Redux is a HOC factory (a HOF that returns a HOF).Q: What’s the difference between compose and pipe, and when would you use each?Both compose functions left-to-right or right-to-left. compose(f, g, h)(x) executes as f(g(h(x))) — right-to-left, mathematical order. pipe(f, g, h)(x) executes as h(g(f(x))) — left-to-right, reading order. pipe is generally more readable because functions execute in the order you read them. Redux’s compose utility uses right-to-left, which aligns with mathematical function composition. Ramda and RxJS use pipe for readability. In practice, use whichever your team’s tools prefer — consistency matters more than the direction.Q: How would you implement a throttle function, and how does it differ from debounce?Debounce waits until the input has stopped for N milliseconds, then fires once. Throttle fires at most once every N milliseconds, regardless of how many times the function is called. Use debounce for search inputs (fire after user stops typing). Use throttle for scroll handlers or resize handlers (fire at a consistent rate during continuous events). Implementation: function throttle(fn, limit) { let waiting = false; return function(...args) { if (!waiting) { fn.apply(this, args); waiting = true; setTimeout(() => { waiting = false; }, limit); } }; }. Libraries like Lodash provide both with additional options like leading and trailing edge execution.
What interviewers are really testing: Historical JavaScript context (pre-module-system scoping), understanding of the module pattern, and awareness that IIFEs are less common in modern ES modules but still appear in specific scenarios.Answer:An IIFE (Immediately Invoked Function Expression) is a function that is defined and executed in the same statement. It creates a private scope, preventing variable leakage into the global namespace.Syntax:
// Standard IIFE
(function() {
    const privateVar = 'secret';
    console.log(privateVar); // Accessible here
})();
// privateVar is not accessible here

// Arrow function IIFE
(() => {
    console.log('Executed immediately');
})();

// With arguments
(function(name) {
    console.log(`Hello, ${name}`);
})('Alice');

// With return value
const result = (function() {
    return 42;
})();
console.log(result); // 42
Why IIFEs exist — historical context:Before ES6 modules (import/export), JavaScript had no module system. Every var in a script tag was global. If two scripts both declared var helper = ..., one would overwrite the other. IIFEs solved this by creating function scope:
// library-a.js (before modules)
(function() {
    var helper = 'Library A helper'; // Private to this IIFE
    window.LibraryA = { /* public API */ };
})();

// library-b.js
(function() {
    var helper = 'Library B helper'; // Different variable, no collision
    window.LibraryB = { /* public API */ };
})();
The Module Pattern (IIFE’s most important application):
const Counter = (function() {
    let count = 0; // Truly private -- no way to access from outside

    return {
        increment() { return ++count; },
        decrement() { return --count; },
        getCount() { return count; },
    };
})();

Counter.increment(); // 1
Counter.increment(); // 2
Counter.getCount();  // 2
// Counter.count      // undefined -- truly private
This was the standard pattern for libraries like jQuery, Lodash, and Underscore before ES modules.Modern use cases (IIFEs are still useful):
// 1. Top-level await workaround in older environments
(async () => {
    const data = await fetch('/api/config');
    const config = await data.json();
    initApp(config);
})();

// 2. Switch/case with block-scoped constants
const message = (() => {
    switch (status) {
        case 200: return 'OK';
        case 404: return 'Not Found';
        case 500: return 'Server Error';
        default: return 'Unknown';
    }
})();

// 3. Complex initialization of a const
const config = (() => {
    const env = process.env.NODE_ENV;
    if (env === 'production') {
        return { apiUrl: 'https://api.prod.com', debug: false };
    }
    return { apiUrl: 'http://localhost:3000', debug: true };
})();
Red flag answer: “An IIFE runs a function immediately.” That’s what it does, but not why it exists. The real value is scope isolation. If a candidate can’t explain the historical need (pre-module global namespace pollution) or the module pattern, they’re missing the conceptual foundation.Follow-up questions:Q: With ES modules (import/export) available, are IIFEs obsolete?Not entirely. ES modules solve the primary use case (scope isolation), so you rarely need IIFEs in modern module-based code. But they’re still useful in several scenarios: (1) Immediately-invoked async functions when top-level await isn’t available; (2) Inline complex constant initialization (the const config = (() => {...})() pattern); (3) UMD (Universal Module Definition) bundles that need to work in both script tags and module systems — tools like Webpack and Rollup still output IIFEs for <script> tag consumption; (4) Bookmarklets and browser console snippets where you want no global pollution. So IIFEs are less common but not dead.Q: What’s the difference between (function(){})() and (function(){}())?Both work identically. The parentheses placement differs: the first wraps the function expression, then calls it. The second wraps the entire call expression. Douglas Crockford (creator of JSLint) preferred the second form, calling the first form “dog balls” style. In practice, ESLint’s wrap-iife rule lets you enforce one or the other. Most modern code uses the first form. The real reason for the outer parentheses: without them, function(){}() is parsed as a function declaration (which requires a name) followed by an empty grouping operator, causing a SyntaxError. The parentheses force the parser to treat it as an expression.Q: How does the Revealing Module Pattern improve on the basic Module Pattern?The basic module pattern returns methods that directly reference closure variables. The Revealing Module Pattern defines all functions as private, then returns an object that maps public names to private functions. This makes it easier to see what’s public vs. private and enables renaming public APIs without changing internal function names:
const Module = (function() {
    function privateHelper() { /* ... */ }
    function doSomething() { privateHelper(); /* ... */ }
    function doSomethingElse() { /* ... */ }

    return {
        publicMethod: doSomething,
        anotherPublic: doSomethingElse,
        // privateHelper is not exposed
    };
})();
Today, this same pattern is achieved more naturally with ES module export statements.
What interviewers are really testing: This is one of the most important JavaScript concepts. Interviewers want to see if you truly understand lexical scoping, can identify closures in real code (not just textbook examples), know the memory implications, and can solve the classic closure-in-a-loop problem.Answer:A closure is a function that retains access to its lexical scope (the variables from its outer function) even after the outer function has returned. Every function in JavaScript creates a closure, but we typically talk about closures when a function is used outside of its original scope.How it works mechanically: When a function is created, it captures a reference to its surrounding lexical environment (not a snapshot of values — a live reference). This environment object stays in memory as long as the closure exists.
function outerFunction() {
    let outerVariable = "I'm from outer function";

    function innerFunction() {
        console.log(outerVariable); // Accesses outer variable via closure
    }

    return innerFunction;
}

const closureFunction = outerFunction();
// outerFunction has returned, its execution context is gone
// BUT outerVariable is still alive because innerFunction holds a closure over it
closureFunction(); // "I'm from outer function"
Private variables (the most practical closure pattern):
function createCounter() {
    let count = 0; // Truly private -- no way to access directly

    return {
        increment() { return ++count; },
        decrement() { return --count; },
        getCount() { return count; },
    };
}

const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.getCount();  // 2
// No way to access or modify 'count' directly
The classic closure-in-a-loop problem:
// BUG: All callbacks share the same 'i' variable
for (var i = 0; i < 5; i++) {
    setTimeout(() => console.log(i), 100);
}
// Output: 5, 5, 5, 5, 5

// FIX 1: Use let (creates new binding per iteration)
for (let i = 0; i < 5; i++) {
    setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2, 3, 4

// FIX 2: IIFE to capture current value (pre-ES6 approach)
for (var i = 0; i < 5; i++) {
    (function(j) {
        setTimeout(() => console.log(j), 100);
    })(i);
}
// Output: 0, 1, 2, 3, 4
Real-world closure patterns in production:1. React hooks use closures extensively:
function useCounter(initial = 0) {
    const [count, setCount] = useState(initial);
    // These functions close over 'count' and 'setCount'
    const increment = useCallback(() => setCount(c => c + 1), []);
    const decrement = useCallback(() => setCount(c => c - 1), []);
    return { count, increment, decrement };
}
2. Event handlers with context:
function setupLogger(userId) {
    // userId is captured by closure
    return function(event) {
        analytics.track({
            userId,  // Available via closure
            event: event.type,
            timestamp: Date.now(),
        });
    };
}
const logClick = setupLogger('user_123');
button.addEventListener('click', logClick);
3. Partial application / currying:
function createApiClient(baseUrl) {
    // baseUrl captured by closure
    return {
        get: (path) => fetch(`${baseUrl}${path}`),
        post: (path, body) => fetch(`${baseUrl}${path}`, {
            method: 'POST',
            body: JSON.stringify(body),
        }),
    };
}
const api = createApiClient('https://api.example.com');
api.get('/users');  // Uses the captured baseUrl
Memory implications of closures:
function createHeavyClosure() {
    const hugeArray = new Array(1000000).fill('data');
    // This closure keeps hugeArray alive!
    return function() {
        return hugeArray.length;
    };
}
const fn = createHeavyClosure();
// hugeArray (several MB) stays in memory as long as 'fn' exists
// To release: fn = null;
In Node.js server code, accidental closures over large objects in request handlers are a common source of memory leaks. Tools like Chrome DevTools Heap Snapshots show retained closure variables.Red flag answer: “A closure is when a function is defined inside another function.” That describes nesting, not closures. The key is that the inner function retains access to the outer scope even after the outer function has returned. Candidates who can’t explain the loop problem or memory implications are missing critical practical knowledge.Follow-up questions:Q: How do closures cause memory leaks in Node.js, and how do you detect them?A common pattern: an Express route handler creates a closure that accidentally captures a reference to the request or response object (or a large parsed body). If this closure is stored somewhere long-lived (like a cache or event emitter), the request data is never garbage collected. Over time, memory grows until the process crashes with an OOM error. Detection: use --inspect flag with Node.js, connect Chrome DevTools, take heap snapshots before and after simulated traffic, and compare retained sizes. Look for objects with “(closure)” in the retainers path. Tools like Clinic.js Doctor can automatically detect memory growth. Prevention: be explicit about what variables your closures capture, avoid closuring over large objects, and use WeakRef/WeakMap when appropriate.Q: What is the difference between lexical scope and dynamic scope, and which does JavaScript use?JavaScript uses lexical (static) scope — a function’s scope is determined by where it’s written in the source code, not where it’s called from. This is why closures work: the inner function’s scope chain is fixed at definition time. Dynamic scope (used by some shell languages like Bash) determines scope based on the call stack at runtime. If JavaScript had dynamic scope, closures wouldn’t work as we know them — outerVariable in the example above would not be accessible because the scope would be determined by who calls the function, not where it was defined. The this keyword in JavaScript is the closest thing to dynamic scoping — it’s determined by how a function is called, not where it’s defined (except for arrow functions, which lexically bind this).Q: How does the JavaScript engine optimize closures in practice?Modern engines like V8 perform “scope analysis” during compilation. If a variable in the outer scope is never referenced by any inner function, it won’t be included in the closure’s environment record — it can be garbage collected normally. V8 also creates “context objects” that only contain the variables actually needed by closures, not the entire scope. However, eval() defeats this optimization because the engine can’t know at compile time which variables eval might reference, so it must keep the entire scope alive. This is one reason eval() is avoided in production code beyond security concerns — it prevents closure optimization. You can verify this in Chrome DevTools: set a breakpoint inside a closure and inspect the “Scope” panel to see exactly which variables are captured.
What interviewers are really testing: Understanding of the event loop’s task queue, why timers are NOT precise, the difference between macrotasks and microtasks, and practical patterns like debouncing, polling, and the pitfalls of setInterval drift.Answer:Both are Web APIs (not part of the JavaScript language itself) that schedule callbacks to run after a delay. The critical insight: the delay is a minimum, not a guarantee. The callback runs only when the call stack is empty AND the delay has elapsed.setTimeout() — single delayed execution:
console.log('Before');
setTimeout(() => {
    console.log('Timeout callback');
}, 2000);
console.log('After');

// Output (immediately):
// "Before"
// "After"
// (2+ seconds later): "Timeout callback"
setInterval() — repeated execution at intervals:
let count = 0;
const intervalId = setInterval(() => {
    count++;
    console.log(`Tick ${count}`);
    if (count >= 5) {
        clearInterval(intervalId);
        console.log('Done');
    }
}, 1000);
Why timers are NOT accurate:
console.log('Start');
setTimeout(() => console.log('Timer'), 0); // 0ms delay!
// Heavy synchronous work
for (let i = 0; i < 1000000000; i++) {} // Takes ~1 second
console.log('End');

// Output:
// "Start"
// "End" (after ~1 second of blocking)
// "Timer" (fires AFTER the blocking work, not at 0ms!)
The setTimeout(fn, 0) pattern is used to defer execution until after the current call stack clears, not to execute “immediately.” The browser also enforces a minimum delay of ~4ms for nested timeouts (per the HTML spec).The setInterval drift problem:
// setInterval doesn't account for execution time
// If callback takes 300ms and interval is 1000ms,
// actual interval between starts is still 1000ms,
// but between end of one and start of next is only 700ms

// If callback takes LONGER than the interval, callbacks queue up
// and can fire back-to-back with no gap

// Better alternative: recursive setTimeout
function reliableInterval(fn, delay) {
    function tick() {
        fn();
        setTimeout(tick, delay); // Schedule AFTER execution
    }
    setTimeout(tick, delay);
}

// Even better: self-correcting timer
function preciseInterval(fn, interval) {
    let expected = Date.now() + interval;
    function step() {
        const drift = Date.now() - expected;
        fn();
        expected += interval;
        setTimeout(step, Math.max(0, interval - drift));
    }
    setTimeout(step, interval);
}
Practical patterns:Debouncing (search input):
function debounce(fn, delay) {
    let timeoutId;
    return function(...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn.apply(this, args), delay);
    };
}

const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce((e) => {
    fetchResults(e.target.value); // Only fires 300ms after user stops typing
}, 300));
Polling with backoff:
async function pollWithBackoff(fn, initialDelay = 1000, maxDelay = 30000) {
    let delay = initialDelay;
    async function poll() {
        try {
            const result = await fn();
            if (result.status === 'complete') return result;
            delay = Math.min(delay * 1.5, maxDelay); // Exponential backoff
        } catch (error) {
            delay = Math.min(delay * 2, maxDelay);
        }
        setTimeout(poll, delay);
    }
    poll();
}
Red flag answer: “setTimeout runs code after a delay and setInterval repeats it.” This misses the event loop implications. If a candidate thinks setTimeout(fn, 100) guarantees execution at exactly 100ms, they don’t understand JavaScript’s concurrency model. Also a red flag: using setInterval without clearInterval (memory leak) or not knowing about drift.Follow-up questions:Q: What’s the difference between setTimeout(fn, 0) and queueMicrotask(fn) or Promise.resolve().then(fn)?All three defer execution, but they go into different queues. setTimeout(fn, 0) adds to the macrotask queue (also called the task queue). queueMicrotask(fn) and Promise.resolve().then(fn) add to the microtask queue. Microtasks are processed BEFORE the next macrotask — meaning they run sooner. The execution order is: current synchronous code finishes, then ALL microtasks drain (including any microtasks queued by microtasks), then ONE macrotask runs, then microtasks again, and so on. This means Promise.resolve().then(() => console.log('micro')) always fires before setTimeout(() => console.log('macro'), 0). This is critical knowledge for understanding React state batching and avoiding render timing bugs.Q: How do timers behave in inactive browser tabs?Browsers throttle timers in background tabs. In Chrome, setInterval in a background tab is throttled to fire at most once per second (instead of the specified interval). setTimeout with delays less than 1000ms are delayed to 1000ms minimum. This is a deliberate battery and CPU optimization. This breaks polling-based features (like live dashboards) when users switch tabs. Solutions: use the Page Visibility API (document.addEventListener('visibilitychange', ...)) to pause/resume polling, or use Web Workers which are NOT throttled in background tabs. Some apps switch from polling to WebSocket push when the tab goes to background.Q: How would you implement a rate limiter using setTimeout?
function createRateLimiter(maxCalls, perMs) {
    const queue = [];
    let activeCount = 0;

    function processQueue() {
        while (queue.length > 0 && activeCount < maxCalls) {
            activeCount++;
            const { fn, resolve, reject } = queue.shift();
            fn().then(resolve).catch(reject).finally(() => {
                activeCount--;
                setTimeout(processQueue, perMs / maxCalls);
            });
        }
    }

    return function(fn) {
        return new Promise((resolve, reject) => {
            queue.push({ fn, resolve, reject });
            processQueue();
        });
    };
}
// Limits to 5 API calls per second
const limiter = createRateLimiter(5, 1000);
This pattern is common for respecting third-party API rate limits (Stripe allows 25 requests/second, GitHub allows 5,000/hour, etc.).
What interviewers are really testing: Understanding of asynchronous programming fundamentals, Promise states and the microtask queue, error handling patterns, Promise.all vs Promise.allSettled vs Promise.race vs Promise.any, and the ability to work with real-world async flows.Answer:A Promise is an object representing the eventual completion or failure of an asynchronous operation. It’s the foundation of modern async JavaScript — async/await is built on Promises, and virtually every I/O operation in Node.js and browser APIs returns one.The three states (immutable once settled):
  1. Pending: Initial state, operation in progress
  2. Fulfilled: Operation succeeded, has a result value
  3. Rejected: Operation failed, has a reason (error)
Once settled (fulfilled or rejected), a Promise cannot change state. This immutability is a key design property.Creating and consuming:
const fetchData = new Promise((resolve, reject) => {
    // Asynchronous work happens here
    setTimeout(() => {
        const success = Math.random() > 0.5;
        if (success) {
            resolve({ id: 1, name: 'Alice' }); // Fulfilled
        } else {
            reject(new Error('Failed to fetch')); // Rejected
        }
    }, 1000);
});

// Consuming
fetchData
    .then(data => console.log('Success:', data))
    .catch(error => console.error('Error:', error.message))
    .finally(() => console.log('Cleanup: always runs'));
Chaining (sequential async operations):
fetch('/api/user/1')
    .then(response => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.json(); // Returns a new promise
    })
    .then(user => fetch(`/api/posts?userId=${user.id}`))
    .then(response => response.json())
    .then(posts => console.log('Posts:', posts))
    .catch(error => {
        // Catches errors from ANY step above
        console.error('Pipeline failed:', error.message);
    });
Promise combinators (critical interview knowledge):
const p1 = fetch('/api/users');
const p2 = fetch('/api/posts');
const p3 = fetch('/api/comments');

// Promise.all -- ALL must succeed, fails fast on first rejection
const [users, posts, comments] = await Promise.all([p1, p2, p3]);
// Use case: loading dashboard data in parallel

// Promise.allSettled -- waits for ALL, never rejects
const results = await Promise.allSettled([p1, p2, p3]);
// results: [
//   { status: 'fulfilled', value: Response },
//   { status: 'rejected', reason: Error },
//   { status: 'fulfilled', value: Response }
// ]
// Use case: sending notifications to multiple services -- some may fail

// Promise.race -- first to SETTLE (fulfill or reject) wins
const result = await Promise.race([
    fetch('/api/data'),
    new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))
]);
// Use case: implementing request timeouts

// Promise.any -- first to FULFILL wins (ignores rejections)
const fastest = await Promise.any([
    fetch('https://cdn1.example.com/data'),
    fetch('https://cdn2.example.com/data'),
    fetch('https://cdn3.example.com/data'),
]);
// Use case: multi-CDN fallback, use whichever responds first
// Throws AggregateError only if ALL reject
Error handling pitfalls:
// BAD: Unhandled promise rejection (crashes Node.js since v15)
fetch('/api/data').then(r => r.json());
// If fetch fails, no .catch -- unhandled rejection

// BAD: catch doesn't cover the last .then
fetch('/api/data')
    .catch(err => console.error(err)) // This catches fetch errors
    .then(data => data.json());       // But if .json() fails, it's unhandled!

// GOOD: .catch at the END of the chain
fetch('/api/data')
    .then(r => r.json())
    .then(data => process(data))
    .catch(err => console.error('Any error caught:', err));

// GOOD: Global unhandled rejection handler (safety net)
process.on('unhandledRejection', (reason, promise) => {
    logger.error('Unhandled rejection:', reason);
    // In production: send to error tracking (Sentry, DataDog)
});
Red flag answer: Only knowing .then() and .catch(). Candidates who can’t explain Promise.allSettled vs Promise.all, or who don’t know about Promise.race for timeouts, are missing practical async patterns. Another red flag: not knowing that unhandled rejections crash Node.js.Follow-up questions:Q: What happens if you resolve a Promise with another Promise?The Promise specification (Section 2.3.2) says if resolve(value) is called where value is itself a thenable (has a .then method), the outer Promise “adopts” the state of the inner Promise. So new Promise(resolve => resolve(Promise.resolve(42))) eventually fulfills with 42, not with a Promise object. This is called “recursive unwrapping” and it means you can’t wrap a Promise inside another Promise — they flatten automatically. This is different from reject, which does NOT unwrap: new Promise((_, reject) => reject(Promise.resolve(42))) rejects with the Promise object itself.Q: How do Promises relate to the microtask queue and why does it matter?Promise .then/.catch/.finally callbacks are scheduled as microtasks, not macrotasks. Microtasks execute BEFORE the next macrotask (setTimeout, I/O callbacks, etc.) and BEFORE the browser renders. This means if you create a chain of 10,000 .then callbacks, they all execute before the browser can update the UI — potentially causing UI jank. In Node.js, process.nextTick has even higher priority than microtasks. Understanding this priority order is essential for debugging timing issues. Example: setState in React batches updates within the same microtask checkpoint, which is why multiple setState calls in a Promise chain behave differently than multiple setState calls in setTimeout callbacks (before React 18’s automatic batching).Q: How would you implement a retry mechanism with exponential backoff using Promises?
async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            return await fn();
        } catch (error) {
            if (attempt === maxRetries) throw error;
            const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
            console.log(`Attempt ${attempt + 1} failed, retrying in ${Math.round(delay)}ms`);
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }
}

// Usage:
const data = await retryWithBackoff(
    () => fetch('https://flaky-api.example.com/data').then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
    }),
    3,    // max retries
    1000  // 1s base delay (1s, 2s, 4s + jitter)
);
The jitter (Math.random() * 1000) prevents thundering herd — if 1,000 clients all retry at exactly the same backoff intervals, they’ll overwhelm the server simultaneously. AWS recommends this pattern in their architecture guidelines.
What interviewers are really testing: Whether you understand that async/await is syntactic sugar over Promises (not a replacement), can handle error patterns correctly, know about parallelism pitfalls (sequential vs. concurrent await), and understand top-level await.Answer:async/await provides synchronous-looking syntax for asynchronous operations. An async function always returns a Promise. await pauses the function’s execution until the awaited Promise settles, then resumes with the resolved value.How it works under the hood:
// This async function:
async function fetchUser() {
    const response = await fetch('/api/user');
    const data = await response.json();
    return data;
}

// Is roughly equivalent to:
function fetchUser() {
    return fetch('/api/user')
        .then(response => response.json())
        .then(data => data);
}
Error handling — always use try/catch:
async function loadDashboard() {
    try {
        const user = await fetchUser();
        const posts = await fetchPosts(user.id);
        const comments = await fetchComments(posts[0].id);
        return { user, posts, comments };
    } catch (error) {
        // Catches errors from ANY await above
        if (error.name === 'AbortError') {
            console.log('Request was cancelled');
        } else if (error instanceof TypeError) {
            console.error('Network error:', error.message);
        } else {
            console.error('Unexpected error:', error);
            throw error; // Re-throw if you can't handle it
        }
    }
}
The sequential vs. parallel mistake (critical performance issue):
// BAD: Sequential -- takes ~3 seconds (1s + 1s + 1s)
async function loadData() {
    const users = await fetchUsers();     // Wait 1s
    const posts = await fetchPosts();     // Wait 1s
    const comments = await fetchComments(); // Wait 1s
    return { users, posts, comments };
}

// GOOD: Parallel -- takes ~1 second (all at once)
async function loadData() {
    const [users, posts, comments] = await Promise.all([
        fetchUsers(),
        fetchPosts(),
        fetchComments(),
    ]);
    return { users, posts, comments };
}
This is one of the most common performance bugs in production code. Developers write sequential awaits for independent operations out of habit, turning what should be a 200ms page load into a 1.2s waterfall.Async iteration (for-await-of):
// Processing a stream of data
async function processStream(readable) {
    for await (const chunk of readable) {
        await processChunk(chunk);
    }
}

// Processing paginated API results
async function* fetchAllPages(url) {
    let nextUrl = url;
    while (nextUrl) {
        const response = await fetch(nextUrl);
        const data = await response.json();
        yield data.items;
        nextUrl = data.nextPage;
    }
}

for await (const page of fetchAllPages('/api/items')) {
    page.forEach(item => console.log(item));
}
Top-level await (ES2022, ESM only):
// In an ES module (.mjs or type: "module")
const config = await fetch('/config.json').then(r => r.json());
const db = await connectToDatabase(config.dbUrl);

export { db, config };
// Importing modules WAIT for this module's top-level awaits to resolve
Red flag answer: “Async/await replaces Promises.” It doesn’t — it’s built on Promises and works WITH them. Also a red flag: not knowing about Promise.all for parallel execution, or writing sequential awaits for independent operations and not recognizing the performance impact.Follow-up questions:Q: What happens if you forget to await a Promise inside an async function?The function continues executing without waiting for the Promise to resolve. This is a common bug — you get a Promise object instead of the resolved value. Example: const data = fetchUser() gives you a Promise, not user data. if (data.name) is always truthy (the Promise object exists). The insidious part: no error is thrown at this point, so the bug silently produces wrong behavior. Worse, if the unawaited Promise rejects, you get an unhandled rejection. ESLint rules like @typescript-eslint/no-floating-promises and require-await catch this at lint time. TypeScript’s type system also helps — data would be typed as Promise<User> not User, so data.name would be a type error.Q: How do you handle errors differently for parallel operations with Promise.all?Promise.all fails fast — if any Promise rejects, the entire result is rejected, and you lose the results of successful Promises. Three approaches: (1) Promise.allSettled — lets all Promises settle, then you inspect each result’s status field. (2) Wrap each Promise with a catch that converts rejections to a known error shape: Promise.all(urls.map(url => fetch(url).catch(err => ({ error: err, url })))). (3) For critical + non-critical mixed: await critical ones with Promise.all, and non-critical ones with Promise.allSettled. In production, the right choice depends on whether partial results are useful. Loading a user’s profile (critical) and their notification count (non-critical) — use approach 3.Q: Can you use await outside of an async function?Only in ES modules with top-level await (Node.js 14.8+ with ESM, modern browsers). In CommonJS (require), top-level await is not available. The workaround is wrapping in an async IIFE: (async () => { const data = await fetch(...); })();. Top-level await has an important implication for module loading: any module that imports from a module using top-level await will wait for that await to resolve before executing. This can create waterfall loading patterns if overused. Use it for initialization (database connections, config loading) but not for lazy operations that could block module graph resolution.
What interviewers are really testing: Deep understanding of this binding in JavaScript, the ability to explain why these methods exist (dynamic this context), and practical use cases beyond textbook examples — method borrowing, partial application, and event handler binding.Answer:All three methods allow you to explicitly set the this context of a function. They exist because JavaScript’s this is determined by how a function is called, not where it’s defined — and sometimes you need to override that behavior.
MethodExecutionArgumentsReturns
call()ImmediateIndividual: fn.call(ctx, a, b)Function’s return value
apply()ImmediateArray: fn.apply(ctx, [a, b])Function’s return value
bind()DeferredIndividual (partial): fn.bind(ctx, a)New function
call() — invoke immediately with explicit this:
function greet(greeting, punctuation) {
    console.log(`${greeting}, ${this.name}${punctuation}`);
}

const user = { name: 'Alice' };
greet.call(user, 'Hello', '!'); // "Hello, Alice!"
apply() — same as call but arguments as array:
greet.apply(user, ['Hello', '!']); // "Hello, Alice!"

// Classic use: passing array to Math.max (before spread existed)
const numbers = [5, 2, 8, 1, 9];
Math.max.apply(null, numbers); // 9
// Modern: Math.max(...numbers) -- spread replaced this use case
bind() — returns a new function with fixed this (and optionally fixed args):
const greetAlice = greet.bind(user, 'Hey');
greetAlice('!');  // "Hey, Alice!"
greetAlice('?');  // "Hey, Alice?"

// bind is permanent -- cannot be re-bound
const reBound = greetAlice.bind({ name: 'Bob' });
reBound('!'); // Still "Hey, Alice!" -- original bind wins
Real-world use cases:1. Method borrowing:
const arrayLike = { 0: 'a', 1: 'b', 2: 'c', length: 3 };
// arrayLike doesn't have array methods, but we can borrow them
const arr = Array.prototype.slice.call(arrayLike);
// Modern: Array.from(arrayLike) -- but the pattern is still common

// Borrowing toString for type checking
Object.prototype.toString.call([]);    // '[object Array]'
Object.prototype.toString.call(null);  // '[object Null]'
2. Event handler binding (React class components):
class Button extends React.Component {
    constructor(props) {
        super(props);
        // Without bind: 'this' is undefined in handleClick
        this.handleClick = this.handleClick.bind(this);
    }

    handleClick() {
        console.log(this.props.label); // 'this' works because of bind
    }

    render() {
        return <button onClick={this.handleClick}>Click</button>;
    }
}
3. Partial application (fixing some arguments):
function log(level, timestamp, message) {
    console.log(`[${level}] ${timestamp}: ${message}`);
}

const errorLog = log.bind(null, 'ERROR');
const warnLog = log.bind(null, 'WARN');

errorLog(Date.now(), 'Connection failed');
// [ERROR] 1680000000000: Connection failed
4. setTimeout with context:
const user = {
    name: 'Alice',
    greetLater() {
        // Without bind: 'this' would be Window/undefined
        setTimeout(function() {
            console.log(`Hello, ${this.name}`);
        }.bind(this), 1000);

        // Modern: arrow function captures 'this' lexically
        setTimeout(() => {
            console.log(`Hello, ${this.name}`);
        }, 1000);
    }
};
Red flag answer: “call passes arguments one by one, apply passes as array, bind returns a function.” This describes the syntax but not the why. Strong candidates explain the this problem, give real use cases, and mention that arrow functions have largely replaced bind for the most common use case.Follow-up questions:Q: How do arrow functions change the need for bind, and what are the tradeoffs?Arrow functions lexically capture this from their enclosing scope — they don’t have their own this. This eliminates the most common use of bind: fixing this in callbacks and event handlers. In React, onClick={() => this.handleClick()} or class field arrow functions handleClick = () => {...} replaced the constructor bind pattern. Tradeoffs: (1) Arrow functions can’t be used as constructors (no new), (2) They don’t have arguments object, (3) They can’t be used as methods on objects if you need this to be dynamic: const obj = { name: 'A', getName: () => this.name }this is the outer scope, not obj. (4) In class components, arrow function class fields create a new function per instance (not on the prototype), using more memory if you have thousands of instances.Q: What is Function.prototype.bind doing under the hood? Can you implement it?
Function.prototype.myBind = function(context, ...boundArgs) {
    const fn = this;
    const bound = function(...callArgs) {
        // If called with 'new', use the new object as 'this'
        const thisArg = this instanceof bound ? this : context;
        return fn.apply(thisArg, [...boundArgs, ...callArgs]);
    };
    // Maintain prototype chain for 'new' support
    bound.prototype = Object.create(fn.prototype);
    return bound;
};
The new check is the tricky part — a bound function can still be used as a constructor with new, and in that case, the bound this is ignored in favor of the newly created object. This is specified in the ECMAScript standard and tested in interviews to check deep understanding.Q: In what situations would this be undefined even without strict mode?In strict mode, this is undefined for plain function calls (no object context). But even in sloppy mode, this can be undefined in ES modules (which are always strict). Arrow functions in the global scope of a module have this === undefined. Another case: destructured methods lose their context — const { method } = obj; method()this is undefined in strict mode. This is a common React bug when destructuring event handlers from a context object.
What interviewers are really testing: Whether you understand how event bubbling enables delegation as a performance pattern, can implement it correctly (handling dynamic elements, filtering targets), and know where modern frameworks use this pattern internally.Answer:Event delegation is a pattern where you attach a single event listener to a parent element instead of individual listeners on child elements. It works because of event bubbling — events fired on a child propagate up to ancestors. This is one of the most important DOM performance patterns.The problem delegation solves:
// BAD: 1,000 listeners for 1,000 items
const items = document.querySelectorAll('.list-item');
items.forEach(item => {
    item.addEventListener('click', handleClick);
});
// Problems:
// 1. 1,000 event listeners consume memory (~1KB each = 1MB for a large list)
// 2. Dynamically added items don't have listeners
// 3. Must manually cleanup on removal (or leak memory)
The delegation solution:
// GOOD: 1 listener handles all items, including future ones
document.getElementById('item-list').addEventListener('click', (e) => {
    const item = e.target.closest('.list-item');
    if (!item) return; // Click wasn't on a list item
    handleItemClick(item);
});
The closest() method is essential (not just e.target):
// HTML:
// <ul id="list">
//   <li class="item"><span class="icon">X</span> Item 1</li>
// </ul>

// BAD: e.target might be the <span>, not the <li>
list.addEventListener('click', (e) => {
    if (e.target.tagName === 'LI') { // Misses clicks on the span!
        handleClick(e.target);
    }
});

// GOOD: closest() traverses up to find the matching ancestor
list.addEventListener('click', (e) => {
    const item = e.target.closest('.item');
    if (item && list.contains(item)) {
        handleClick(item);
    }
});
// The list.contains(item) check prevents matching elements outside our list
Performance comparison at scale:
// Rendering a table with 10,000 rows and 5 columns = 50,000 cells
// Without delegation: 50,000 listeners
// With delegation: 1 listener on the <table>

// Benchmark (approximate):
// 50,000 addEventListener calls: ~150ms
// 1 addEventListener call: ~0.01ms
// Memory: ~50MB vs ~50KB
Delegation with dynamic content:
const container = document.getElementById('container');

container.addEventListener('click', (e) => {
    // Handle delete buttons
    if (e.target.matches('.delete-btn')) {
        const card = e.target.closest('.card');
        card.remove();
        return;
    }

    // Handle edit buttons
    if (e.target.matches('.edit-btn')) {
        const card = e.target.closest('.card');
        openEditModal(card.dataset.id);
        return;
    }
});

// Add new cards dynamically -- they automatically work!
function addCard(data) {
    container.innerHTML += `
        <div class="card" data-id="${data.id}">
            <h3>${data.title}</h3>
            <button class="edit-btn">Edit</button>
            <button class="delete-btn">Delete</button>
        </div>
    `;
}
How frameworks use delegation:
  • React: Since React 17, all events are delegated to the root DOM node (not document). This enables multiple React roots on the same page without event interference.
  • jQuery: $(parent).on('click', '.child', handler) — jQuery’s delegation syntax.
  • Vue: v-on directives attach directly to elements (no delegation by default), but libraries like vue-delegated-events add it for performance-critical lists.
Red flag answer: “Event delegation means putting the event listener on the parent.” Correct but incomplete. Candidates should know WHY (performance, dynamic elements), use closest() instead of just e.target.tagName, and be aware of the contains() guard pattern.Follow-up questions:Q: What are the limitations of event delegation?(1) Events that don’t bubble (focus, blur, scroll, mouseenter, mouseleave) can’t be delegated in the bubbling phase — you’d need the capture phase. (2) stopPropagation() called by any intermediate handler breaks delegation for that event. (3) There’s a slight performance overhead per event — the closest() or matches() check runs on every click, even irrelevant ones. For a form with 3 inputs, direct listeners are simpler and faster. Delegation shines when you have many similar elements or dynamic content. (4) Some CSS pseudo-elements (like ::before, ::after) can’t be event targets at all. (5) Debugging is harder because DevTools shows the listener on the parent, not the child, making it less obvious which element’s click is being handled.Q: How would you implement event delegation that supports multiple event types and namespaced handlers?
class DelegatedEvents {
    constructor(root) {
        this.root = root;
        this.handlers = new Map();
    }

    on(eventType, selector, handler) {
        if (!this.handlers.has(eventType)) {
            this.handlers.set(eventType, []);
            this.root.addEventListener(eventType, (e) => {
                for (const { selector: sel, handler: fn } of this.handlers.get(eventType)) {
                    const target = e.target.closest(sel);
                    if (target && this.root.contains(target)) {
                        fn.call(target, e, target);
                    }
                }
            });
        }
        this.handlers.get(eventType).push({ selector, handler });
    }

    off(eventType, selector) {
        const handlers = this.handlers.get(eventType);
        if (handlers) {
            this.handlers.set(eventType, handlers.filter(h => h.selector !== selector));
        }
    }
}
This is essentially what jQuery’s delegation engine does internally, simplified. Production implementations also handle event namespacing (.namespace suffixes for grouped removal) and one-time handlers.

Hard Level Questions

What interviewers are really testing: This is the single most important JavaScript internals question. They want to see if you can trace execution order through synchronous code, Promises (microtasks), setTimeout (macrotasks), and understand why JavaScript can handle concurrency on a single thread.Answer:The event loop is JavaScript’s concurrency mechanism. It’s the reason a single-threaded language can handle thousands of concurrent I/O operations without blocking. Understanding it is essential for debugging async timing bugs, preventing UI jank, and writing performant Node.js servers.The complete mental model:
     Call Stack          (executes one frame at a time)
         |
         v
    (stack empty?) ---> Microtask Queue    (Promises, queueMicrotask, MutationObserver)
         |                  |
         |              (drain ALL microtasks)
         |                  |
         v                  v
    Macrotask Queue     (setTimeout, setInterval, I/O, UI rendering)
         |
    (pick ONE macrotask)
         |
         v
    Back to Call Stack
Execution order rules:
  1. Execute all synchronous code (call stack)
  2. Drain the entire microtask queue (ALL microtasks, including ones added during processing)
  3. Execute ONE macrotask
  4. Back to step 2 (check microtasks again)
  5. (Browser only) Render/paint if needed (~16ms for 60fps)
The definitive example:
console.log('1: sync');

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

Promise.resolve().then(() => console.log('3: promise 1'));

Promise.resolve().then(() => {
    console.log('4: promise 2');
    setTimeout(() => console.log('5: nested setTimeout'), 0);
    Promise.resolve().then(() => console.log('6: nested promise'));
});

console.log('7: sync end');

// Output:
// 1: sync
// 7: sync end
// 3: promise 1
// 4: promise 2
// 6: nested promise    (microtask queue is fully drained before macrotask)
// 2: setTimeout
// 5: nested setTimeout
Why this order:
  1. Sync code runs: “1: sync”, “7: sync end”
  2. Call stack empty: drain microtasks — “3: promise 1”, “4: promise 2”
  3. “4: promise 2” adds a nested Promise (microtask) and setTimeout (macrotask)
  4. Nested Promise is a microtask, so drain continues: “6: nested promise”
  5. Microtask queue now empty. Pick one macrotask: “2: setTimeout”
  6. Microtask queue empty. Pick next macrotask: “5: nested setTimeout”
Node.js vs Browser event loop differences:Node.js has additional phases:
   timers          (setTimeout, setInterval callbacks)
      |
   pending I/O     (I/O callbacks from previous cycle)
      |
   idle/prepare    (internal use)
      |
   poll            (retrieve new I/O events, execute I/O callbacks)
      |
   check           (setImmediate callbacks)
      |
   close           (close event callbacks)
// Node.js specific:
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
// Order is non-deterministic! Depends on how fast the process starts.

// But INSIDE an I/O callback, setImmediate always fires first:
const fs = require('fs');
fs.readFile(__filename, () => {
    setImmediate(() => console.log('setImmediate')); // Always first
    setTimeout(() => console.log('setTimeout'), 0);  // Always second
});

// process.nextTick is NOT part of the event loop -- it fires
// between every phase transition, with higher priority than microtasks
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// nextTick fires before promise (nextTickQueue > microtaskQueue)
Why this matters in production:
  • UI blocking: A Promise chain with 100,000 .then callbacks blocks the browser from rendering because microtasks drain completely before paint. Use requestAnimationFrame or chunking.
  • Starvation: Infinite microtasks (recursive queueMicrotask) starve macrotasks — your setTimeout callbacks never run.
  • Node.js performance: process.nextTick in a recursive loop can starve I/O. Use setImmediate instead for yielding to the event loop.
Red flag answer: “The event loop checks if there are callbacks and runs them.” This is too vague. Candidates must know microtask vs macrotask priority, be able to trace execution order of mixed sync/async code, and understand that microtasks drain completely before the next macrotask.Follow-up questions:Q: What’s the difference between requestAnimationFrame, setTimeout, and queueMicrotask in terms of timing?queueMicrotask(fn) fires at the end of the current task, before any rendering or macrotasks. setTimeout(fn, 0) fires in the next macrotask cycle, after rendering if needed. requestAnimationFrame(fn) fires just before the next browser repaint (~16ms at 60fps). For visual updates: always use requestAnimationFrame — it syncs with the display’s refresh rate and is batched efficiently. For DOM reads followed by DOM writes, requestAnimationFrame prevents layout thrashing. For logic that should run “soon” without blocking rendering: use setTimeout(fn, 0). For logic that must run before the next render: use queueMicrotask. Note: requestAnimationFrame doesn’t exist in Node.js.Q: How can microtask starvation happen and how do you prevent it?If a microtask queues another microtask, which queues another, the microtask queue never empties. No macrotasks execute, no rendering occurs — the UI freezes. Example: a recursive Promise.resolve().then(recursiveFunction). Prevention: if processing a large dataset asynchronously, batch work and yield to the event loop with setTimeout(fn, 0) between batches (not queueMicrotask, which doesn’t yield). Pattern: process 1000 items, then setTimeout(processNext1000, 0) to let the browser breathe. React’s useTransition and the Scheduler package implement this cooperative yielding pattern to keep the UI responsive during large state updates.Q: A developer reports that their setTimeout(fn, 100) is actually firing at 200-300ms. What could cause this?Several possibilities: (1) Long-running synchronous code on the main thread — the timer fires after the delay, but the callback can’t run until the stack is empty. A complex React render or heavy computation blocks it. (2) Heavy microtask processing (many resolved Promises draining before the macrotask). (3) Browser tab is in the background — Chrome throttles timers to 1000ms minimum for background tabs. (4) The system is under heavy load (CPU-bound). (5) Nested setTimeout calls in the same context — the HTML spec requires a minimum 4ms delay after 5 nested levels. (6) Garbage collection pauses (can be 10-50ms for major GC in V8). Diagnosis: use performance.now() before and after, check the Performance tab in DevTools for long tasks, or use the PerformanceObserver API to detect long tasks programmatically.
What interviewers are really testing: Whether you understand that async/await is syntactic sugar (not a new concurrency model), can identify when Promises are better than async/await (and vice versa), and know the gotchas of mixing them.Answer:async/await is syntactic sugar built on top of Promises — the underlying mechanism is identical. The difference is in readability, error handling ergonomics, and code flow. Neither replaces the other; they’re complementary.Comparison across dimensions:
AspectPromise .then chainsasync/await
ReadabilityNesting/chaining can be complexLinear, synchronous-looking flow
Error handling.catch() — catches async errors in chaintry/catch — familiar block syntax
DebuggingStack traces can be unclearStack traces show the await point
Parallel executionPromise.all([...]) — explicitEasy to accidentally write sequential code
Conditional logicAwkward nested .then chainsClean if/else with await
Loop integrationDifficult with .then in loopsNatural for/while with await
Where async/await clearly wins:
// Complex conditional async logic
// With Promises -- deeply nested, hard to follow
function processOrder(orderId) {
    return fetchOrder(orderId)
        .then(order => {
            if (order.status === 'pending') {
                return validatePayment(order.paymentId)
                    .then(payment => {
                        if (payment.valid) {
                            return fulfillOrder(order.id);
                        } else {
                            return refundOrder(order.id);
                        }
                    });
            } else {
                return fetchOrderStatus(order.id);
            }
        })
        .catch(error => handleError(error));
}

// With async/await -- clean, linear, debuggable
async function processOrder(orderId) {
    try {
        const order = await fetchOrder(orderId);
        if (order.status === 'pending') {
            const payment = await validatePayment(order.paymentId);
            if (payment.valid) {
                return await fulfillOrder(order.id);
            } else {
                return await refundOrder(order.id);
            }
        } else {
            return await fetchOrderStatus(order.id);
        }
    } catch (error) {
        handleError(error);
    }
}
Where Promise combinators are essential (can’t be replaced by await):
// Parallel execution -- Promises shine
const [user, posts, notifications] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchNotifications(userId),
]);

// Race condition patterns
const result = await Promise.race([
    fetchData(),
    timeout(5000), // Reject after 5 seconds
]);

// Partial failure tolerance
const results = await Promise.allSettled([
    sendEmail(user),
    sendSMS(user),
    sendPush(user),
]);
const failures = results.filter(r => r.status === 'rejected');
if (failures.length) reportPartialFailure(failures);
Error handling nuances:
// Gotcha: try/catch only catches awaited rejections
async function risky() {
    try {
        const p = fetchData(); // NOT awaited!
        doSomethingElse();
        const data = await p;  // Error caught HERE, not where p was created
    } catch (error) {
        // Stack trace might not show where fetchData was called
    }
}

// Gotcha: returning vs awaiting in try/catch
async function example() {
    try {
        return fetchData(); // If fetchData rejects, catch does NOT fire!
    } catch (error) {
        // This never catches fetchData rejection
    }
}

async function exampleFixed() {
    try {
        return await fetchData(); // NOW catch works
    } catch (error) {
        // This catches fetchData rejection
    }
}
Red flag answer: “Async/await is better than Promises in every way.” This is false. Promise combinators (all, allSettled, race, any) have no async/await equivalent — you still need them. Also, some patterns (like streaming or pipeline-style processing) read more naturally with .then chains. A strong engineer knows when each is appropriate.Follow-up questions:Q: Why is return await promise sometimes necessary inside a try/catch but otherwise redundant?Without try/catch, return await promise and return promise are functionally identical — the async function returns a Promise either way. But inside try/catch, there’s a crucial difference: return promise forwards the Promise directly, so if it rejects, the rejection bypasses the catch block entirely (the async function’s returned Promise rejects). return await promise unwraps the Promise inside the async function’s scope, so a rejection triggers the catch block. ESLint has a no-return-await rule that flags unnecessary return await, but the rule is disabled inside try/catch because there it’s necessary.Q: How do you handle concurrent async operations with a concurrency limit?Promise.all runs everything in parallel with no limit. For rate-limited APIs (Stripe: 25 req/s, GitHub: 5000 req/hr), you need controlled concurrency:
async function parallelLimit(tasks, limit) {
    const results = [];
    const executing = new Set();
    for (const task of tasks) {
        const p = task().then(result => {
            executing.delete(p);
            return result;
        });
        executing.add(p);
        results.push(p);
        if (executing.size >= limit) {
            await Promise.race(executing);
        }
    }
    return Promise.all(results);
}
Libraries like p-limit (300K+ weekly downloads) provide this. In production at scale, you’d also add per-second rate limiting and circuit breaker patterns.Q: What are async generators and for await...of? When would you use them?Async generators combine generators and async functions — they yield Promises. for await...of consumes async iterables. Use case: processing data that arrives over time (streaming HTTP responses, WebSocket messages, database cursors, paginated APIs). Example: Node.js Readable streams implement the async iterator protocol, so you can do for await (const chunk of readStream). Without this, you’d have complex event-based code with on('data') handlers. The pattern is natural for ETL pipelines: read from a stream, transform each chunk, write to output — all with backpressure handling built into the iteration protocol.
What interviewers are really testing: Whether you can use reduce for complex transformations (grouping, pivoting, pipeline building), understand when reduce is overkill vs. the right tool, and can reason about the accumulator pattern.Answer:reduce() processes an array element-by-element, accumulating a result. Unlike map (same-length array) and filter (subset array), reduce can produce ANY output type — a number, string, object, array, or even a function.Syntax and mental model:
array.reduce((accumulator, currentValue, index, array) => {
    // Return the next accumulator value
    return updatedAccumulator;
}, initialValue);

// Think of it as: "Start with X, then for each item, update X"
Step-by-step execution (building intuition):
const numbers = [1, 2, 3, 4, 5];
const sum = numbers.reduce((acc, num) => {
    console.log(`acc: ${acc}, num: ${num}, result: ${acc + num}`);
    return acc + num;
}, 0);

// acc: 0, num: 1, result: 1
// acc: 1, num: 2, result: 3
// acc: 3, num: 3, result: 6
// acc: 6, num: 4, result: 10
// acc: 10, num: 5, result: 15
// sum = 15
Practical use cases (beyond summing numbers):1. Grouping/categorizing (extremely common):
const transactions = [
    { category: 'food', amount: 50 },
    { category: 'transport', amount: 30 },
    { category: 'food', amount: 25 },
    { category: 'entertainment', amount: 100 },
    { category: 'food', amount: 75 },
];

const byCategory = transactions.reduce((groups, tx) => {
    const key = tx.category;
    groups[key] = groups[key] || [];
    groups[key].push(tx);
    return groups;
}, {});
// { food: [{...}, {...}, {...}], transport: [{...}], entertainment: [{...}] }

// Modern alternative: Object.groupBy (Stage 4, 2024)
// Object.groupBy(transactions, tx => tx.category);
2. Building a lookup map from an array:
const users = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
    { id: 3, name: 'Charlie' },
];

const userById = users.reduce((map, user) => {
    map[user.id] = user;
    return map;
}, {});
// { 1: {id:1, name:'Alice'}, 2: {id:2, name:'Bob'}, 3: {id:3, name:'Charlie'} }
// Now: userById[2].name = 'Bob' -- O(1) lookup
3. Composing functions (pipeline):
const pipeline = [
    (str) => str.trim(),
    (str) => str.toLowerCase(),
    (str) => str.replace(/\s+/g, '-'),
    (str) => encodeURIComponent(str),
];

const slugify = (input) => pipeline.reduce((result, fn) => fn(result), input);
slugify('  Hello World  '); // "hello-world"
4. Flattening nested structures:
const nested = [[1, 2], [3, [4, 5]], [6]];

// Shallow flatten
nested.reduce((flat, arr) => flat.concat(arr), []);
// [1, 2, 3, [4, 5], 6]

// Modern alternative: [].flat(Infinity)

// Deep flatten with reduce (recursive)
function deepFlatten(arr) {
    return arr.reduce((flat, item) =>
        flat.concat(Array.isArray(item) ? deepFlatten(item) : item)
    , []);
}
deepFlatten(nested); // [1, 2, 3, 4, 5, 6]
5. Implementing other array methods with reduce (shows mastery):
// map via reduce
const mapped = [1,2,3].reduce((acc, val) => [...acc, val * 2], []);

// filter via reduce
const filtered = [1,2,3,4,5].reduce((acc, val) =>
    val > 3 ? [...acc, val] : acc
, []);

// This proves reduce is the most general array method
The “reduce is overused” debate:
// BAD: Using reduce when map/filter is clearer
const doubled = numbers.reduce((acc, n) => [...acc, n * 2], []); // Just use .map()
const evens = numbers.reduce((acc, n) => n % 2 === 0 ? [...acc, n] : acc, []); // Just use .filter()

// GOOD: Use reduce when you need a different OUTPUT TYPE than array
const total = numbers.reduce((sum, n) => sum + n, 0); // number from array
const grouped = items.reduce((groups, item) => { /* ... */ }, {}); // object from array
Always provide an initial value:
// Without initial value -- first element becomes initial accumulator
[].reduce((acc, val) => acc + val);
// TypeError: Reduce of empty array with no initial value

[].reduce((acc, val) => acc + val, 0);
// Returns 0 (safe!)
Red flag answer: Only showing the sum example. reduce is the most powerful and versatile array method — candidates who can only sum numbers with it haven’t used it in real code. Another red flag: creating new arrays inside reduce with spread ([...acc, item]) which is O(n^2) because spread copies the entire array on each iteration.Follow-up questions:Q: What’s the performance issue with [...acc, item] inside reduce, and how do you fix it?Using spread inside reduce creates a new array on every iteration. For an array of n items, you copy 1, then 2, then 3… items = n*(n+1)/2 = O(n^2) total operations. For 100,000 items, this is ~5 billion copy operations. The fix: use acc.push(item); return acc; which is O(1) per iteration, O(n) total. Yes, push mutates the accumulator, but since the accumulator was created by reduce (the initial []), this is safe. Or better yet, just use .map() or .filter() which are already O(n). This performance difference matters at scale — I’ve seen production code where a reduce with spread on a 50K-item array took 3 seconds in Chrome.Q: How does reduceRight work and when would you use it?reduceRight processes from right to left (last element to first). The classic use case is function composition: const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x). This gives mathematical composition order where compose(f, g)(x) equals f(g(x)). It’s also useful when building strings or structures where the last element should be the innermost wrapper. In practice, reduceRight is rare — most use cases are covered by reduce with reversed logic or by using pipe (left-to-right composition) instead of compose.Q: How does Object.groupBy compare to the reduce grouping pattern?Object.groupBy(array, keyFn) (shipping in 2024, supported in Node 21+ and modern browsers) does exactly what the reduce grouping pattern does but in a single, declarative call. Object.groupBy(transactions, t => t.category) replaces 5+ lines of reduce code. It returns a null-prototype object (no inherited properties). There’s also Map.groupBy for when you need a Map. This is a case where the language caught up to the most common reduce pattern. However, for more complex aggregation (grouping AND summing, or grouping into custom structures), you still need reduce.
What interviewers are really testing: Functional programming depth, understanding of partial application vs. currying (they’re different!), and whether you’ve used these patterns in real code (middleware factories, configuration functions, React component patterns).Answer:Currying transforms a function that takes multiple arguments into a sequence of functions that each take a single argument. Named after Haskell Curry, it’s a core concept in functional programming.The transformation:
// Uncurried: takes all arguments at once
function add(a, b, c) { return a + b + c; }
add(1, 2, 3); // 6

// Curried: takes one argument at a time
function curriedAdd(a) {
    return function(b) {
        return function(c) {
            return a + b + c;
        };
    };
}
curriedAdd(1)(2)(3); // 6

// Arrow function syntax (cleaner)
const curriedAdd = a => b => c => a + b + c;
Currying vs. Partial Application (a distinction most candidates miss):
// CURRYING: transforms f(a, b, c) into f(a)(b)(c)
// Each function takes EXACTLY ONE argument

// PARTIAL APPLICATION: fixes some arguments, returns a function taking the rest
// The returned function can take MULTIPLE remaining arguments

function add(a, b, c) { return a + b + c; }

// Partial application (using bind)
const add5 = add.bind(null, 5);     // Fixes first arg
add5(2, 3);                          // 10 (takes remaining 2 args at once)

// Currying
const curriedAdd = a => b => c => a + b + c;
curriedAdd(5)(2)(3);                 // 10 (one arg at a time)
Real-world currying patterns:1. Configuration factories (extremely common):
// Curried logger factory
const createLogger = (service) => (level) => (message) => {
    console.log(`[${service}] [${level}] ${new Date().toISOString()}: ${message}`);
};

const authLogger = createLogger('AuthService');
const authError = authLogger('ERROR');
const authInfo = authLogger('INFO');

authError('Invalid token for user_123');
// [AuthService] [ERROR] 2024-01-15T10:30:00.000Z: Invalid token for user_123
authInfo('User logged in');
// [AuthService] [INFO] 2024-01-15T10:30:00.000Z: User logged in
2. API client factories:
const apiRequest = (baseUrl) => (method) => (path) => (data) =>
    fetch(`${baseUrl}${path}`, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: data ? JSON.stringify(data) : undefined,
    }).then(r => r.json());

const api = apiRequest('https://api.example.com');
const get = api('GET');
const post = api('POST');

const getUsers = get('/users');
const createUser = post('/users');

await getUsers();                          // GET /users
await createUser({ name: 'Alice' });       // POST /users
3. Validation pipeline:
const validate = (rules) => (fieldName) => (value) => {
    const errors = [];
    for (const rule of rules) {
        const error = rule(value);
        if (error) errors.push(`${fieldName}: ${error}`);
    }
    return errors;
};

const required = (v) => v ? null : 'is required';
const minLength = (min) => (v) => v?.length >= min ? null : `min ${min} chars`;
const isEmail = (v) => v?.includes('@') ? null : 'invalid email';

const validateEmail = validate([required, isEmail])('email');
const validatePassword = validate([required, minLength(8)])('password');

validateEmail('');         // ['email: is required', 'email: invalid email']
validateEmail('alice@x');  // []
validatePassword('short'); // ['password: min 8 chars']
Generic auto-curry utility:
function curry(fn) {
    return function curried(...args) {
        if (args.length >= fn.length) {
            return fn.apply(this, args);
        }
        return function(...moreArgs) {
            return curried.apply(this, [...args, ...moreArgs]);
        };
    };
}

const add = curry((a, b, c) => a + b + c);
add(1)(2)(3);    // 6
add(1, 2)(3);    // 6 (also works with multiple args)
add(1, 2, 3);    // 6 (also works uncurried)
Red flag answer: Only knowing the textbook “multiply(2)(3)” example. Currying is a real pattern used in logging, API clients, middleware, validation, and configuration. If a candidate can’t give a practical use case, they’ve only read about it, not used it.Follow-up questions:Q: How does currying relate to React.createElement and JSX?JSX <Component prop="value" /> compiles to React.createElement(Component, { prop: 'value' }). Higher-order component factories are essentially curried functions: const withAuth = (requiredRole) => (Component) => (props) => { ... }. Usage: const AdminPage = withAuth('admin')(Dashboard). This is currying in practice — fix the role, get a component wrapper, then it receives props at render time. Hooks partially replaced this pattern but currying still appears in createSlice (Redux Toolkit), styled() (styled-components), and connect(mapState, mapDispatch)(Component) (React-Redux).Q: What is “point-free style” and how does currying enable it?Point-free (or tacit) programming means defining functions without explicitly mentioning their arguments. Currying makes this possible because partially-applied functions are already ready to accept remaining arguments. Example: instead of users.map(user => formatName(user)), you write users.map(formatName) — point-free. With curried utilities: instead of data.filter(item => item.age > 18), a curried greaterThan lets you write data.filter(greaterThan(18)). Ramda and lodash/fp provide auto-curried utilities enabling this style. The tradeoff: point-free code can be elegant or cryptic depending on the team’s familiarity with the pattern. Use it where it genuinely improves readability.Q: Why are curried functions particularly useful in functional composition?Curried functions are composable because they always return a function until all arguments are provided. This means you can build complex operations by chaining simple, single-purpose functions. Example: const processUser = pipe(getAge, greaterThan(18), not) creates a function that checks if a user is underage, without ever writing function(user) { return !(user.age > 18); }. Each piece is reusable and testable in isolation. Libraries like Ramda are built entirely on this principle — every function is auto-curried and data-last, making composition the default.
What interviewers are really testing: Understanding of lazy evaluation, the iterator protocol, real use cases beyond simple sequences (async flow control, state machines, pagination), and how generators relate to async generators and the for-await-of loop.Answer:A generator function (declared with function*) can pause its execution at yield points and resume later. It returns an iterator object that you control with next(). This enables lazy evaluation, custom iteration, and cooperative multitasking patterns.How generators work under the hood:
function* counter() {
    console.log('Start');
    yield 1;
    console.log('After first yield');
    yield 2;
    console.log('After second yield');
    return 3; // Final value, done: true
}

const gen = counter();
// Nothing executes yet! Generator is created but paused.

console.log(gen.next()); // "Start" -> { value: 1, done: false }
console.log(gen.next()); // "After first yield" -> { value: 2, done: false }
console.log(gen.next()); // "After second yield" -> { value: 3, done: true }
console.log(gen.next()); // { value: undefined, done: true }
Two-way communication (sending values INTO a generator):
function* conversation() {
    const name = yield 'What is your name?';
    const age = yield `Hello ${name}! How old are you?`;
    return `${name} is ${age} years old`;
}

const chat = conversation();
console.log(chat.next());           // { value: 'What is your name?', done: false }
console.log(chat.next('Alice'));    // { value: 'Hello Alice! How old are you?', done: false }
console.log(chat.next(30));         // { value: 'Alice is 30 years old', done: true }
// The argument to next() becomes the return value of the yield expression
Real-world use cases:1. Infinite sequences without memory blowup:
function* fibonacci() {
    let [a, b] = [0, 1];
    while (true) {
        yield a;
        [a, b] = [b, a + b];
    }
}

// Take only what you need -- no infinite array in memory
function take(gen, n) {
    const result = [];
    for (const value of gen) {
        result.push(value);
        if (result.length >= n) break;
    }
    return result;
}

take(fibonacci(), 10); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
2. Paginated API consumption:
function* paginatedFetch(baseUrl) {
    let page = 1;
    let hasMore = true;

    while (hasMore) {
        const response = yield fetch(`${baseUrl}?page=${page}&limit=100`);
        hasMore = response.hasNextPage;
        page++;
    }
}

// Usage:
async function loadAllData() {
    const paginator = paginatedFetch('/api/products');
    let result = paginator.next();

    while (!result.done) {
        const response = await result.value;
        const data = await response.json();
        processPage(data.items);
        result = paginator.next(data);
    }
}
3. Unique ID generation (replacing global counters):
function* idGenerator(prefix = 'id') {
    let id = 0;
    while (true) {
        yield `${prefix}_${++id}_${Date.now()}`;
    }
}

const userIds = idGenerator('user');
const sessionIds = idGenerator('session');

userIds.next().value;    // 'user_1_1705334400000'
userIds.next().value;    // 'user_2_1705334400001'
sessionIds.next().value; // 'session_1_1705334400002'
4. State machine:
function* trafficLight() {
    while (true) {
        yield 'green';
        yield 'yellow';
        yield 'red';
    }
}

const light = trafficLight();
light.next().value; // 'green'
light.next().value; // 'yellow'
light.next().value; // 'red'
light.next().value; // 'green' (cycles)
5. yield* for delegation:
function* inner() {
    yield 'a';
    yield 'b';
}

function* outer() {
    yield 1;
    yield* inner(); // Delegates to inner generator
    yield 2;
}

[...outer()]; // [1, 'a', 'b', 2]
Generators and the iterator protocol:
// Making any object iterable with a generator
class Range {
    constructor(start, end) {
        this.start = start;
        this.end = end;
    }

    *[Symbol.iterator]() {
        for (let i = this.start; i <= this.end; i++) {
            yield i;
        }
    }
}

const range = new Range(1, 5);
console.log([...range]);       // [1, 2, 3, 4, 5]
for (const n of range) { }     // Works!
Red flag answer: “Generators are functions that can return multiple values.” This is technically accurate but misses the key insight: generators enable lazy evaluation and cooperative multitasking. If a candidate can only show yield 1; yield 2; yield 3 without practical use cases, they haven’t used generators in production.Follow-up questions:Q: How did generators relate to async/await before async/await existed?Before ES2017, libraries like co (by TJ Holowaychuk, creator of Express) used generators to simulate async/await. The idea: yield a Promise, and the library’s runner would await it and send the result back via next(resolvedValue). The pattern: co(function* () { const data = yield fetch('/api'); return data; }). This is exactly what async/await does under the hood — in fact, Babel initially transpiled async/await to generator-based code. Understanding this history helps you see that generators are a more general mechanism than async/await.Q: What is the difference between generator.return() and generator.throw()?generator.return(value) forces the generator to complete immediately — the next call to next() returns { done: true }. It triggers any finally blocks inside the generator. This is how for...of cleans up: when you break out of a loop, it calls return() on the iterator. generator.throw(error) injects an error at the current yield point — the generator can catch it with try/catch and continue, or let it propagate. This enables error injection for testing generators and handles errors in async-generator-based flows.Q: What are async generators and when would you use them over regular generators?Async generators (async function*) can use await inside them and yield Promises. They’re consumed with for await...of. Use case: streaming data processing where each item requires async work. Example: reading a file line by line, making an API call for each line, and yielding results. Node.js streams implement the async iterator protocol, so for await (const chunk of readableStream) works. Without async generators, you’d use complex event-based code with on('data') callbacks and manual backpressure management. The for await...of loop handles backpressure naturally — it won’t request the next value until the current one is processed.
What interviewers are really testing: Understanding of garbage collection, reference types (strong vs. weak), memory leak prevention, and real use cases (private data, caching, DOM node metadata).Answer:WeakMap and WeakSet hold “weak” references to objects, meaning they don’t prevent garbage collection. When the only remaining reference to an object is inside a WeakMap/WeakSet, that object can be garbage collected, and the entry is automatically removed.Why “weak” references matter:
// Regular Map -- strong reference, prevents GC
const cache = new Map();
let user = { name: 'Alice', data: new Array(1000000) };
cache.set(user, 'cached');

user = null; // We're done with the user
// BUT the Map still holds a strong reference -> object stays in memory
// Memory leak if we forget to cache.delete(user)

// WeakMap -- weak reference, allows GC
const weakCache = new WeakMap();
let user2 = { name: 'Bob', data: new Array(1000000) };
weakCache.set(user2, 'cached');

user2 = null; // We're done with the user
// WeakMap's reference is weak -> object CAN be garbage collected
// Entry automatically disappears from the WeakMap
WeakMap constraints and why they exist:
const wm = new WeakMap();

// Keys MUST be objects (or symbols in modern engines)
wm.set('string', 'value');  // TypeError
wm.set(42, 'value');        // TypeError
wm.set({}, 'value');        // OK

// Not iterable -- no forEach, keys(), values(), entries()
// No size property
// Why? Because entries can disappear at any time (GC is non-deterministic)
// Iterating over a WeakMap could give inconsistent results
Real-world use cases:1. Private data for classes (the original motivation):
const privateData = new WeakMap();

class User {
    constructor(name, ssn) {
        this.name = name;
        privateData.set(this, { ssn, loginAttempts: 0 });
    }

    getSSN(authToken) {
        if (!validateToken(authToken)) throw new Error('Unauthorized');
        return privateData.get(this).ssn;
    }

    recordLoginAttempt() {
        const data = privateData.get(this);
        data.loginAttempts++;
    }
}

const user = new User('Alice', '123-45-6789');
console.log(user.name);           // 'Alice' (public)
console.log(user.ssn);            // undefined (truly private)
// When 'user' is garbage collected, the private data goes with it
2. Caching expensive computations tied to objects:
const computeCache = new WeakMap();

function expensiveComputation(obj) {
    if (computeCache.has(obj)) {
        return computeCache.get(obj);
    }
    const result = /* heavy computation based on obj */ obj.data.reduce((a, b) => a + b);
    computeCache.set(obj, result);
    return result;
}

// Cache automatically cleans up when objects are GC'd
// No need to manually evict -- no memory leak possible
3. DOM node metadata (jQuery used this pattern internally):
const nodeData = new WeakMap();

function attachData(element, data) {
    nodeData.set(element, data);
}

function getData(element) {
    return nodeData.get(element);
}

const div = document.createElement('div');
attachData(div, { clicks: 0, visible: true });
// When div is removed from DOM and dereferenced, metadata is GC'd automatically
4. Tracking object processing (preventing double-processing):
const processed = new WeakSet();

function processEvent(event) {
    if (processed.has(event)) return; // Already handled
    processed.add(event);
    // Process the event...
}
// Event objects are GC'd after processing, WeakSet entries disappear
Key differences from Map/Set:
FeatureMap/SetWeakMap/WeakSet
Key typesAny valueObjects only (no primitives)
ReferencesStrong (prevents GC)Weak (allows GC)
IterableYes (for...of, forEach)No
.sizeYesNo
DeterministicYesNo (GC timing varies)
Use caseGeneral storageObject metadata, caching
Red flag answer: “WeakMap is like Map but weaker.” This says nothing. Candidates should explain the garbage collection behavior, why keys must be objects, why WeakMap isn’t iterable, and give at least one real use case.Follow-up questions:Q: Why can’t WeakMap keys be primitives?Primitives are passed by value, not by reference. When you write weakMap.set(42, 'value'), there’s no “object” to hold a weak reference to — the number 42 isn’t allocated on the heap in a way that can be garbage collected. A primitive value just IS — it doesn’t have a lifecycle. WeakMap’s entire purpose is to tie data to an object’s lifecycle, and primitives don’t have lifecycles. WeakRef (ES2021) follows the same logic — you can only create weak references to objects.Q: What is WeakRef and FinalizationRegistry, and how do they extend the weak reference concept?WeakRef (ES2021) lets you hold a weak reference to an individual object (not just in a Map/Set context) via new WeakRef(obj). You call ref.deref() to get the object back — it returns undefined if the object has been GC’d. FinalizationRegistry lets you register a callback that runs after an object is garbage collected (a “destructor” pattern). Use case: resource cleanup for objects that hold native resources (file handles, WebGL buffers, database connections). Warning: GC timing is non-deterministic, so never use these for critical logic — only for optimization and resource cleanup. The TC39 proposal explicitly warns against relying on finalization for correctness.Q: How would you implement an LRU cache, and would you use WeakMap for it?A WeakMap is NOT suitable for LRU caches because you can’t iterate it (no way to find the least recently used entry) and you can’t control eviction. An LRU cache needs: (1) O(1) get/set, (2) tracking access order, (3) evicting the oldest entry when capacity is reached. The standard implementation uses a combination of a Map (which maintains insertion order) and manual eviction: when the Map exceeds capacity, delete the first key (map.keys().next().value). For production, libraries like lru-cache (80M+ weekly npm downloads) provide battle-tested implementations with TTL, stale-while-revalidate, and size-based eviction. WeakMap is for “cache that cleans itself when the key disappears” — different from LRU where you want to control the eviction policy.
What interviewers are really testing: Understanding of stack vs. heap, garbage collection algorithms (mark-and-sweep, generational GC), practical knowledge of memory leaks and how to find them with profiling tools.Answer:JavaScript handles memory automatically through allocation, usage, and garbage collection. Understanding this is critical for building applications that don’t degrade over time — memory leaks are one of the top causes of Node.js production crashes and browser tab slowdowns.Memory architecture:Stack (fast, automatic, limited size):
function calculate() {
    let x = 10;           // Stack: 8 bytes
    let name = 'John';    // Stack: pointer to string in heap
    let flag = true;      // Stack: 1 byte
    return x + 1;
}
// When calculate() returns, its entire stack frame is popped -- instant cleanup
Stack stores: primitive values, function call frames, references (pointers) to heap objects. Stack size is limited (~1MB in V8) — exceeding it causes “Maximum call stack size exceeded” (stack overflow from deep recursion).Heap (slower, manual-ish, large):
let person = { name: 'John', hobbies: ['code', 'read'] };  // Object on heap
let numbers = new Array(1000000);  // Array on heap (8MB)
function greet() { }               // Function object on heap
Heap stores: objects, arrays, functions, closures, strings (longer than ~10 chars in V8). Heap is much larger (1.5GB default in Node.js 64-bit, adjustable via --max-old-space-size).Garbage Collection in V8 (Chrome/Node.js):V8 uses a generational garbage collector with two main spaces:1. Young Generation (Scavenger — Minor GC):
  • Small space (~1-8MB), for recently created objects
  • Uses a semi-space (copy) algorithm: two halves, objects are copied from “from-space” to “to-space” if still alive
  • Very fast (~1-2ms pauses)
  • Runs frequently (every few hundred milliseconds)
  • Most objects die young (the “generational hypothesis”)
2. Old Generation (Mark-Sweep/Mark-Compact — Major GC):
  • Larger space (up to 1.5GB), for objects that survived multiple young GC cycles
  • Mark phase: Traverse from roots (global scope, stack, handles), mark reachable objects
  • Sweep phase: Free memory of unmarked objects
  • Compact phase: Defragment memory (move objects to reduce fragmentation)
  • Slower (10-50ms pauses, can be 100ms+ for large heaps)
  • V8 does this incrementally and concurrently to minimize pauses
The five most common memory leaks:1. Accidental global variables:
function processData() {
    results = [];  // Missing 'let/const' -- creates global variable!
    // 'results' lives forever, grows with each call
}
// Fix: 'use strict' or ESLint's no-undef rule
2. Forgotten timers and callbacks:
// Leak: interval keeps running, holding references
const data = loadHugeDataset();
setInterval(() => {
    updateUI(data); // 'data' can't be GC'd even if no longer needed
}, 1000);
// Fix: clearInterval when component unmounts or data is stale
3. Detached DOM nodes:
let detached = document.createElement('div');
detached.innerHTML = '<span>Large content...</span>';
document.body.appendChild(detached);
document.body.removeChild(detached);
// 'detached' variable still references the node -- it's not GC'd
// Fix: detached = null;
4. Closure-captured variables:
function createProcessor() {
    const hugeBuffer = new ArrayBuffer(100 * 1024 * 1024); // 100MB
    return function process(data) {
        // Even if process() never uses hugeBuffer,
        // some engines keep it alive (V8 is smart about this, but not all)
        return data.length;
    };
}
5. Growing collections (Maps, Sets, Arrays, event emitters):
const eventLog = [];
app.on('request', (req) => {
    eventLog.push({ url: req.url, time: Date.now() });
    // Array grows forever, never trimmed
});
// Fix: Use a bounded collection, or write to external storage
Detecting memory leaks in production:
// Node.js: Track heap usage
setInterval(() => {
    const usage = process.memoryUsage();
    console.log({
        rss: `${(usage.rss / 1024 / 1024).toFixed(1)}MB`,
        heap: `${(usage.heapUsed / 1024 / 1024).toFixed(1)}MB`,
        external: `${(usage.external / 1024 / 1024).toFixed(1)}MB`,
    });
}, 10000);

// Chrome DevTools: Memory tab
// 1. Take heap snapshot before action
// 2. Perform the action (open/close modal, navigate, etc.)
// 3. Take heap snapshot after
// 4. Compare: "Objects allocated between Snapshot 1 and 2"
// 5. Look for growing retained sizes
Red flag answer: “JavaScript automatically handles memory so you don’t need to worry about it.” This is dangerously wrong. While GC is automatic, memory leaks are one of the most common production issues. Candidates who can’t name at least 2-3 common leak patterns haven’t debugged real applications.Follow-up questions:Q: How would you debug a Node.js server whose memory grows over 24 hours until it OOMs?Step 1: Add process.memoryUsage() logging at intervals to confirm the leak (heap growing, not just RSS). Step 2: Use --inspect flag and connect Chrome DevTools remotely (or use node --heapsnapshot-signal=SIGUSR2 to take snapshots in production without DevTools). Step 3: Take heap snapshots at T=0, T=1hr, T=4hr. Compare snapshots looking for: (a) Objects that keep growing in count, (b) Large retained sizes, (c) Strings or arrays with growing counts. Step 4: Use the “Allocation timeline” in DevTools to see what’s being allocated over time. Common culprits: growing Maps/Arrays used as caches without eviction, event listeners on long-lived emitters that accumulate per-request, closures holding references to request/response objects in middleware. Tools: Clinic.js, Heapdump, 0x for production profiling.Q: What is the difference between RSS, heap total, heap used, and external memory in Node.js?rss (Resident Set Size) is total memory allocated by the OS to the process — includes heap, stack, code segment, and shared libraries. heapTotal is V8’s total heap allocation (may include unused pre-allocated space). heapUsed is how much of the heap is actually occupied by live objects. external is memory used by C++ objects bound to JavaScript (Buffers, native addons). A common pattern: heapUsed stays flat but rss grows — this indicates a native memory leak (Buffers not being freed, or C++ addon leaks). heapUsed growing indicates a JavaScript-level leak (objects not being GC’d). external growing often points to Buffer leaks in stream processing.Q: How does ArrayBuffer and SharedArrayBuffer memory differ from regular heap memory?ArrayBuffer allocates raw binary memory, tracked under V8’s “external” memory (not the JavaScript heap). This memory is allocated by the OS and managed by V8’s GC only through the associated ArrayBuffer wrapper. If you create many small ArrayBuffers, the GC might not trigger often enough because the JS heap looks small while external memory grows. You can use --max-old-space-size for heap limits but external memory has no built-in limit. SharedArrayBuffer is similar but shareable between Workers — it requires explicit synchronization with Atomics (compare-and-swap, wait/notify). SharedArrayBuffer was disabled in browsers after Spectre/Meltdown and re-enabled only with proper COOP/COEP headers for cross-origin isolation.
What interviewers are really testing: Understanding of reference types, the limitations of common copy methods, knowledge of structuredClone (the modern solution most candidates miss), and when copying matters in practice (React state, Redux immutability).Answer:The distinction matters whenever you work with nested objects or arrays. A shallow copy duplicates the top-level properties; a deep copy recursively duplicates everything including nested objects.The reference problem:
const original = {
    name: 'Alice',
    scores: [90, 85, 92],
    address: { city: 'NYC', zip: '10001' }
};

// Shallow copy -- top level is new, nested is shared
const shallow = { ...original };
shallow.name = 'Bob';           // Safe: string is primitive, copied by value
shallow.scores.push(100);       // DANGER: mutates original.scores too!
shallow.address.city = 'LA';    // DANGER: mutates original.address too!

console.log(original.scores);       // [90, 85, 92, 100] -- corrupted!
console.log(original.address.city); // 'LA' -- corrupted!
Shallow copy methods (and their quirks):
const obj = { a: 1, nested: { b: 2 } };

// 1. Spread operator (most common)
const copy1 = { ...obj };

// 2. Object.assign
const copy2 = Object.assign({}, obj);

// 3. Array spread / Array.from (for arrays)
const arrCopy = [...originalArray];
const arrCopy2 = Array.from(originalArray);
const arrCopy3 = originalArray.slice();

// All of these are SHALLOW -- nested objects are still shared references
Deep copy methods (ranked by reliability):1. structuredClone() — THE modern solution (2022+):
const original = {
    name: 'Alice',
    date: new Date(),
    regex: /pattern/g,
    nested: { deep: { value: 42 } },
    set: new Set([1, 2, 3]),
    map: new Map([['key', 'value']]),
    buffer: new ArrayBuffer(8),
};

const deep = structuredClone(original);
deep.nested.deep.value = 99;
console.log(original.nested.deep.value); // 42 (untouched!)

// Handles: Date, RegExp, Map, Set, ArrayBuffer, Blob, File, ImageData
// Does NOT handle: Functions, DOM nodes, Symbols, Error objects
// Throws on: Functions in the object tree
2. JSON.parse(JSON.stringify()) — the classic hack:
const deep = JSON.parse(JSON.stringify(original));

// LOSES these types:
// - undefined values -> omitted
// - Functions -> omitted
// - Date -> becomes a string (not a Date object)
// - RegExp -> becomes empty object {}
// - Map/Set -> becomes empty object {}
// - Infinity/NaN -> becomes null
// - Circular references -> throws TypeError

// ONLY safe for: plain objects with strings, numbers, booleans, arrays, null
3. Library solutions (for edge cases):
// Lodash -- handles most types, configurable
import { cloneDeep } from 'lodash';
const deep = cloneDeep(original);
// Handles circular references, custom classes, etc.
// ~600 bytes when tree-shaken

// Immer -- immutable update pattern (used by Redux Toolkit)
import { produce } from 'immer';
const next = produce(original, draft => {
    draft.nested.deep.value = 99;
});
// 'original' untouched, 'next' has the change
// Uses structural sharing -- only changed paths are new objects
Performance comparison (10,000 copies of a medium object):
structuredClone:           ~150ms
JSON.parse(JSON.stringify): ~120ms (fastest but lossy)
Lodash cloneDeep:          ~200ms
Immer produce:             ~80ms  (structural sharing, not full copy)
When copying matters in practice:React state (must not mutate):
// BAD: Direct mutation -- React doesn't detect the change
const [user, setUser] = useState({ name: 'Alice', address: { city: 'NYC' } });
user.address.city = 'LA';
setUser(user); // Same reference! React skips re-render!

// GOOD: Deep update
setUser(prev => ({
    ...prev,
    address: { ...prev.address, city: 'LA' }
}));

// GOOD: With Immer (used by Redux Toolkit's createSlice)
setUser(produce(draft => {
    draft.address.city = 'LA';
}));
Red flag answer: “Use JSON.parse(JSON.stringify()) for deep copy.” Without mentioning its limitations (loses functions, Dates, undefined, circular refs), this is a dangerous recommendation. Strong candidates know about structuredClone and its limitations, and mention Immer for React/Redux contexts.Follow-up questions:Q: What is “structural sharing” and why is it better than deep copying for state management?Structural sharing creates new objects only for the parts of the tree that changed, reusing references for unchanged subtrees. If you have { a: { x: 1 }, b: { y: 2 } } and update a.x, structural sharing creates a new root object and a new a object, but b is the SAME reference. This is O(depth) instead of O(total nodes) for memory and time. Immutable.js and Immer both use this. It also enables efficient equality checks — React can do prevState.b === nextState.b to skip re-rendering components that depend on b. Without structural sharing, deep copying large state trees on every Redux action would be prohibitively expensive.Q: How do circular references affect copying, and how do you handle them?JSON.stringify throws a TypeError on circular references. structuredClone handles them correctly — it tracks visited objects and recreates the circular structure in the clone. Lodash’s cloneDeep also handles circular references. If you’re writing a custom deep clone, you need a WeakMap (or Map) to track visited objects: if (visited.has(obj)) return visited.get(obj);. The visited map maps original objects to their clones, so when you encounter the same object again, you return the existing clone instead of recursing infinitely.Q: When would you use Object.freeze vs. deep copying for immutability?Object.freeze is shallow — nested objects are still mutable. It’s a development-time guard, not a deep immutability solution. Use it for configuration objects that shouldn’t be modified: const CONFIG = Object.freeze({ apiUrl: '...', timeout: 5000 }). For state management, deep freeze (recursive Object.freeze) is expensive and impractical for large objects — it also prevents any mutation, which breaks patterns like Immer that rely on Proxy-based mutation tracking. In production, the standard approach is: use TypeScript’s Readonly<T> for compile-time immutability, Immer for runtime immutable updates, and Object.freeze only for small, static configuration objects.
What interviewers are really testing: Historical context for strict mode, specific behaviors it changes, awareness that ES modules are always strict, and practical implications for modern development.Answer:Strict mode ('use strict';) opts into a restricted variant of JavaScript that eliminates silent errors, prevents unsafe patterns, and enables future language optimizations. It was introduced in ES5 (2009) to fix decades-old language design mistakes that couldn’t be changed in sloppy mode without breaking the web.Enabling strict mode:
// Entire script
'use strict';
// All code below is strict

// Single function
function strictFunction() {
    'use strict';
    // Only this function is strict
}

// ES modules and classes are ALWAYS strict (no directive needed)
// import/export syntax = automatic strict mode
// class body = automatic strict mode
What strict mode changes (with real impact):1. No accidental globals:
'use strict';
mistypedVariable = 17; // ReferenceError: mistypedVariable is not defined

// Without strict mode: silently creates window.mistypedVariable
// Real impact: In a 50K-line codebase, a typo in a variable name silently
// creates a global that can be read/written by any other code
2. Assignment to non-writable properties throws:
'use strict';
const obj = {};
Object.defineProperty(obj, 'x', { value: 42, writable: false });
obj.x = 9; // TypeError: Cannot assign to read only property

// Without strict mode: silently fails -- obj.x stays 42
// Real impact: You think you updated a config value but it silently didn't change
3. this in functions is undefined, not window:
'use strict';
function showThis() {
    return this;
}
showThis(); // undefined

// Without strict mode: returns Window (browser) or global (Node.js)
// Real impact: Prevents accidental reads/writes to the global object
// Security impact: Without strict mode, `this.secretVariable` could leak data
4. No duplicate parameter names:
'use strict';
function sum(a, a, c) { // SyntaxError: Duplicate parameter name
    return a + a + c;
}

// Without strict mode: second 'a' shadows first -- sum(1, 2, 3) returns 7, not 5
5. Octal literals are forbidden:
'use strict';
const num = 010; // SyntaxError: Octal literals not allowed
// Use 0o10 for octal in strict mode

// Without strict mode: 010 === 8 (octal interpretation)
// Real impact: A phone number starting with 0 being interpreted as octal
6. delete on non-configurable properties throws:
'use strict';
delete Object.prototype; // TypeError

// Without strict mode: silently fails
7. eval doesn’t leak variables:
'use strict';
eval('var x = 42');
console.log(x); // ReferenceError: x is not defined

// Without strict mode: x leaks into the calling scope
// Security impact: eval'd code can inject variables into your scope
8. arguments object is decoupled from parameters:
'use strict';
function test(a) {
    a = 42;
    console.log(arguments[0]); // Original value, NOT 42
}

// Without strict mode: arguments[0] would be 42 (aliased)
Modern relevance:
  • ES modules (import/export) are always strict — no directive needed
  • Classes are always strict internally
  • Bundlers (Webpack, Vite) typically output modules, so code is strict by default
  • Node.js with "type": "module" in package.json makes all .js files strict
Practical implication: Most modern JavaScript is already in strict mode because of modules and bundlers. The directive 'use strict' matters mainly for: legacy scripts loaded via <script> tags, Node.js CommonJS files, and immediately-invoked function expressions in non-module contexts.Red flag answer: “Strict mode makes JavaScript stricter” without listing specific behaviors. Or not knowing that modules are automatically strict. Another red flag: thinking strict mode has a performance cost — in reality, strict mode enables V8 optimizations (no arguments aliasing, no with statement, predictable this behavior).Follow-up questions:Q: Does strict mode have any performance implications?Strict mode enables certain V8 optimizations. Without strict mode, the engine must handle with statements (which make scope chains unpredictable), arguments aliasing (changes to named params reflect in arguments), and potential global creation on assignment. In strict mode, the engine knows these won’t happen, enabling more aggressive inlining and scope optimization. The performance difference is small (1-5%) but it’s a net positive. There’s no performance cost to strict mode. This is why the V8 team and TC39 continue to make new features strict-only.Q: What happens when strict and non-strict code interact?Each function has its own strictness mode. A strict function called from non-strict code is still strict. A non-strict function called from strict code is still non-strict. However, concatenating strict and non-strict scripts can be problematic — if a strict file is bundled after a non-strict file, the 'use strict' might end up inside a function scope or be preceded by other statements, rendering it ineffective. This is why bundlers wrap each module in its own function scope. IIFE-wrapped libraries often include their own 'use strict' at the top of the IIFE.Q: What are some of the 'use strict' behaviors that were adopted as defaults in ES6+?Several strict-mode-only features became the default in ES6+: (1) Block scoping with let/const (strict-like behavior by default). (2) Classes are always strict. (3) Arrow functions don’t have their own this (no accidental global this). (4) ES modules are always strict. (5) Default parameters, destructuring, and for...of were designed with strict semantics in mind. The direction of the language is clearly toward strict-by-default, and sloppy mode is essentially a legacy compatibility layer. TC39 has stated they won’t add new syntax to sloppy mode.
What interviewers are really testing: Design pattern knowledge, ability to connect patterns to real implementations (EventEmitter, DOM events, RxJS, React state management), and understanding of the tradeoffs (memory leaks from forgotten subscriptions, ordering guarantees, error handling).Answer:The Observer pattern defines a one-to-many dependency where a subject (publisher) notifies all its observers (subscribers) when its state changes. It’s one of the most pervasive patterns in JavaScript — DOM events, Node.js EventEmitter, React’s state updates, Redux, RxJS, and WebSocket message handling all implement variations of it.Core implementation:
class EventEmitter {
    constructor() {
        this.listeners = new Map();
    }

    on(event, callback) {
        if (!this.listeners.has(event)) {
            this.listeners.set(event, []);
        }
        this.listeners.get(event).push(callback);
        // Return unsubscribe function (crucial for cleanup)
        return () => this.off(event, callback);
    }

    off(event, callback) {
        const callbacks = this.listeners.get(event);
        if (callbacks) {
            this.listeners.set(event, callbacks.filter(cb => cb !== callback));
        }
    }

    emit(event, ...args) {
        const callbacks = this.listeners.get(event) || [];
        callbacks.forEach(cb => cb(...args));
    }

    once(event, callback) {
        const wrapper = (...args) => {
            callback(...args);
            this.off(event, wrapper);
        };
        return this.on(event, wrapper);
    }
}
JavaScript’s built-in Observer implementations:1. DOM Events (the most common observer):
// The DOM IS an observer system
const button = document.getElementById('btn');

// Subscribe (observe)
const handler = (e) => console.log('Clicked at', e.clientX, e.clientY);
button.addEventListener('click', handler);

// Unsubscribe
button.removeEventListener('click', handler);

// Subject: the DOM element
// Observer: the event handler function
// Notification: the event object
2. Node.js EventEmitter:
const EventEmitter = require('events');

class OrderService extends EventEmitter {
    async createOrder(data) {
        const order = await db.orders.create(data);

        // Emit events -- observers handle side effects
        this.emit('order:created', order);
        this.emit('order:payment:pending', order);

        return order;
    }
}

const orderService = new OrderService();

// Different services observe independently (loose coupling)
orderService.on('order:created', (order) => {
    emailService.sendConfirmation(order.customerEmail, order);
});

orderService.on('order:created', (order) => {
    inventoryService.reserveItems(order.items);
});

orderService.on('order:created', (order) => {
    analyticsService.trackConversion(order);
});
// Adding new behavior = adding new observer, no changes to OrderService
3. Intersection Observer (DOM performance):
// Browser API for observing element visibility
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            entry.target.src = entry.target.dataset.src; // Lazy load
            observer.unobserve(entry.target); // Stop observing once loaded
        }
    });
}, { threshold: 0.1 });

document.querySelectorAll('img[data-src]').forEach(img => {
    observer.observe(img);
});
4. MutationObserver (DOM change detection):
const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
        if (mutation.type === 'childList') {
            console.log('DOM children changed');
        }
    }
});

observer.observe(document.body, {
    childList: true,
    subtree: true,
});
Observer pattern in state management:
// Simplified Redux store -- pure Observer pattern
function createStore(reducer, initialState) {
    let state = initialState;
    const listeners = new Set();

    return {
        getState: () => state,
        dispatch: (action) => {
            state = reducer(state, action);
            listeners.forEach(listener => listener(state));
        },
        subscribe: (listener) => {
            listeners.add(listener);
            return () => listeners.delete(listener); // Unsubscribe
        },
    };
}
The memory leak danger (Observer’s biggest pitfall):
// BAD: subscribing in a component without unsubscribing
class UserProfile extends React.Component {
    componentDidMount() {
        // Subscribes on mount
        eventBus.on('user:updated', this.handleUpdate);
    }
    // Forgot componentWillUnmount!
    // If component unmounts, handler still fires on a dead component
    // Memory leak: component instance can't be GC'd
}

// GOOD: Always clean up
componentDidMount() {
    this.unsubscribe = eventBus.on('user:updated', this.handleUpdate);
}
componentWillUnmount() {
    this.unsubscribe();
}

// React hooks equivalent:
useEffect(() => {
    const unsubscribe = eventBus.on('user:updated', handleUpdate);
    return () => unsubscribe(); // Cleanup on unmount
}, []);
Red flag answer: Only showing the YouTube subscriber analogy without connecting to real implementations (EventEmitter, DOM events, React/Redux). Candidates who can’t discuss unsubscription and memory leak risks haven’t used the Observer pattern in production.Follow-up questions:Q: What’s the difference between the Observer pattern and the Pub/Sub pattern?In the Observer pattern, the subject directly notifies observers — they have a direct reference to each other. In Pub/Sub, there’s an intermediary message broker/channel that decouples publishers from subscribers. Pub/Sub is more loosely coupled: publishers don’t know who subscribes, subscribers don’t know who publishes. Example: Node.js EventEmitter is Observer (emitter and listeners are directly connected). Redis Pub/Sub, Kafka, or RabbitMQ are Pub/Sub (producers and consumers are fully decoupled via the message broker). In frontend: React context is Observer-like (direct subscription), while a global event bus is Pub/Sub-like. The tradeoff: Observer is simpler and has less overhead; Pub/Sub scales better in distributed systems.Q: How does RxJS extend the Observer pattern, and when would you use it?RxJS implements the Observer pattern with composable operators for transforming, filtering, combining, and error-handling asynchronous data streams (Observables). Unlike basic EventEmitter, RxJS provides: (1) Operators like map, filter, debounceTime, switchMap, retry that compose into powerful pipelines. (2) Backpressure handling. (3) Cold vs. hot observables (unicast vs. multicast). (4) Built-in error handling and completion semantics. Use cases: complex event handling (autocomplete search with debounce + switchMap + error retry), WebSocket message processing, combining multiple async sources, and Angular (which uses RxJS extensively). The tradeoff: steep learning curve and large bundle size (~30KB min+gzip). For simple cases, Promises or EventEmitter are sufficient.Q: How would you implement a once listener that also handles errors properly?
once(event, callback) {
    const wrapper = (...args) => {
        this.off(event, wrapper);
        try {
            const result = callback(...args);
            if (result && typeof result.catch === 'function') {
                result.catch(err => this.emit('error', err));
            }
        } catch (err) {
            this.emit('error', err);
        }
    };
    return this.on(event, wrapper);
}
The key details: remove the listener BEFORE calling the callback (prevents re-entrancy if the callback emits the same event), handle both sync errors and async rejections, and delegate errors to an ‘error’ event (the Node.js convention). Node.js EventEmitter throws an unhandled exception if an ‘error’ event is emitted with no listener, which is a deliberate design choice to prevent silent error swallowing.

Expert Level Questions

What interviewers are really testing: Advanced JavaScript metaprogramming knowledge, understanding of how frameworks like Vue 3 and MobX use Proxies internally, and the ability to implement cross-cutting concerns (validation, logging, caching) without modifying business logic.Answer:Proxy creates a wrapper around an object that intercepts and customizes fundamental operations (property access, assignment, function calls, etc.). Reflect provides the default behavior for those operations, making it easy to call the original operation from within a trap.Basic Proxy with traps:
const user = { name: 'Alice', age: 30 };

const proxy = new Proxy(user, {
    get(target, property, receiver) {
        console.log(`Reading ${property}`);
        return Reflect.get(target, property, receiver);
    },
    set(target, property, value, receiver) {
        console.log(`Setting ${property} = ${value}`);
        if (property === 'age' && (typeof value !== 'number' || value < 0)) {
            throw new TypeError('Age must be a positive number');
        }
        return Reflect.set(target, property, value, receiver);
    },
});

proxy.name;       // Logs: "Reading name", returns "Alice"
proxy.age = -5;   // Throws: TypeError: Age must be a positive number
proxy.age = 25;   // Logs: "Setting age = 25", works
Real-world applications:1. Vue 3’s reactivity system (this is how ref() and reactive() work):
function reactive(target) {
    return new Proxy(target, {
        get(target, key, receiver) {
            track(target, key); // Register dependency
            return Reflect.get(target, key, receiver);
        },
        set(target, key, value, receiver) {
            const result = Reflect.set(target, key, value, receiver);
            trigger(target, key); // Notify watchers to re-render
            return result;
        },
    });
}
2. Validation layer without modifying business objects:
function withValidation(obj, schema) {
    return new Proxy(obj, {
        set(target, prop, value) {
            const validator = schema[prop];
            if (validator && !validator(value)) {
                throw new Error(`Invalid value for ${prop}: ${value}`);
            }
            return Reflect.set(target, prop, value);
        },
    });
}

const user = withValidation({}, {
    email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
    age: (v) => Number.isInteger(v) && v >= 0 && v <= 150,
});

user.email = 'alice@example.com'; // OK
user.email = 'not-an-email';      // Error: Invalid value for email
3. API client with automatic retries:
function withRetry(apiClient, maxRetries = 3) {
    return new Proxy(apiClient, {
        get(target, prop) {
            const original = target[prop];
            if (typeof original !== 'function') return original;
            return async function(...args) {
                for (let i = 0; i <= maxRetries; i++) {
                    try {
                        return await original.apply(target, args);
                    } catch (err) {
                        if (i === maxRetries) throw err;
                        await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
                    }
                }
            };
        },
    });
}
Available traps (13 total): get, set, has (for in operator), deleteProperty, apply (function calls), construct (new), getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, defineProperty, getOwnPropertyDescriptor, ownKeys.Performance note: Proxy operations have overhead (roughly 5-10x slower than direct property access in microbenchmarks). For hot paths processing millions of operations per second, this matters. Vue 3 mitigates this by only proxying top-level reactive objects, not deep nesting. For general application code, the overhead is negligible.Red flag answer: “Proxy is for intercepting object access.” Too vague. Candidates should know specific traps, mention Reflect as the companion API, and give at least one production use case (Vue reactivity, validation, or logging).Follow-up questions:Q: Why does Reflect exist alongside Proxy? Can’t you just use target[prop] directly?Reflect methods have the same signature as Proxy traps, making them the clean way to invoke default behavior. More importantly, Reflect.get/set properly handles the receiver parameter for prototype chain correctness. Without Reflect, target[prop] in a get trap can break when the proxy is used as a prototype or when getters use this. Reflect also returns boolean success/failure (for set, defineProperty) instead of throwing, which is consistent with the boolean return expected by Proxy traps.Q: What are Proxy “invariants” and why do they exist?Invariants are constraints that Proxy traps must obey to prevent breaking fundamental JavaScript guarantees. For example: a get trap cannot return a value different from the target’s property if that property is non-configurable and non-writable. A has trap cannot hide a non-configurable own property. These exist to maintain the integrity of the language’s core contracts — without them, a Proxy could lie about Object.freezed properties, making the freeze semantics meaningless. Violating invariants throws a TypeError.
What interviewers are really testing: Understanding of Symbols beyond “unique identifier” — specifically well-known Symbols that control language behavior, the Symbol registry (Symbol.for), and practical metaprogramming applications.Answer:Symbols are unique, immutable primitive values primarily used as property keys that are guaranteed not to collide with any other key (string or Symbol). They enable both namespacing (avoiding property collisions) and metaprogramming (customizing built-in language behavior).Three categories of Symbols:1. Unique Symbols (privacy and collision avoidance):
const id = Symbol('id');
const otherId = Symbol('id');
console.log(id === otherId); // false -- always unique

// Use as property keys to avoid collisions
const INTERNAL = Symbol('internal');
class Library {
    [INTERNAL] = { version: '2.0' };
    getVersion() { return this[INTERNAL].version; }
}
// Users can't accidentally overwrite INTERNAL with a string key
2. Global Symbol Registry (Symbol.for / Symbol.keyFor):
// Shared across realms (iframes, workers)
const s1 = Symbol.for('app.userId');
const s2 = Symbol.for('app.userId');
console.log(s1 === s2); // true -- same global symbol

Symbol.keyFor(s1); // 'app.userId'

// Use case: cross-module communication without importing the symbol
3. Well-Known Symbols (metaprogramming hooks):
// Symbol.iterator -- make objects iterable
class Range {
    constructor(start, end) { this.start = start; this.end = end; }
    [Symbol.iterator]() {
        let current = this.start;
        const end = this.end;
        return {
            next() {
                return current <= end
                    ? { value: current++, done: false }
                    : { done: true };
            }
        };
    }
}
for (const n of new Range(1, 5)) console.log(n); // 1, 2, 3, 4, 5

// Symbol.toPrimitive -- control type coercion
class Money {
    constructor(amount, currency) {
        this.amount = amount;
        this.currency = currency;
    }
    [Symbol.toPrimitive](hint) {
        if (hint === 'number') return this.amount;
        if (hint === 'string') return `${this.amount} ${this.currency}`;
        return this.amount; // default
    }
}
const price = new Money(42, 'USD');
console.log(+price);     // 42 (number hint)
console.log(`${price}`); // "42 USD" (string hint)
console.log(price + 10); // 52 (default hint)

// Symbol.hasInstance -- customize instanceof
class Even {
    static [Symbol.hasInstance](num) {
        return typeof num === 'number' && num % 2 === 0;
    }
}
console.log(4 instanceof Even); // true
console.log(3 instanceof Even); // false
Symbols are NOT truly private:
const secret = Symbol('secret');
const obj = { [secret]: 'hidden', public: 'visible' };

// Symbols don't show in normal iteration
Object.keys(obj);                    // ['public']
JSON.stringify(obj);                 // '{"public":"visible"}'
for (const key in obj) { }          // Only 'public'

// BUT they're discoverable:
Object.getOwnPropertySymbols(obj);   // [Symbol(secret)]
Reflect.ownKeys(obj);                // ['public', Symbol(secret)]
Red flag answer: “Symbols are for creating unique IDs.” This is only the simplest use case. Candidates who don’t know about well-known Symbols (Symbol.iterator, Symbol.toPrimitive, Symbol.hasInstance) are missing the metaprogramming power that makes Symbols a language-level feature rather than just a utility.Follow-up questions:Q: How does React use Symbols internally?React uses Symbol.for('react.element') as the $$typeof property on React elements. This prevents XSS attacks where an attacker could inject a JSON object that looks like a React element. Since Symbols can’t be represented in JSON (JSON.parse can’t create Symbols), a server-rendered object from untrusted data can never have a valid $$typeof. React checks for this Symbol before rendering any element. This is an elegant security pattern: the defense mechanism is built into the data format, not just the rendering logic.Q: What is Symbol.asyncIterator and how does it enable for await...of?Symbol.asyncIterator is the protocol for async iteration. An object implementing [Symbol.asyncIterator]() must return an object with a next() method that returns a Promise of { value, done }. This is what makes for await (const item of asyncIterable) work. Node.js Readable streams implement this protocol, enabling for await (const chunk of fs.createReadStream('file')). You can create custom async iterables for paginated APIs, WebSocket streams, or any data source that produces values over time.
What interviewers are really testing: Precise understanding of the event loop priority system, ability to predict execution order in complex async scenarios, and knowledge of how framework batching (React, Vue) leverages microtasks.Answer:The JavaScript event loop maintains two types of task queues with different priorities:Macrotasks (Task Queue):
  • setTimeout, setInterval
  • setImmediate (Node.js)
  • I/O callbacks
  • UI rendering events
  • MessageChannel, postMessage
  • One macrotask runs per event loop iteration
Microtasks (Microtask Queue):
  • Promise .then/.catch/.finally callbacks
  • queueMicrotask(fn)
  • MutationObserver callbacks
  • process.nextTick (Node.js, even higher priority than microtasks)
  • ALL microtasks drain before the next macrotask
The execution order algorithm:
1. Execute synchronous code (call stack)
2. Call stack empty? Drain ALL microtasks (including newly queued ones)
3. Browser: Render if needed (requestAnimationFrame runs here)
4. Execute ONE macrotask
5. Go to step 2
The definitive ordering test:
console.log('1: sync');

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

queueMicrotask(() => {
    console.log('3: microtask');
    queueMicrotask(() => console.log('4: nested microtask'));
});

Promise.resolve().then(() => console.log('5: promise'));

console.log('6: sync end');

// Output:
// 1: sync
// 6: sync end
// 3: microtask
// 5: promise
// 4: nested microtask  (queued during microtask drain, runs before macrotask)
// 2: setTimeout
queueMicrotask vs. Promise.resolve().then():
// Functionally equivalent, but queueMicrotask is:
// 1. More explicit about intent
// 2. Slightly more performant (no Promise allocation)
// 3. Not tied to Promise semantics

queueMicrotask(() => {
    console.log('microtask');
});

// Use queueMicrotask when you need microtask timing
// without Promise semantics
Why this matters in practice — React batching:
// React 17 (pre-automatic batching):
button.addEventListener('click', () => {
    setCount(c => c + 1);
    setFlag(f => !f);
    // These batch in event handlers (synchronous context)
    // But NOT in setTimeout/Promise callbacks:
    setTimeout(() => {
        setCount(c => c + 1); // Triggers re-render
        setFlag(f => !f);     // Triggers ANOTHER re-render
    });
});

// React 18 (automatic batching via microtasks):
// ALL state updates are batched, regardless of context
// React uses queueMicrotask internally for scheduling
The starvation risk:
// This starves macrotasks indefinitely:
function microLoop() {
    queueMicrotask(microLoop); // Infinite microtask recursion
}
microLoop();
// setTimeout callbacks NEVER fire
// UI NEVER renders
// The page freezes completely
Red flag answer: Not knowing that microtasks have higher priority than macrotasks, or being unable to predict the output order of mixed sync/Promise/setTimeout code. This is fundamental to understanding JavaScript’s concurrency model.Follow-up questions:Q: In Node.js, where does process.nextTick fit relative to microtasks and macrotasks?process.nextTick callbacks run BEFORE microtasks (Promises). The priority order in Node.js is: (1) Synchronous code, (2) process.nextTick queue, (3) Microtask queue (Promises), (4) Macrotask (timers, I/O). Recursive process.nextTick can starve I/O even harder than microtask recursion because it runs before I/O polling. The Node.js docs explicitly warn against using process.nextTick recursively and recommend setImmediate for yielding to the event loop. The distinction exists for historical reasons and backward compatibility.Q: How does the browser’s rendering fit into the task queue model?Rendering (style calculation, layout, paint) happens BETWEEN macrotasks, after microtasks drain, approximately every 16ms (60fps). requestAnimationFrame callbacks run just before rendering. The sequence: macrotask -> microtasks -> rAF -> render -> next macrotask. This is why long-running microtask chains block rendering — the browser can’t paint until the microtask queue is empty. For smooth animations, schedule work with requestAnimationFrame (synced with display refresh) rather than setTimeout (not synced, can cause dropped frames).
What interviewers are really testing: Deep understanding of JavaScript’s object model, the prototype chain, Object.create, how class syntax maps to prototypes, and when composition is preferable to inheritance.Answer:JavaScript uses prototypal inheritance: objects inherit directly from other objects through an internal [[Prototype]] link. There are no classes at the engine level — the class keyword is syntactic sugar over prototypes and constructor functions.The prototype chain:
const animal = {
    breathe() { return 'breathing'; }
};

const dog = Object.create(animal); // dog's [[Prototype]] is animal
dog.bark = function() { return 'woof'; };

const myDog = Object.create(dog);
myDog.name = 'Rex';

myDog.name;      // 'Rex' (own property)
myDog.bark();    // 'woof' (found on dog)
myDog.breathe(); // 'breathing' (found on animal)
myDog.toString();// '[object Object]' (found on Object.prototype)

// The chain: myDog -> dog -> animal -> Object.prototype -> null
How class maps to prototypes:
class Animal {
    constructor(name) { this.name = name; }
    speak() { return `${this.name} makes a sound`; }
}

class Dog extends Animal {
    bark() { return 'Woof!'; }
}

// Is equivalent to:
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);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() { return 'Woof!'; };

// In both cases:
const rex = new Dog('Rex');
rex.bark();    // 'Woof!' (on Dog.prototype)
rex.speak();   // 'Rex makes a sound' (on Animal.prototype)
rex instanceof Dog;    // true
rex instanceof Animal; // true
Prototypal vs. Classical inheritance:
AspectPrototypal (JavaScript)Classical (Java, C++)
MechanismObjects inherit from objectsClasses define blueprints, instances from classes
FlexibilityCan modify prototype at runtimeClass structure fixed at compile time
Multiple inheritancePossible via mixins/Object.assignLimited (interfaces in Java)
MemoryMethods shared on prototype (efficient)Methods on each instance (or vtable)
Composition over inheritance (the expert perspective):
// Instead of deep inheritance hierarchies:
// Animal -> Pet -> Dog -> GuideDog

// Use composition with mixins:
const canWalk = (state) => ({
    walk: () => `${state.name} is walking`,
});

const canBark = (state) => ({
    bark: () => `${state.name} says woof!`,
});

const canGuide = (state) => ({
    guide: (person) => `${state.name} guides ${person}`,
});

function createGuideDog(name) {
    const state = { name };
    return Object.assign(
        {},
        canWalk(state),
        canBark(state),
        canGuide(state),
    );
}

const rex = createGuideDog('Rex');
rex.walk();         // 'Rex is walking'
rex.bark();         // 'Rex says woof!'
rex.guide('Alice'); // 'Rex guides Alice'
Red flag answer: “JavaScript classes work the same as Java classes.” They don’t — JavaScript class is syntactic sugar over prototypes, with fundamentally different mechanics (dynamic dispatch, runtime prototype modification, no access modifiers until recent private fields). Candidates who can’t explain the prototype chain or Object.create are relying on class syntax without understanding what it does.Follow-up questions:Q: What is the performance difference between own properties and prototype chain lookups?Own property access is O(1) — a direct hash table lookup on the object. Prototype chain lookup traverses up the chain until the property is found or the chain ends (null). In the worst case, it’s O(chain depth). V8 optimizes this with “hidden classes” (also called “shapes” or “maps”) and inline caches — after the first lookup, V8 caches the lookup path and subsequent accesses are nearly as fast as own property access. However, very long prototype chains (depth 10+) can defeat these optimizations. In practice, keeping chains shallow (2-3 levels) and using hasOwnProperty() checks when needed is the standard practice.Q: How do private class fields (#) work under the hood?Private fields (#field) are not stored on the prototype — they’re stored directly on the instance using a WeakMap-like internal mechanism. They’re truly private: not accessible via this['#field'], not visible in Object.keys, not inherited by subclasses, and not accessible through Proxies. This is different from the convention of _privateField which is just naming convention with no enforcement. Under the hood, V8 uses a per-class brand check — when you access #field, the engine verifies the object was created by the right class constructor.
What interviewers are really testing: Awareness of upcoming language features, understanding of the decorator pattern in general, TypeScript’s experimental decorators vs. the TC39 Stage 3 proposal, and metaprogramming concepts.Answer:Decorators are a proposed JavaScript feature (TC39 Stage 3, shipping in 2024+) that provides a declarative syntax for modifying classes, methods, fields, and accessors. They’re functions that receive the decorated value and return a modified version.The TC39 decorator syntax:
// Method decorator
function logged(originalMethod, context) {
    const methodName = context.name;
    function replacementMethod(...args) {
        console.log(`Calling ${methodName} with`, args);
        const result = originalMethod.call(this, ...args);
        console.log(`${methodName} returned`, result);
        return result;
    }
    return replacementMethod;
}

class Calculator {
    @logged
    add(a, b) { return a + b; }
}

const calc = new Calculator();
calc.add(2, 3);
// Calling add with [2, 3]
// add returned 5
Class decorator:
function sealed(value, { kind }) {
    if (kind === 'class') {
        Object.seal(value);
        Object.seal(value.prototype);
    }
}

@sealed
class Config {
    constructor(data) { this.data = data; }
}
Practical patterns:
// Memoization decorator
function memoize(originalMethod, context) {
    const cache = new Map();
    return function(...args) {
        const key = JSON.stringify(args);
        if (!cache.has(key)) {
            cache.set(key, originalMethod.apply(this, args));
        }
        return cache.get(key);
    };
}

class MathService {
    @memoize
    fibonacci(n) {
        if (n <= 1) return n;
        return this.fibonacci(n - 1) + this.fibonacci(n - 2);
    }
}

// Rate limiting decorator
function rateLimit(callsPerSecond) {
    return function(originalMethod, context) {
        let lastCalled = 0;
        return function(...args) {
            const now = Date.now();
            if (now - lastCalled < 1000 / callsPerSecond) {
                throw new Error('Rate limit exceeded');
            }
            lastCalled = now;
            return originalMethod.apply(this, args);
        };
    };
}

class ApiClient {
    @rateLimit(10) // Max 10 calls per second
    async fetchData(url) { return fetch(url); }
}
TypeScript decorators (experimental) vs. TC39 decorators: TypeScript’s experimentalDecorators (used by Angular, NestJS) follow an older proposal with different semantics. The TC39 Stage 3 proposal is different — decorator functions receive (value, context) instead of (target, propertyKey, descriptor). TypeScript 5.0+ supports the new standard decorators alongside the legacy ones. New projects should target the TC39 standard.Red flag answer: Confusing TypeScript’s experimental decorators with the TC39 proposal. Or saying “decorators aren’t in JavaScript” — they’re Stage 3 and shipping in engines. Candidates should understand the decorator pattern conceptually even if they haven’t used the syntax.Follow-up questions:Q: How do you achieve decorator-like behavior today without the decorator syntax?Higher-order functions serve as decorators: const loggedFn = withLogging(originalFn). For classes, higher-order components (React HOCs), mixins, and Object.defineProperty for method modification. The decorator syntax is syntactic sugar for these patterns. The key advantage of syntax: it’s declarative (@logged above the method) vs. imperative (wrapping functions at the bottom of the file), making the intent immediately visible at the declaration site.Q: What are auto-accessors in the decorator proposal?Auto-accessors (accessor keyword) create a getter/setter pair backed by a private field: accessor name = 'default'. This is useful because decorators can intercept accessors but not plain field assignments. accessor gives decorators a hook to run custom logic on property get/set without requiring the developer to manually write getter/setter boilerplate.
What interviewers are really testing: Understanding of module history and why ES modules exist, practical differences between CJS and ESM (sync vs async, static vs dynamic), tree-shaking implications, and interop challenges.Answer:JavaScript went through several module system iterations, each solving different problems:CommonJS (CJS) — Node.js’s original system:
// math.js
const PI = 3.14159;
function add(a, b) { return a + b; }
module.exports = { PI, add };

// app.js
const { PI, add } = require('./math');
Characteristics: synchronous loading, dynamic (can require() inside if-blocks), copies values on require (not live bindings), single-threaded file resolution. Used by: Node.js (default), legacy npm packages.ES Modules (ESM) — the standard:
// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export default class Calculator { }

// app.js
import Calculator, { PI, add } from './math.js';

// Dynamic import (lazy loading)
const module = await import('./heavy-module.js');
Characteristics: asynchronous loading, static structure (imports must be top-level), live bindings (exported values update across modules), strict mode by default, supports tree-shaking.The key differences that matter in practice:
FeatureCommonJSES Modules
LoadingSynchronousAsynchronous
StructureDynamic (require anywhere)Static (top-level only, except import())
ExportsValue copyLive bindings
Tree-shakingNot possibleBuilt-in (bundlers can eliminate dead code)
thismodule.exportsundefined
File extension.js or .cjs.mjs or .js with "type": "module"
Tree-shaking (why ESM matters for bundle size):
// utils.js (ESM)
export function used() { return 'I am used'; }
export function unused() { return 'I am 100KB of unused code'; }

// app.js
import { used } from './utils.js';
// Bundler (Webpack/Rollup/Vite) can statically determine 'unused'
// is never imported and REMOVE it from the bundle
// This is only possible because ESM imports are static
CJS-ESM interop challenges:
// ESM can import CJS (usually):
import lodash from 'lodash'; // Works (CJS default export)
import { map } from 'lodash'; // Might not work (named exports need special handling)

// CJS cannot use 'import' syntax
// Must use dynamic import:
const { default: fetch } = await import('node-fetch'); // Async!

// This is why the Node.js ecosystem's CJS-to-ESM migration has been painful
Red flag answer: Not knowing the difference between require and import beyond syntax. Or not understanding tree-shaking and why static imports matter for bundle optimization. Also: not knowing that ESM uses live bindings while CJS copies values.Follow-up questions:Q: What is the “dual package hazard” and how do library authors handle it?When a package ships both CJS and ESM, a consumer might end up loading both versions if different parts of the dependency tree use different module systems. This means two copies of the module in memory, and instanceof checks fail across them. Solutions: (1) Ship ESM-only (increasing trend). (2) Use package.json "exports" field with conditional exports: "exports": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" }. (3) Use a thin CJS wrapper that re-exports from the ESM source. The "exports" field also prevents deep imports (import x from 'pkg/internal'), which helps library authors maintain a stable public API.Q: How does import() dynamic import work for code splitting?import('module') returns a Promise that resolves to the module namespace object. Bundlers like Webpack and Vite recognize this syntax and create separate chunks. Example: const AdminPanel = lazy(() => import('./AdminPanel')) in React creates a separate JS file that’s only loaded when the admin panel is rendered. This can reduce initial bundle size by 50-70% for large apps. The chunk is fetched with a network request on first access and cached afterward. Webpack supports “magic comments” for naming chunks: import(/* webpackChunkName: "admin" */ './AdminPanel').
What interviewers are really testing: Understanding the difference between concurrency (event loop) and parallelism (Web Workers), the communication model, SharedArrayBuffer and Atomics, and practical use cases.Answer:Web Workers provide true multi-threading in the browser. Each Worker runs in a separate OS-level thread with its own event loop, global scope, and JavaScript execution context. They cannot access the DOM — they communicate with the main thread via message passing.Basic Worker usage:
// main.js
const worker = new Worker('worker.js');

worker.postMessage({ data: largeArray, operation: 'sort' });

worker.onmessage = (e) => {
    console.log('Sorted result:', e.data);
};

worker.onerror = (e) => {
    console.error('Worker error:', e.message);
};

// worker.js
self.onmessage = (e) => {
    const { data, operation } = e.data;
    if (operation === 'sort') {
        data.sort((a, b) => a - b);
        self.postMessage(data);
    }
};
Transferable objects (zero-copy transfer):
// Instead of copying (slow for large data):
worker.postMessage(buffer);

// Transfer ownership (instant, zero-copy):
worker.postMessage(buffer, [buffer]);
// buffer is now EMPTY in the main thread -- ownership transferred
// This is crucial for large ArrayBuffers (video frames, 3D data)
SharedArrayBuffer + Atomics (shared memory):
// Shared memory between threads (requires COOP/COEP headers)
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);

worker.postMessage({ buffer: shared });

// Atomic operations prevent race conditions
Atomics.store(view, 0, 42);       // Thread-safe write
Atomics.load(view, 0);            // Thread-safe read
Atomics.add(view, 0, 1);          // Atomic increment
Atomics.wait(view, 0, 42);        // Block until value changes
Atomics.notify(view, 0, 1);       // Wake one waiting thread
Real-world use cases:
  • Figma: Runs the entire design engine in a Worker — UI stays responsive during complex operations
  • OffscreenCanvas: Render graphics/charts in a Worker thread
  • Encryption/hashing: Heavy crypto operations off the main thread
  • WASM workloads: Run WebAssembly computation in Workers
  • Video processing: Frame manipulation without UI jank
Node.js worker_threads:
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
    const worker = new Worker(__filename, { workerData: { num: 42 } });
    worker.on('message', (msg) => console.log('Result:', msg));
} else {
    const result = heavyComputation(workerData.num);
    parentPort.postMessage(result);
}
Red flag answer: Confusing Web Workers with Service Workers (different purpose — caching and offline support). Or saying “Workers share memory with the main thread” — they don’t by default; SharedArrayBuffer is opt-in and requires security headers.Follow-up questions:Q: What are the security requirements for SharedArrayBuffer and why?After Spectre and Meltdown (2018), browsers disabled SharedArrayBuffer because shared memory + high-resolution timing enabled side-channel attacks that could read other processes’ memory. It was re-enabled only with cross-origin isolation: the server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers. These headers prevent the page from loading cross-origin resources without explicit opt-in, which mitigates the timing attack surface. performance.now() was also reduced to 1ms resolution in non-isolated contexts for the same reason.Q: How does a Worker pool improve performance over creating Workers per task?Worker creation has overhead (~50-100ms for thread creation + JS context initialization). A Worker pool pre-creates N workers and dispatches tasks via a queue. When a Worker finishes, it picks up the next task. This amortizes creation cost and limits parallelism to the number of CPU cores (creating 1000 Workers on an 8-core machine thrashes the scheduler). Libraries like workerpool and Piscina (Node.js) provide battle-tested pool implementations. The pool size should typically equal navigator.hardwareConcurrency (number of logical CPU cores).
What interviewers are really testing: Deep V8 internals knowledge, understanding of hidden classes, inline caches, escape analysis, and the practical performance boundaries of closures in hot paths.Answer:Closures have a memory cost (the captured environment must be retained) and a potential performance cost (indirect variable access through the scope chain). Modern engines like V8 heavily optimize both, but understanding the limits helps write performant code.What V8 does under the hood:1. Scope analysis and context minimization:
function outer() {
    const used = 'needed';
    const unused = new Array(1000000); // 1 million items

    return function inner() {
        return used; // Only 'used' is captured
    };
}
// V8 analyzes which variables the inner function actually references
// 'unused' is NOT included in the closure context
// Only 'used' is retained in memory
2. The eval exception (optimization killer):
function outer() {
    const a = 1, b = 2, c = 3;

    return function inner() {
        return eval('a + b'); // eval might access ANY variable
    };
}
// V8 CANNOT optimize: eval makes all variables potentially needed
// Entire scope is captured, preventing dead variable elimination
3. Inline caching and closures:
// V8 creates "hidden classes" for object shapes
// Closures that return objects with consistent shapes are optimized well:
function createPoint(x, y) {
    return { x, y }; // Same hidden class every time
}

// This is fast because V8 caches the property lookup path
const p1 = createPoint(1, 2);
const p2 = createPoint(3, 4);
p1.x + p2.x; // Optimized via inline cache
When closures impact performance:Hot loops with closures:
// BAD: creates a new function object every iteration
for (let i = 0; i < 1000000; i++) {
    array.forEach(item => item.process(i));
    // New closure allocated per iteration of outer loop
}

// BETTER: hoist the closure if possible
const processor = (i) => (item) => item.process(i);
for (let i = 0; i < 1000000; i++) {
    array.forEach(processor(i));
    // Still creates closures, but the pattern is more optimizable
}

// BEST: avoid closures in hot loops entirely
for (let i = 0; i < 1000000; i++) {
    for (let j = 0; j < array.length; j++) {
        array[j].process(i);
    }
}
Memory implications in Node.js servers:
// Each request creates closures that capture request-scoped data
app.get('/api/data', (req, res) => {
    const userId = req.params.id;
    // This closure captures 'userId', 'req', 'res'
    db.query('SELECT * FROM users WHERE id = ?', [userId])
        .then(user => {
            // This nested closure captures 'req', 'res', 'user'
            // And indirectly 'userId' through the outer closure
            return formatResponse(user);
        })
        .then(formatted => res.json(formatted));
});
// Under 10K concurrent requests: 10K sets of closures in memory
// If req/res objects are large, this adds up
Red flag answer: “Closures are slow.” This is an oversimplification. V8 optimizes closures aggressively — in most code, the performance impact is unmeasurable. The concern is specific to hot loops (millions of iterations) and memory retention (large objects captured by closures in long-lived contexts).Follow-up questions:Q: How does V8’s TurboFan JIT compiler handle closures differently from the interpreter (Ignition)?Ignition (the interpreter) executes closures as-is — each closure access follows the scope chain. TurboFan (the optimizing JIT) can inline closures, eliminate allocations (escape analysis), and specialize based on observed types. If a closure is called frequently with the same types, TurboFan generates optimized machine code that accesses closure variables directly (as if they were local). However, if the closure captures a variable that changes type (e.g., initially a number, later a string), TurboFan deoptimizes back to Ignition. This is why consistent types in hot code paths matter — it keeps the JIT happy.Q: What is “escape analysis” and how does it help with closures?Escape analysis determines whether an object (including a closure’s context) can be proven to never “escape” the function that created it. If V8 can prove a closure is only used locally (not stored, returned, or passed to another function), it can allocate the closure’s context on the stack instead of the heap, and potentially eliminate the allocation entirely by inlining the closure’s variables. This optimization is why closures in Array.prototype.map/filter/reduce callbacks are nearly free — V8 recognizes these patterns and avoids heap allocation for the closure context.