JavaScript Interview Questions and Answers
Last updated:
Check out 60 of the most common JavaScript interview questions, then take an AI-powered practice interview
Q1How do var, let, and const actually differ at runtime, and what is the temporal dead zone?
BasicScoping
Answer
var is function-scoped and hoisted with an initial value of undefined, so reading it before the declaration line silently gives you undefined. let and const are block-scoped and also hoisted, but into the temporal dead zone (TDZ): the binding exists from the top of the block, yet any access before the declaration line throws ReferenceError: Cannot access 'x' before initialization. That error message is worth memorizing because interviewers quote it. const additionally requires an initializer and forbids reassignment of the binding, but it does not freeze the value: const arr = [] still allows arr.push(1), because only the reference is constant. The classic probe is a for loop with setTimeout: with var you get the final index printed on every tick because all callbacks close over one shared binding, while let creates a fresh binding per iteration, which is special-cased in the spec for for loops.
In production code the practical rule since ES2015 has been const by default, let when reassignment is genuinely needed, and var never, and linters enforce this with the no-var and prefer-const ESLint rules. Interviewers also check whether you know that var declarations at the top level of a script become properties of globalThis in browsers, while let and const do not, which is one of the subtle reasons module scope is safer than script scope.
console.log(a); // undefined (var hoisted, initialized)
var a = 1;
try {
console.log(b); // TDZ
} catch (e) {
console.log(e.message); // Cannot access 'b' before initialization
}
let b = 2;
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var:', i)); // 3, 3, 3
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let:', j)); // 0, 1, 2
}
const list = [];
list.push('ok'); // fine: value is mutable
// list = []; // TypeError: Assignment to constant variable
Key Points
- var: function scope, hoisted as undefined, attaches to globalThis at script top level
- let/const: block scope, hoisted into the TDZ, access throws ReferenceError
- const locks the binding, not the value; objects stay mutable
- let in a for header creates one binding per iteration
Q2What does hoisting really move, and why do function declarations behave differently from function expressions?
BasicScoping
Answer
Nothing physically moves. During the creation phase of an execution context, the engine registers all declarations in scope before executing a single line. Function declarations are registered with their full body, so you can call greet() above its declaration. var names are registered and initialized to undefined. let, const, and class names are registered but left uninitialized, which is why touching them early throws (the TDZ).
A function expression assigned to a variable follows the variable's rules, not the function's: const fn = function () {} is in the TDZ until that line runs, and var fn = function () {} is undefined until that line runs, so calling it early throws TypeError: fn is not a function rather than ReferenceError. That distinction between the two error types is a favorite interview trap. Class declarations are hoisted but TDZ-bound, so new User() before class User {} throws ReferenceError, unlike constructor functions.
One more wrinkle worth naming: function declarations inside blocks have messy legacy semantics in sloppy mode (Annex B of the spec), where the name may leak to the enclosing function scope in browsers; in strict mode and inside ES modules they are cleanly block-scoped. Since all module code is automatically strict, modern codebases rarely hit the legacy behavior, but interviewers at product companies sometimes probe it to see whether you know that ES modules are always strict mode.
greet(); // 'hello' (declaration fully hoisted)
function greet() { console.log('hello'); }
try {
ola(); // TypeError: ola is not a function
} catch (e) { console.log(e.constructor.name); }
var ola = function () {};
try {
new User(); // ReferenceError (class is TDZ-bound)
} catch (e) { console.log(e.constructor.name); }
class User {}
Key Points
- Declarations are registered in the creation phase; code does not move
- Function declarations hoist with their body; expressions follow variable rules
- Early call on a var-assigned expression: TypeError, not ReferenceError
- Classes hoist but are TDZ-bound; ES modules are always strict mode
Q3Walk through how == coercion works and when === can still surprise you.
BasicTypes & Coercion
Answer
Loose equality (==) runs the Abstract Equality algorithm: if types differ, it coerces step by step. null == undefined is true and neither equals anything else. Number vs string converts the string to a number, so '5' == 5. Boolean operands are converted to numbers first, which produces the infamous true == '1' being true.
Object vs primitive triggers ToPrimitive, calling valueOf then toString, which is why [] == '' and [0] == false are both true. Interviewers do not want you to recite the whole table; they want the rule of thumb and the famous traps: NaN == NaN is false, [] == ![] is true (![] is false, false becomes 0, [] becomes '' becomes 0). Strict equality (===) skips coercion but has its own edge cases: NaN === NaN is still false (use Number.isNaN or Object.is), and +0 === -0 is true even though Object.is(+0, -0) is false.
Object.is exists precisely to patch those two holes and is what React uses internally to compare state values, which is a nice production detail to drop. Practical guidance for real code: use === everywhere, with the single idiomatic exception x == null, which checks null and undefined in one comparison and is explicitly allowed by many style guides including the ESLint eqeqeq rule's 'smart' option.
console.log('5' == 5); // true (string -> number)
console.log(true == '1'); // true (boolean -> number, string -> number)
console.log(null == undefined); // true (special case)
console.log(null == 0); // false (null only equals undefined)
console.log([] == ''); // true (ToPrimitive: [] -> '')
console.log([] == ![]); // true (the classic trap)
console.log(NaN === NaN); // false
console.log(Number.isNaN(NaN)); // true
console.log(Object.is(NaN, NaN)); // true
console.log(+0 === -0); // true
console.log(Object.is(+0, -0)); // false
// The one accepted use of ==
function isNullish(x) { return x == null; }
Key Points
- == coerces via ToPrimitive/ToNumber; null == undefined is the special pair
- NaN is never equal to itself under == or ===; use Number.isNaN
- Object.is fixes NaN and signed-zero; React uses it for state comparison
- x == null is the idiomatic nullish check; otherwise always ===
Q4What are the eight data types in JavaScript, and which typeof results are misleading?
BasicTypes & Coercion
Answer
Seven primitives: string, number, boolean, undefined, null, symbol, and bigint, plus object (which covers arrays, functions, dates, maps, everything else). Primitives are immutable and compared by value; objects are compared by reference. typeof is reliable for most primitives but has famous lies: typeof null returns 'object', a bug from the first JavaScript implementation that can never be fixed without breaking the web, so the correct null check is x === null. typeof NaN is 'number', which sounds absurd until you remember NaN is a numeric value defined by IEEE 754. typeof for functions returns 'function' even though functions are objects, which is actually convenient. Arrays report 'object', so you must use Array.isArray(), never typeof, and not instanceof Array either, because instanceof fails across iframe boundaries where each realm has its own Array constructor.
For number specifics: all JavaScript numbers are 64-bit IEEE 754 doubles, safe integers run up to Number.MAX_SAFE_INTEGER (2^53 - 1), and beyond that you need BigInt, written with an n suffix (10n). BigInt and number never mix implicitly: 1n + 1 throws TypeError, and JSON.stringify throws on BigInt values, a real production gotcha when serializing database IDs from Node drivers that return BigInt. Symbols are guaranteed-unique keys mainly used for well-known protocol hooks like Symbol.iterator and for collision-free property names in shared objects.
console.log(typeof null); // 'object' (historical bug)
console.log(typeof NaN); // 'number'
console.log(typeof []); // 'object'
console.log(Array.isArray([])); // true (the correct check)
console.log(typeof 10n); // 'bigint'
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(9007199254740993 === 9007199254740992); // true! precision lost
console.log(9007199254740993n === 9007199254740992n); // false, BigInt exact
try { JSON.stringify({ id: 10n }); }
catch (e) { console.log(e.message); } // Do not know how to serialize a BigInt
Key Points
- 7 primitives + object; typeof null === 'object' is unfixable legacy
- Array.isArray over instanceof (instanceof breaks across iframes/realms)
- Integers are exact only up to 2^53 - 1; BigInt beyond that
- JSON.stringify throws on BigInt; watch out with DB-returned IDs
Q5What is a closure, and where do closures show up in code you ship every day?
BasicFunctions & Closures
Answer
A closure is a function bundled with references to the variables of the scope where it was defined. The inner function keeps those bindings alive even after the outer function has returned; it captures the variable itself, not a snapshot of its value. That live-binding behavior is exactly why the var-in-a-loop bug happens and why counters built with closures work.
You use closures constantly whether you name them or not: every React event handler that reads props is a closure (stale-closure bugs in useEffect are the framework version of this question), every debounce implementation stores its timer ID in a closure, module patterns hide private state in closures, and once/memoize helpers cache results in them. Interviewers usually push into two follow-ups. First, privacy: before class private fields (#field), closures were the only real encapsulation mechanism in JavaScript, and the counter factory below still cannot be tampered with from outside.
Second, memory: a closure keeps its captured scope reachable, so a long-lived callback (a setInterval handler, a global event listener) that captures a large object prevents that object from being garbage collected. The fix is to null out references you no longer need or to remove the listener. A precise definition to give in the room: 'a closure is the combination of a function and the lexical environment within which it was declared', then immediately demonstrate the counter, because concrete code beats definitions.
function makeCounter() {
let count = 0; // private: reachable only through the closures below
return {
inc: () => ++count,
dec: () => --count,
value: () => count,
};
}
const c = makeCounter();
c.inc(); c.inc();
console.log(c.value()); // 2
console.log(c.count); // undefined, no direct access
// once(): run an expensive init exactly one time
function once(fn) {
let done = false, result;
return (...args) => {
if (!done) { done = true; result = fn(...args); }
return result;
};
}
const init = once(() => Date.now());
console.log(init() === init()); // true
Key Points
- Captures live variable bindings, not value snapshots
- Powers debounce, memoize, module privacy, React handlers
- Long-lived closures keep captured objects out of GC; a real leak source
- Class #fields now offer privacy, but closures remain everywhere
Q6Explain the four rules of this binding and the order of precedence between them.
Basicthis & Binding
Answer
this is determined by the call site, not where the function is written, and four rules cover every case. (1) new binding: called with new, this is the freshly created object. (2) Explicit binding: call, apply, or a bind-created function fixes this to the given object; bind wins over later implicit calls and cannot be re-bound. (3) Implicit binding: obj.method() sets this to obj, but only the immediately preceding object counts (a.b.fn() binds to b). (4) Default binding: a plain fn() call gets undefined in strict mode, or globalThis in sloppy mode. Precedence is new > explicit > implicit > default. Arrow functions opt out entirely: they have no own this and resolve it lexically from the enclosing scope at definition time, which is why they are perfect for callbacks and wrong for object methods (an arrow method on an object literal sees the module scope, not the object).
The classic failure is extracting a method: const fn = user.greet; fn() loses the implicit binding and this becomes undefined, producing TypeError: Cannot read properties of undefined. That exact scenario is why pre-hooks React code was full of this.handleClick = this.handleClick.bind(this) in constructors. Also know that call/apply on an arrow function or on a bound function silently fails to change this, no error is thrown, which trips people up when debugging.
const user = {
name: 'Asha',
greet() { return `hi ${this.name}`; },
greetArrow: () => `hi ${this?.name}`, // arrow: lexical this, NOT user
};
console.log(user.greet()); // 'hi Asha' (implicit)
const loose = user.greet;
try { loose(); } catch (e) { console.log('lost this'); } // default binding
const fixed = user.greet.bind({ name: 'Ravi' });
console.log(fixed()); // 'hi Ravi' (explicit)
console.log(fixed.call({ name: 'X' })); // still 'hi Ravi': bind is permanent
function Person(name) { this.name = name; }
const p = new Person('Meera'); // new binding beats everything
console.log(p.name); // 'Meera'
Key Points
- Precedence: new > bind/call/apply > obj.method() > plain call
- Strict mode default binding is undefined, not globalThis
- Arrow functions resolve this lexically and ignore call/apply/bind
- Extracted methods lose their receiver; bind or wrap in an arrow
Q7Beyond syntax, what do arrow functions actually lack compared to function declarations?
BasicFunctions & Closures
Answer
Arrow functions are missing four things, and each absence is a feature or a footgun depending on context. No own this: they inherit it lexically, so they are ideal inside callbacks where you want the surrounding context, and wrong as object-literal methods or as event handlers that rely on this being the DOM element (addEventListener sets this to the element only for regular functions). No arguments object: use rest parameters (...args) instead; referencing arguments inside an arrow reaches the enclosing regular function's arguments, which confuses people during debugging.
No construct ability: new (() => {}) throws TypeError: X is not a constructor, and arrows have no prototype property at all, which you can verify with 'prototype' in fn. No own super or new.target either, they forward to the enclosing scope, which is why class field arrows work inside classes. Two more practical differences: arrow functions are always anonymous expressions (though they infer a name from the variable they are assigned to, visible in stack traces), and a concise-body arrow returning an object literal needs parentheses: () => ({ ok: true }), because braces otherwise parse as a block, silently returning undefined.
That silent-undefined bug is extremely common in code review. When asked 'when would you NOT use an arrow function', the strong answers are: object methods needing this, DOM handlers needing the element, anything used as a constructor, and generator functions, since there is no arrow generator syntax.
const obj = {
n: 42,
regular() { return this.n; },
arrow: () => this?.n,
};
console.log(obj.regular()); // 42
console.log(obj.arrow()); // undefined (lexical this)
const Arrow = () => {};
console.log('prototype' in Arrow); // false
try { new Arrow(); } catch (e) { console.log(e.message); }
// Arrow is not a constructor
// The silent block-vs-object bug:
const bad = () => { ok: true }; // 'ok:' parses as a label!
const good = () => ({ ok: true });
console.log(bad()); // undefined
console.log(good()); // { ok: true }
Key Points
- No own this, arguments, prototype, super, or new.target
- Cannot be constructors or generators
- Returning an object literal needs wrapping parens
- DOM handlers using this-as-element require regular functions
Q8What can tagged template literals do that plain string concatenation cannot?
BasicSyntax & Language
Answer
Template literals (backticks) give you interpolation with ${}, real multiline strings, and expression embedding, all familiar. The interview-worthy part is tagged templates: prefix a template with a function and the engine calls that function with an array of the literal string chunks plus each interpolated value as separate arguments. Crucially, the tag receives the raw pieces before they are joined, so it can process user-supplied values differently from developer-written literals.
That separation is the foundation of real libraries: styled-components uses tags to turn CSS templates into components, lit-html builds efficient DOM updates from them, and SQL libraries like the postgres npm package use a sql`` tag so every interpolated value becomes a bound parameter, structurally preventing SQL injection rather than relying on developer discipline. The strings array also carries a .raw property with backslash escapes unprocessed, which String.raw exposes directly: String.raw`C:\temp\new` keeps the backslashes literally, useful for Windows paths and regex sources. A practical HTML-escaping tag is a common hands-on exercise: escape each value, trust each literal chunk.
One subtle detail interviewers like: the strings array is frozen and identical (same reference) across repeated evaluations of the same template site, which is what allows lit-html to cache compiled templates keyed by that array. If you mention that referential identity, you signal genuinely deep knowledge.
const esc = (s) => String(s)
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
function html(strings, ...values) {
return strings.reduce(
(out, chunk, i) => out + chunk + (i < values.length ? esc(values[i]) : ''),
''
);
}
const userInput = '<img src=x onerror=alert(1)>';
console.log(html`<p>Hello ${userInput}</p>`);
// <p>Hello <img src=x onerror=alert(1)></p>
console.log(String.raw`C:\temp\new`); // C:\temp\new (no escapes processed)
Key Points
- Tags receive literal chunks and values separately: trust one, sanitize the other
- Real users: styled-components, lit-html, parameterized SQL tags
- String.raw and the .raw property skip escape processing
- The strings array is frozen and referentially stable per template site
Q9Show the destructuring patterns that actually appear in interviews: defaults, renaming, nesting, and swaps.
BasicSyntax & Language
Answer
Destructuring unpacks arrays by position and objects by key. The pieces interviewers test: renaming with a colon ({ id: userId }), defaults with = which apply only when the value is exactly undefined (null does NOT trigger a default, a frequent trap), nested patterns for reaching into API responses, rest collection with ...remaining, and swapping variables without a temp: [a, b] = [b, a]. In function signatures, destructured parameters with defaults create self-documenting option objects, the standard pattern for any function taking more than two arguments; pair it with a default of = {} on the whole parameter so calling the function with no arguments does not throw.
Array destructuring works on any iterable, so you can pull from strings, Sets, Map entries, and generator output, and you can skip positions with holes: [, second]. Two gotchas worth naming. First, destructuring null or undefined throws TypeError: Cannot destructure property, so guard API payloads: const { data } = response ?? {}.
Second, when destructuring into pre-declared variables you need parentheses around the whole statement, ({ a } = obj), because a leading brace parses as a block. In React and Node codebases destructuring is ubiquitous (props, require results, named imports mimicry), so panels treat fluency here as a proxy for how much real code you have written.
const res = { data: { user: { id: 7, name: 'Dev' } }, meta: null };
const {
data: { user: { id: userId, name = 'Anonymous' } },
meta = {}, // careful: null does NOT trigger this default
} = res;
console.log(userId, name, meta); // 7 'Dev' null
function connect({ host = 'localhost', port = 5432, ssl = false } = {}) {
return `${host}:${port} ssl=${ssl}`;
}
console.log(connect()); // localhost:5432 ssl=false
console.log(connect({ port: 3306 })); // localhost:3306 ssl=false
let a = 1, b = 2;
[a, b] = [b, a];
console.log(a, b); // 2 1
const [first, , third, ...rest] = [10, 20, 30, 40, 50];
console.log(first, third, rest); // 10 30 [40, 50]
Key Points
- Defaults fire only on undefined; null passes through
- Destructuring null/undefined throws; guard with ?? {}
- = {} on a destructured parameter makes the whole argument optional
- Assignment to existing vars needs wrapping parens
Q10map, filter, reduce, forEach: which returns what, and when is reduce the wrong tool?
BasicArrays
Answer
map transforms each element and returns a new array of the same length. filter returns a new array with only the elements passing the predicate. reduce folds the array into a single value using an accumulator, and forEach runs a side effect and returns undefined, which means you cannot chain after it and returning a value from its callback does nothing (a classic bug is 'return' inside forEach expecting to break; only exceptions break out, which is why some/every or a for...of loop are the right tools for early exit). None of these mutate the source array, though the callback can mutate elements if they are objects. The reduce question interviewers actually care about: reduce is correct for genuine folds (sum, min, building a Map) and wrong when a clearer method exists.
Building an object of grouped items with reduce plus spread on every iteration is O(n squared) and was such a common pattern that ES2024 added Object.groupBy and Map.groupBy to replace it. Also always pass an initial value to reduce: without one, reducing an empty array throws TypeError: Reduce of empty array with no initial value, a real production crash when a filtered list comes back empty. Know your chaining costs too: arr.filter().map() allocates an intermediate array; for hot paths of millions of elements a single loop or a reduce doing both steps is measurably faster, but for typical UI code the readable chain wins.
const orders = [
{ id: 1, amount: 250, status: 'paid' },
{ id: 2, amount: 480, status: 'pending' },
{ id: 3, amount: 120, status: 'paid' },
];
const paidTotal = orders
.filter(o => o.status === 'paid')
.reduce((sum, o) => sum + o.amount, 0); // ALWAYS pass the initial value
console.log(paidTotal); // 370
// ES2024: replaces the reduce-into-object grouping idiom
const byStatus = Object.groupBy(orders, o => o.status);
console.log(Object.keys(byStatus)); // ['paid', 'pending']
// Early exit: forEach cannot break; use some/every or find
const firstBig = orders.find(o => o.amount > 300);
console.log(firstBig?.id); // 2
try { [].reduce((a, b) => a + b); }
catch (e) { console.log(e.message); } // Reduce of empty array with no initial value
Key Points
- forEach returns undefined and cannot break early; use find/some/every
- reduce without an initial value throws on empty arrays
- Object.groupBy / Map.groupBy (ES2024) replace the spread-in-reduce grouping
- filter().map() chains allocate intermediates; fine for UI, not for hot loops
Q11When do ?? and ?. behave differently from || and &&, and where does ?. short-circuit?
BasicSyntax & Language
Answer
|| returns the right operand whenever the left is falsy, which includes 0, '' (empty string), NaN, and false. ?? (nullish coalescing) falls through only on null or undefined. The difference bites constantly with numeric and string settings: retries || 3 turns an explicit retries: 0 into 3, silently breaking a 'no retries' configuration, while retries ?? 3 keeps the 0. Same story for volume: 0, pageSize: 0, or an intentionally empty prefix ''.
Optional chaining ?. reads a property, calls a method (obj.method?.()), or indexes (arr?.[0]) only when the left side is not nullish; otherwise the entire chain evaluates to undefined without throwing. Key detail: short-circuiting covers the rest of the chain, so a?.b.c does not throw when a is null, but DOES throw if a exists and a.b is undefined, because the guard applies at the ?. position only. Function-call form obj.method?.() guards against the method being absent, but if the property exists and is not callable you still get TypeError: obj.method is not a function.
The logical assignment operators from ES2021 complete the family: x ??= y assigns only when x is nullish, x ||= y on falsy, x &&= y on truthy, and each evaluates the right side lazily, so cache ??= computeExpensive() will not run the computation when the cache is warm. The syntax parser also forbids mixing ?? with || or && without parentheses, a deliberate SyntaxError to prevent precedence bugs.
const config = { retries: 0, prefix: '', timeout: null };
console.log(config.retries || 3); // 3 (bug: 0 is falsy)
console.log(config.retries ?? 3); // 0 (correct)
console.log(config.prefix || '/'); // '/' (bug: '' is falsy)
console.log(config.timeout ?? 5000); // 5000 (null -> default, correct)
const user = { profile: null };
console.log(user.profile?.address?.city); // undefined, no throw
console.log(user.sendMail?.()); // undefined, method absent is fine
let cache;
cache ??= 'computed once'; // assigns only when nullish
cache ??= 'never happens';
console.log(cache); // 'computed once'
// console.log(a ?? b || c); // SyntaxError: parens required when mixing
Key Points
- || treats 0, '', NaN, false as missing; ?? only null/undefined
- ?. short-circuits the remainder of the chain at that position only
- ??=, ||=, &&= evaluate their right side lazily
- Mixing ?? with ||/&& without parentheses is a SyntaxError by design
Q12A setTimeout(fn, 0) and a resolved Promise.then are both pending: which callback runs first and why?
BasicEvent Loop
Answer
The promise callback runs first, always. JavaScript has one call stack and two kinds of queues: the macrotask queue (setTimeout, setInterval, DOM events, I/O) and the microtask queue (promise reactions, queueMicrotask, MutationObserver). The rule the whole question hangs on: after the current synchronous execution finishes, the engine drains the ENTIRE microtask queue, including microtasks queued by other microtasks, before it takes even one macrotask or updates the screen.
So the canonical output order for the snippet below is: 'script start', 'script end', 'promise 1', 'promise 2', 'timeout'. setTimeout(fn, 0) never means 'immediately'; it means 'as a macrotask, no earlier than 0 ms', and browsers clamp nested timers to a minimum of 4 ms after five levels of nesting, plus timers in background tabs get throttled to 1000 ms or more, which is why polling UIs go stale when tabbed away. Two production consequences follow. First, an await always yields to the microtask queue even when awaiting an already-resolved value, so code after an await never runs synchronously.
Second, a microtask that endlessly queues more microtasks starves rendering and timers completely, freezing the page, whereas a macrotask loop lets the browser breathe between iterations. If you can articulate 'drain all microtasks between macrotasks and before rendering', you have answered what the interviewer is really asking.
console.log('script start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
console.log('script end');
// Output:
// script start
// script end
// promise 1
// promise 2
// timeout
// await parks the rest of the function as a microtask:
async function demo() {
console.log('before await');
await null; // already settled, still yields
console.log('after await'); // runs as a microtask
}
Key Points
- Microtask queue drains fully between macrotasks and before paint
- setTimeout 0 is 'next macrotask at the earliest', with 4 ms nesting clamp
- await always yields, even on already-resolved values
- Infinite microtask chains starve rendering; macrotasks do not
Q13How does error propagation work in a promise chain, and where do people accidentally swallow errors?
BasicPromises
Answer
A promise is in one of three states: pending, fulfilled, or rejected, and it settles exactly once; further resolve/reject calls are ignored. Every .then returns a NEW promise, which is what makes chaining work: the value you return becomes the next fulfillment value, a thrown error or returned rejected promise becomes the next rejection, and returning a promise makes the chain wait for it. Rejections skip forward past every .then that lacks a rejection handler until they hit a .catch (or the second argument of a then).
The three classic swallowing bugs: (1) .catch placed in the middle of a chain converts the rejection into a fulfillment with whatever the catch returns (usually undefined), so downstream .then handlers happily run with undefined data; rethrow inside catch if you only wanted logging. (2) The forgotten return: inside a .then you call another async function but do not return its promise, so the chain does not wait and errors from it become unhandled rejections. (3) then(onFulfilled, onRejected) with both arguments: the onRejected there does NOT catch errors thrown by the onFulfilled sitting next to it, which is why chain-ending .catch is generally safer. Also mention .finally, which runs on either outcome, passes the settlement through untouched, and is where cleanup like clearing loading spinners belongs. Unhandled rejections surface via the unhandledrejection window event in browsers and crash Node processes by default since Node 15, so a chain-terminating catch is not optional in production code.
fetchUser()
.then(user => {
return fetchOrders(user.id); // returning is what makes the chain wait
})
.then(orders => {
console.log(orders.length);
throw new Error('boom'); // becomes a rejection downstream
})
.catch(err => {
console.error('handled:', err.message);
// return value here FULFILLS the chain; rethrow to keep it rejected
throw err;
})
.finally(() => hideSpinner()); // runs either way, passes result through
// The two-argument trap:
doWork().then(
result => { throw new Error('oops'); },
err => console.log('will NOT see oops') // only catches doWork's rejection
);
Key Points
- Each .then returns a new promise; return values/throws feed the next link
- Mid-chain catch converts rejection to fulfillment unless you rethrow
- then(onOk, onErr): onErr cannot catch onOk's own throw
- Unhandled rejections crash Node (15+) and fire unhandledrejection in browsers
Q14What does async/await desugar to, and how do you avoid accidentally serializing independent awaits?
BasicPromises
Answer
An async function always returns a promise: returned values are wrapped with Promise.resolve semantics, and thrown errors become rejections. Each await suspends the function, schedules the continuation as a microtask, and unwraps the awaited value (thenables are assimilated). Error handling becomes ordinary try/catch, which composes with finally for cleanup, and an uncaught throw inside async code is just a rejected promise, so a caller must await or .catch it: calling an async function without either is the modern version of a swallowed error, and ESLint's no-floating-promises (via typescript-eslint) exists to flag exactly that.
The performance mistake interviewers hunt for is sequential awaits on independent operations: awaiting fetchProfile then fetchOrders takes the sum of both latencies, while starting both promises first and awaiting Promise.all takes only the max. The subtle version of the bug: starting both but awaiting them one by one in separate statements still works for timing, but if the second rejects while you are awaiting the first, you can get an unhandled rejection window; Promise.all (or allSettled) is the correct pattern. Two more details worth volunteering: await in a loop body serializes iterations, which is sometimes exactly what you want for rate-limited APIs and otherwise a bug (use Promise.all over a map); and top-level await works in ES modules, where it blocks importers of that module until resolution, so use it for genuinely required startup work only.
// SLOW: ~600ms if each call takes ~300ms
async function slow(userId) {
const profile = await fetchProfile(userId);
const orders = await fetchOrders(userId); // waits for profile first!
return { profile, orders };
}
// FAST: ~300ms, both requests in flight together
async function fast(userId) {
const [profile, orders] = await Promise.all([
fetchProfile(userId),
fetchOrders(userId),
]);
return { profile, orders };
}
async function safe(userId) {
try {
return await fast(userId); // 'return await' keeps the frame in the stack trace
} catch (err) {
metrics.increment('user_load_failed');
throw err;
} finally {
releaseConnection();
}
}
Key Points
- async wraps returns in a promise; throw becomes rejection
- Independent operations: start first, then Promise.all, never await serially
- await in loops serializes; deliberate for rate limits, a bug otherwise
- return await inside try/catch is meaningful: it lets the catch run
Q15What does JSON.stringify silently drop or transform, and how do replacer and reviver help?
BasicTypes & Coercion
Answer
JSON.stringify has quiet rules that cause real production bugs. undefined, functions, and symbols are dropped from objects entirely and become null inside arrays (so lengths are preserved but object keys vanish). NaN and Infinity serialize as null. Date objects become ISO strings via their toJSON method, but JSON.parse does NOT turn them back into Dates, so a round trip silently converts Dates to strings, the number one cause of 'date.getTime is not a function' errors after reading from localStorage or a cache.
BigInt throws TypeError: Do not know how to serialize a BigInt. Circular references throw TypeError: Converting circular structure to JSON, common with DOM nodes, ORM entities with back-references, and React fiber objects accidentally logged into a payload. Map and Set serialize as {} because their data is not in enumerable properties.
The escape hatches: an object's own toJSON method controls its serialization; the replacer argument (a function or an allowlist array of keys) filters or transforms values, and an array replacer doubles as a field allowlist for API responses, a cheap way to avoid leaking password hashes; the reviver argument to JSON.parse walks every parsed value and can rebuild Dates or class instances. The third stringify argument adds indentation for logs. When you truly need to preserve types and cycles for in-memory copying, structuredClone is the right tool, not a JSON round trip; and know that the JSON.parse(JSON.stringify(x)) deep-copy idiom inherits every one of these losses.
const data = {
name: 'order',
fn: () => {}, // dropped
when: new Date(), // becomes ISO string
tags: new Set(['a']), // becomes {}
missing: undefined, // dropped
list: [undefined], // becomes [null]
};
console.log(JSON.stringify(data));
// {"name":"order","when":"2026-...","tags":{},"list":[null]}
// Array replacer = response field allowlist
const user = { id: 1, email: 'a@b.c', passwordHash: 'x' };
console.log(JSON.stringify(user, ['id', 'email']));
// {"id":1,"email":"a@b.c"}
// Reviver: rebuild Dates on the way in
const parsed = JSON.parse(
JSON.stringify({ at: new Date() }),
(key, value) =>
typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)
? new Date(value)
: value
);
console.log(parsed.at instanceof Date); // true
Key Points
- undefined/functions/symbols dropped in objects, null in arrays
- Dates survive as strings only; reviver must rebuild them
- Map/Set serialize as {}; BigInt and cycles throw
- Array replacer is a handy response-field allowlist
Q16Spread, Object.assign, structuredClone: which copies what, and when does a shallow copy betray you?
BasicObjects & References
Answer
Spread ({ ...obj }, [...arr]) and Object.assign copy one level deep: top-level primitives are duplicated, but nested objects and arrays are copied by reference, so mutating copy.address.city also changes the original. This is the root cause of an entire class of React bugs where state 'changes' but components do not re-render, or worse, re-render everywhere because a shared nested object was mutated. Interviewers expect you to say 'shallow' unprompted and demonstrate the failure.
For deep copies the modern answer is structuredClone, available in all evergreen browsers and Node since v17: it handles nested objects, arrays, Dates, Maps, Sets, RegExps, ArrayBuffers, and, crucially, circular references. Its limits matter too: it throws DOMException: object could not be cloned on functions and DOM nodes, drops property getters (it copies the current value), and does not preserve class prototypes, a cloned instance of your User class comes back as a plain object. The legacy JSON.parse(JSON.stringify(x)) idiom is strictly worse: everything structuredClone rejects plus the JSON losses (Dates to strings, undefined dropped, Map/Set emptied, cycles throw).
Lodash cloneDeep remains in codebases for prototype-preserving copies. Also distinguish copying from freezing: Object.freeze is shallow as well, freezing only the top level, so 'deeply immutable' requires recursive freezing or an immutability library. A sharp closing point: spread also skips non-enumerable properties and copies getters by invoking them, so copying exotic objects (errors, class instances) with spread quietly loses behavior.
const original = { name: 'Kiran', address: { city: 'Pune' }, joined: new Date() };
const shallow = { ...original };
shallow.address.city = 'Delhi';
console.log(original.address.city); // 'Delhi' (shared reference!)
const deep = structuredClone(original);
deep.address.city = 'Chennai';
console.log(original.address.city); // 'Delhi' (untouched)
console.log(deep.joined instanceof Date); // true (JSON trick would say false)
// Cycles: structuredClone handles them, JSON throws
const node = { name: 'root' };
node.self = node;
console.log(structuredClone(node).self.name); // 'root'
try { structuredClone({ fn: () => {} }); }
catch (e) { console.log(e.name); } // DataCloneError
Key Points
- Spread and Object.assign copy one level; nested objects stay shared
- structuredClone: deep, cycle-safe, keeps Date/Map/Set (Node 17+)
- It throws on functions/DOM nodes and loses class prototypes
- Object.freeze is also shallow; deep immutability needs recursion
Q17Why is 0.1 + 0.2 !== 0.3, and how should you actually handle money in JavaScript?
BasicNumbers
Answer
All JavaScript numbers are IEEE 754 double-precision floats. 0.1 and 0.2 have no exact binary representation, so each is stored as the nearest representable double, and adding those approximations yields 0.30000000000000004. This is floating-point behavior shared with Python and Java doubles, not a JavaScript defect, but JavaScript lacks a built-in decimal type, so you must handle it. For comparisons, check the difference against an epsilon: Math.abs(a - b) < Number.EPSILON scaled appropriately for the magnitudes involved.
For money, the professional answer is: never do arithmetic on floating rupees. Store and compute in integer paise (Razorpay's API does exactly this, amounts are integers in the smallest currency unit), format only at the display edge with Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }), which also gets you correct lakh/crore digit grouping. For amounts that could exceed 2^53 paise or for rate math, use a decimal library like decimal.js or big.js, or BigInt when everything stays integral.
Related number gotchas worth volunteering: toFixed returns a string and has surprising rounding on some values ((1.005).toFixed(2) gives '1.00' because 1.005 is actually stored slightly below 1.005); parseInt parses leading digits and ignores trailing junk (parseInt('12px') is 12) while Number('12px') is NaN, so pick based on whether trailing text is acceptable; and always pass the radix to parseInt in older codebases. A Proposal for a native Decimal type exists at TC39 but is not shipped, so say 'integer minor units or a decimal library', not 'wait for Decimal'.
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON); // true
// Money: integer paise, format at the edge
const pricePaise = 49999n * 100n; // BigInt if totals may grow huge
const inr = new Intl.NumberFormat('en-IN', {
style: 'currency', currency: 'INR',
});
console.log(inr.format(129999.5)); // ₹1,29,999.50 (lakh grouping)
console.log((1.005).toFixed(2)); // '1.00' (stored below 1.005)
console.log(parseInt('12px', 10)); // 12
console.log(Number('12px')); // NaN
console.log(Number('')); // 0 (surprise!)
Key Points
- IEEE 754 doubles cannot represent 0.1 exactly; epsilon-compare floats
- Money: integer minor units (paise), like Razorpay's integer amounts
- Intl.NumberFormat('en-IN') handles ₹ symbol and lakh/crore grouping
- parseInt tolerates trailing junk; Number('') is 0, Number('x') is NaN
Q18ES modules vs CommonJS: what differs at load time, and why does named-export interop break?
BasicModules
Answer
CommonJS (require/module.exports) loads synchronously at runtime: require is a function call that can sit inside an if block, and it returns whatever object the module assigned, copied by value at that moment. ES modules (import/export) are static: imports are hoisted, must sit at the top level, and are resolved before execution, which enables tree-shaking (bundlers like Vite and esbuild can drop unused named exports because the dependency graph is known without running code). ESM exports are live bindings, not copies: if the exporting module reassigns an exported let counter, importers see the new value, whereas a CJS consumer holds the stale destructured copy.
ESM also always runs in strict mode and has module-scoped this as undefined. In Node, .mjs files or "type": "module" in package.json opt into ESM; .cjs forces CommonJS. Interop is one-directional and lossy: ESM can import CJS, but named imports from CJS depend on Node's static analysis (the cjs-module-lexer package) detecting exports, which fails for dynamically built exports, producing SyntaxError: Named export not found, the standard fix being to import the default and destructure.
CJS cannot require an ESM graph synchronously in older Node versions and had to use dynamic import(); recent Node versions (22+) can require ESM modules that contain no top-level await. Add that dynamic import() returns a promise and works in both systems, which is the standard tool for lazy loading and conditional loading in ESM. Also know ESM circular imports yield TDZ errors on partially initialized bindings, while CJS yields partially filled export objects.
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1 (live binding: sees the update)
// CommonJS equivalent copies the value at require time:
// const { count } = require('./counter.cjs'); // stays 0 after increment()
// Interop failure and its fix:
// import { parse } from 'legacy-cjs-lib'; // may throw:
// SyntaxError: Named export 'parse' not found
import pkg from 'legacy-cjs-lib';
const { parse } = pkg;
// Dynamic import: lazy, conditional, promise-based
const { heavyChart } = await import('./charts.mjs');
Key Points
- ESM: static, hoisted, tree-shakeable, strict mode, live bindings
- CJS: runtime require, value copies, conditional loading
- Named-import-from-CJS depends on lexer detection; default-import fallback
- "type": "module", .mjs/.cjs extensions control Node's mode
Q19Explain event bubbling, capturing, and why event delegation is the standard pattern for lists.
BasicDOM & Events
Answer
When you click a button nested inside a card inside a list, the event travels in three phases: capturing from window down to the target, the target phase, then bubbling from the target back up to window. addEventListener attaches to the bubbling phase by default; passing { capture: true } (or true as the third argument) attaches to the capture phase. Most events bubble, but some do not: focus and blur do not (use focusin/focusout, which do), and neither do load or mouseenter/mouseleave. stopPropagation halts further travel; stopImmediatePropagation additionally stops other listeners on the same element; preventDefault cancels the default action (navigation, form submit) without stopping propagation, and the three are frequently confused. Event delegation exploits bubbling: instead of attaching a listener to each of 500 list rows, attach ONE listener to the container and use event.target.closest('[data-action]') to identify which row or button was really clicked.
Benefits: one listener instead of hundreds (less memory, faster page setup), and rows added later by rendering are handled automatically with no rebinding, which is exactly how frameworks and older libraries like jQuery's .on(selector) worked. The distinction between event.target (the innermost element actually clicked) and event.currentTarget (the element the listener is attached to) is essential here, and closest() handles clicks landing on an icon inside the button. Also mention modern listener options: { once: true } auto-removes after the first call, and { passive: true } on touchstart/wheel promises you will not call preventDefault, letting the browser start scrolling without waiting for your handler, a real scroll-performance win.
const list = document.querySelector('#orders');
// ONE listener for any number of rows, including future ones
list.addEventListener('click', (event) => {
const btn = event.target.closest('[data-action]');
if (!btn || !list.contains(btn)) return;
const row = btn.closest('li');
if (btn.dataset.action === 'delete') {
row.remove();
} else if (btn.dataset.action === 'expand') {
row.classList.toggle('open');
}
});
// Listener options that matter in production:
window.addEventListener('scroll', onScroll, { passive: true });
banner.addEventListener('click', dismiss, { once: true });
// Capture-phase logging sees the event before any target handler:
document.addEventListener('click', audit, { capture: true });
Key Points
- Phases: capture down, target, bubble up; listeners default to bubble
- target vs currentTarget; closest() resolves clicks on inner icons
- Delegation: one container listener covers current and future children
- { passive: true } unblocks scrolling; { once: true } self-removes
Q20localStorage, sessionStorage, cookies, IndexedDB: pick the right one and name the limits.
BasicBrowser APIs
Answer
localStorage stores strings per origin (about 5-10 MB depending on the browser), persists until cleared, and is synchronous, meaning large reads/writes block the main thread. sessionStorage has the same API but is scoped per tab and dies with it; a duplicated tab gets a copy. Both fire the storage event on OTHER tabs of the same origin when a value changes, a cheap cross-tab sync mechanism (though BroadcastChannel is the cleaner modern tool). Both store only strings, so you JSON.stringify on write and parse on read, inheriting the JSON pitfalls (Dates become strings).
Cookies are the only storage automatically sent to the server on every request, which is their point and their cost: keep them tiny (4 KB limit per cookie). Auth session cookies should be HttpOnly (invisible to document.cookie, immune to XSS exfiltration), Secure, and SameSite=Lax or Strict for CSRF resistance, and interviewers absolutely expect you to say that tokens in localStorage are readable by any XSS payload, which is why HttpOnly cookies are the safer session mechanism. IndexedDB is the asynchronous, transactional database for structured data: object stores, indexes, key ranges, storing Blobs and Files, gigabyte-scale quotas, and availability inside Web Workers and Service Workers (where localStorage does not exist), making it the backbone of offline-first apps; its event-based API is awkward, so the idb wrapper library is standard. Also name storage eviction: browsers can clear data under pressure unless you request persistence via navigator.storage.persist(), and Safari has historically capped and expired script-writable storage in ITP, so never treat client storage as durable.
// localStorage: sync, strings only
localStorage.setItem('prefs', JSON.stringify({ theme: 'dark' }));
const prefs = JSON.parse(localStorage.getItem('prefs') ?? '{}');
// Cross-tab: storage event fires on OTHER tabs only
window.addEventListener('storage', (e) => {
if (e.key === 'prefs') applyTheme(JSON.parse(e.newValue));
});
// IndexedDB via the idb wrapper: async, structured, worker-safe
import { openDB } from 'idb';
const db = await openDB('app', 1, {
upgrade(db) {
db.createObjectStore('drafts', { keyPath: 'id' });
},
});
await db.put('drafts', { id: 'jd-42', body: '...', savedAt: new Date() });
const draft = await db.get('drafts', 'jd-42');
Key Points
- localStorage is synchronous and blocks; keep values small
- Auth belongs in HttpOnly+Secure+SameSite cookies, not localStorage
- IndexedDB: async, transactional, Blob-capable, works in workers
- Browsers may evict storage; navigator.storage.persist() requests durability
Q21null vs undefined: how does each arise, and how should APIs you design use them?
BasicTypes & Coercion
Answer
undefined is the language's own 'no value yet': unassigned variables, missing object properties, out-of-range array indexes, functions that return nothing, and parameters that were not passed all evaluate to undefined. null is an assigned value meaning 'intentionally empty', it only appears where some code (or a platform API) explicitly put it. The runtime differences: typeof undefined is 'undefined' while typeof null is 'object' (the famous bug); null == undefined is true but null === undefined is false; Number(null) is 0 while Number(undefined) is NaN, which occasionally produces bewildering arithmetic; and default parameter values plus destructuring defaults trigger on undefined only, so passing null suppresses the default, a genuinely common bug when a nullable API field flows into a function with defaults. JSON keeps null but drops undefined properties entirely, so round-tripping converts 'field explicitly cleared' and 'field never set' into different shapes, something PATCH endpoint designers must decide deliberately: many APIs treat null as 'clear this field' and absent as 'leave unchanged'.
Platform precedent is inconsistent but instructive: DOM APIs return null (getElementById, closest, match), while language constructs produce undefined (Map.prototype.get on a missing key returns undefined). Reasonable team conventions: functions that look something up and miss should return undefined (or use option-style objects); use null when you need an explicit 'cleared' marker that survives JSON; and check for both at boundaries with value == null or value ?? fallback rather than distinguishing them unless the distinction is meaningful.
let notAssigned;
console.log(notAssigned); // undefined
console.log({}.missing); // undefined
console.log(document.querySelector('#nope')); // null (platform choice)
console.log(Number(null)); // 0
console.log(Number(undefined)); // NaN
// Defaults fire on undefined ONLY:
function page(size = 20) { return size; }
console.log(page(undefined)); // 20
console.log(page(null)); // null <- default suppressed!
// JSON: null survives, undefined vanishes
console.log(JSON.stringify({ a: null, b: undefined })); // {"a":null}
// Boundary check covering both:
const input = null;
const value = input ?? 'fallback';
console.log(value); // 'fallback'
Key Points
- undefined = never set; null = deliberately empty
- Defaults and destructuring defaults ignore null
- JSON.stringify keeps null, drops undefined: PATCH semantics live here
- ?? and == null treat them uniformly when you do not care which
Q22What makes an object iterable, and how do Symbol.iterator, for...of, and spread connect?
BasicIterators & Symbols
Answer
An object is iterable when it has a method under the well-known symbol key Symbol.iterator that returns an iterator: an object with a next() method returning { value, done } pairs. Every language construct that 'walks a sequence' consumes this protocol: for...of, spread [...x], array destructuring, Array.from, Promise.all's input, new Map(entries), new Set(list), and yield* all call obj[Symbol.iterator]() under the hood. Arrays, strings (by code points, so emoji survive where charAt splits them), Maps, Sets, NodeLists, and function arguments objects are iterable; plain objects are NOT, which is why [...{ a: 1 }] throws 'object is not iterable' and why Object.keys/values/entries exist as bridges. for...of iterates values of an iterable; for...in iterates enumerable string keys including inherited ones, which makes for...in on arrays a classic bug (it yields index strings plus any added properties, in no guaranteed order).
Implementing the protocol by hand is a standard exercise: give any object a [Symbol.iterator]() and it instantly works with the entire language surface, no library registration needed, that decoupling is the elegance interviewers want you to articulate. Generators are the shortcut: a function* returns an object that is both iterator and iterable, so the range example below is three lines. Also useful to name: iterators can implement return() to run cleanup when a for...of loop breaks early, which is how generator finally blocks fire on early exit, and strings being iterable by code point is the correct way to count emoji-containing text lengths.
// Any object can opt into iteration:
const range = {
from: 1, to: 5,
*[Symbol.iterator]() {
for (let n = this.from; n <= this.to; n++) yield n;
},
};
console.log([...range]); // [1, 2, 3, 4, 5]
console.log(Math.max(...range)); // 5
for (const n of range) if (n === 3) break; // return() cleanup hook fires
// Strings iterate by code point, not UTF-16 unit:
console.log('hi🚀'.length); // 4 (UTF-16 units)
console.log([...'hi🚀'].length); // 3 (code points)
// for...in vs for...of on arrays:
const arr = [10, 20];
arr.extra = 'oops';
for (const k in arr) console.log(k); // '0', '1', 'extra'
for (const v of arr) console.log(v); // 10, 20
Key Points
- Symbol.iterator returning { next() } powers for...of, spread, Array.from
- Plain objects are not iterable; use Object.entries as the bridge
- for...in walks keys (including inherited); never use it on arrays
- Generators implement the whole protocol in one function*
Q23How do async, defer, and type=module change script loading, and where should scripts live in the HTML?
BasicBrowser APIs
Answer
A bare <script src> blocks HTML parsing: the parser stops, fetches, executes, then resumes, which is why legacy advice said 'scripts at the bottom of body'. Modern attributes remove the need. defer downloads in parallel and executes after parsing completes, just before DOMContentLoaded, preserving the order of multiple deferred scripts, so dependencies stay correct. async downloads in parallel and executes the moment it arrives, pausing the parser at an unpredictable point and with NO ordering guarantees between async scripts, so it suits independent scripts only (analytics, ads); two async scripts where one depends on the other is a race condition that works on fast connections and fails on slow ones, a genuinely nasty production bug class. <script type="module"> is deferred by default (no attribute needed), executes once no matter how many times it is included, runs in strict mode with module scope (no accidental globals), and is fetched with CORS. Adding async to a module executes it as soon as it and its whole import graph are ready.
Because deferred/module scripts run after parsing, DOM elements below them are already available, eliminating the 'null element' errors that pushed scripts to the bottom historically. Complementary hints: <link rel="modulepreload"> warms module dependencies, and rel="preload" as="script" fetches early without executing. Also know nomodule, the legacy escape hatch older browsers used to load fallback bundles, mostly gone from 2026 builds since evergreen browsers all speak ESM natively, letting tools like Vite ship unbundled-module dev servers and module-first production output.
<!-- Blocks parsing: avoid -->
<script src="/legacy.js"></script>
<!-- Parallel fetch, ordered execution after parsing: the default choice -->
<script defer src="/vendor.js"></script>
<script defer src="/app.js"></script> <!-- runs after vendor.js, always -->
<!-- Fire-when-ready, unordered: independent scripts only -->
<script async src="https://analytics.example.com/tag.js"></script>
<!-- Modules: deferred by default, strict, scoped, deduplicated -->
<script type="module">
import { mount } from '/src/main.js';
mount(document.querySelector('#root')); // DOM is ready here
</script>
<link rel="modulepreload" href="/src/main.js" />
Key Points
- defer: parallel fetch, ordered, pre-DOMContentLoaded execution
- async: unordered, executes on arrival; only for independent scripts
- type=module implies defer, strict mode, own scope, single execution
- modulepreload warms the import graph before execution
Q24Two identical objects are not ===. How do you compare, dedupe, and key objects correctly?
BasicObjects & References
Answer
Primitives compare by value; objects compare by reference, so {} === {} is false and two structurally identical objects are never equal under ===, ==, Object.is, Set membership, Map keys, indexOf, or includes, all of which use reference (SameValueZero) semantics for objects. This single fact explains a family of bugs: a Set fails to dedupe fetched objects, arr.includes(obj) misses a structurally equal object, and a React dependency array [options] retriggers an effect every render because the parent recreates options each time (the fix being useMemo or primitive dependencies). For actual structural comparison your options are: hand-rolled recursive comparison, lodash isEqual (handles cycles, Dates, Maps), or Node's assert.deepStrictEqual in tests; a JSON.stringify comparison is a fragile shortcut because key order matters ({ a: 1, b: 2 } vs { b: 2, a: 1 } stringify differently) and JSON drops undefined and functions.
Also know that recent runtimes shipped a structural-equality-adjacent tool for tests only, but application code still needs an explicit strategy. For keying collections by content, derive a primitive key: map.set(`${row.userId}:${row.date}`, row) or use the entity's id. When you genuinely want object-identity keys with garbage-collection friendliness, WeakMap holds metadata keyed by the object itself without preventing collection. In interviews, walk through the Set-dedupe failure and fix it with a Map keyed by id: const unique = [...new Map(list.map(x => [x.id, x])).values()] is a one-liner worth having memorized.
console.log({ a: 1 } === { a: 1 }); // false (references)
console.log([1, 2].includes([1, 2])); // ...with nested arrays: false
const a = { id: 7 };
const b = { id: 7 };
console.log(new Set([a, b]).size); // 2, no structural dedupe
// Dedupe objects by content-derived key:
const rows = [{ id: 1, v: 'x' }, { id: 2, v: 'y' }, { id: 1, v: 'x2' }];
const unique = [...new Map(rows.map(r => [r.id, r])).values()];
console.log(unique.length); // 2 (last write wins)
// Structural equality in tests:
// assert.deepStrictEqual({ a: 1 }, { a: 1 }); // passes in Node
// Fragile shortcut, key order breaks it:
console.log(JSON.stringify({ a: 1, b: 2 }) === JSON.stringify({ b: 2, a: 1 })); // false
Key Points
- ===, Set, Map keys, includes: all reference-based for objects
- Structural checks: lodash isEqual or assert.deepStrictEqual in tests
- JSON.stringify comparison breaks on key order and dropped values
- Dedupe via Map keyed by id; WeakMap for GC-safe object metadata
Q25Untangle prototype vs __proto__ vs Object.getPrototypeOf, and trace a property lookup through the chain.
IntermediatePrototypes
Answer
Every object has an internal [[Prototype]] link, readable via Object.getPrototypeOf(obj) (the legacy accessor __proto__ exposes the same link but is deprecated for writing and slow to mutate). Separately, FUNCTIONS have a .prototype property: the object that will become the [[Prototype]] of instances created by calling that function with new. The two are routinely confused and distinguishing them crisply is the core of this question: dog.__proto__ === Dog.prototype after new Dog().
Property reads walk the chain: the engine checks own properties, then [[Prototype]], then its [[Prototype]], until null, which is why every object 'has' toString (from Object.prototype) without owning it. Writes do NOT walk the chain the same way: assigning obj.x creates an own property that shadows an inherited one (with exceptions for inherited setters and non-writable inherited properties, which in strict mode throw). Object.create(proto) builds an object with an explicit prototype, the cleanest way to set up delegation, and Object.create(null) makes a prototype-free dictionary immune to prototype pollution and the classic 'hasOwnProperty is not a function' trap, since it inherits nothing at all.
Related APIs worth naming: Object.hasOwn(obj, key) (ES2022) replaces obj.hasOwnProperty(key) safely, for...in walks enumerable inherited properties while Object.keys stays own-only, and instanceof checks whether a constructor's .prototype appears anywhere in the object's chain, customizable via Symbol.hasInstance. Performance note that earns points: mutating an existing object's prototype with Object.setPrototypeOf deoptimizes V8's inline caches badly; establish prototypes at creation time instead.
function Dog(name) { this.name = name; }
Dog.prototype.speak = function () { return `${this.name} says woof`; };
const rex = new Dog('Rex');
console.log(Object.getPrototypeOf(rex) === Dog.prototype); // true
console.log(rex.speak()); // found one hop up the chain
console.log(Object.hasOwn(rex, 'speak')); // false (inherited)
console.log(Object.hasOwn(rex, 'name')); // true (own)
// Delegation without constructors:
const base = { greet() { return 'hi from base'; } };
const child = Object.create(base);
console.log(child.greet()); // 'hi from base'
// Null-prototype dictionary: no inherited anything
const dict = Object.create(null);
dict['__proto__'] = 'just data'; // harmless here, no pollution
console.log('toString' in dict); // false
Key Points
- fn.prototype (future instances) vs obj's [[Prototype]] (its parent)
- Reads walk the chain; writes create shadowing own properties
- Object.hasOwn (ES2022) is the safe own-property check
- Object.create(null) dictionaries dodge pollution; avoid setPrototypeOf on hot objects
Q26What do class private fields (#), static blocks, and field initializers actually compile down to, and where do #fields bite?
IntermediateClasses
Answer
class syntax layers ergonomics over prototypes: methods land on ClassName.prototype, static members on the constructor itself, extends wires two prototype chains (one for instances, one for statics), and super calls the parent. But several class features are NOT sugar. Private fields (#balance) are enforced by the engine: they live in a per-instance internal slot keyed by the class's private name, are invisible to Object.keys, JSON.stringify, Reflect.ownKeys, and Proxy traps, and accessing a missing one throws TypeError: Cannot read private member.
Practical bite points: structuredClone drops them, a Proxy wrapped around an instance breaks methods touching #fields (the slot lives on the target, not the proxy), and the in operator gained a special form (#x in obj) precisely so classes can brand-check instances safely. Field initializers run per-instance in definition order BEFORE the constructor body but after super(), and arrow-function fields capture this per instance, the standard fix for callback binding at the cost of one function object per instance instead of a shared prototype method. Static blocks (ES2022) run once at class evaluation for multi-step static setup.
Also know: class bodies are strict mode, classes must be called with new (TypeError otherwise, unlike constructor functions), derived constructors must call super() before touching this (ReferenceError otherwise), and class declarations are TDZ-hoisted. When an interviewer asks 'when would you avoid #private', the honest answers are serialization needs, Proxy-based frameworks (older MobX had exactly this issue), and test seams where TypeScript's soft private keyword is more pragmatic.
class Account {
#balance = 0; // engine-enforced privacy
static #registry = new Map();
static { // static init block (ES2022)
Account.#registry.set('root', null);
}
constructor(owner) {
this.owner = owner;
Account.#registry.set(owner, this);
}
deposit(amt) { this.#balance += amt; return this; }
static isAccount(obj) {
return #balance in obj; // brand check, never throws
}
}
const acc = new Account('asha').deposit(500);
console.log(JSON.stringify(acc)); // {"owner":"asha"} #balance invisible
console.log(Account.isAccount(acc)); // true
console.log(Account.isAccount({})); // false
try { acc.deposit.call({}, 1); } // foreign object lacks the slot
catch (e) { console.log(e.constructor.name); } // TypeError
Key Points
- #fields are engine slots: invisible to reflection, JSON, and Proxies
- #x in obj is the safe brand check (ES2022)
- Field initializers run after super(), before constructor body
- Arrow fields fix this-binding per instance; prototype methods are shared
Q27Promise.all vs allSettled vs race vs any: failure semantics, and which one fits each production scenario?
IntermediatePromises
Answer
All four take an iterable of promises; the difference is entirely about failure and settlement policy. Promise.all fulfills with an ordered array of results when ALL fulfill, and rejects immediately on the FIRST rejection; crucially, the other operations keep running (nothing cancels them, JavaScript has no built-in cancellation; pair with AbortController if abandonment matters). Use it when every result is required: loading user + permissions + config before rendering a dashboard.
Promise.allSettled never rejects; it waits for everything and yields an array of { status: 'fulfilled', value } or { status: 'rejected', reason } objects, ideal for batch jobs where partial success is normal: sending 100 notification emails and reporting which failed. Promise.race settles with the first promise to settle EITHER way, so a rejection can win the race; its canonical uses are timeouts (racing work against a rejecting timer) and 'first response wins' patterns, though AbortSignal.timeout(ms) passed to fetch is the cleaner modern timeout since it actually aborts the network request rather than abandoning it. Promise.any fulfills with the first FULFILLMENT, ignoring rejections unless every input rejects, in which case it rejects with an AggregateError whose .errors array holds all reasons, perfect for querying mirror endpoints or racing cache vs network where you want the first success.
Ordering subtlety interviewers check: Promise.all preserves input order in its result array regardless of completion order. Empty-input edge cases: all and allSettled fulfill immediately with []; race stays pending forever; any rejects with an AggregateError, small facts that reliably distinguish candidates who have read the spec from those who have not.
// All required: fail fast
const [user, perms] = await Promise.all([getUser(id), getPerms(id)]);
// Batch with partial failure reporting
const results = await Promise.allSettled(emails.map(sendMail));
const failed = results
.map((r, i) => ({ r, to: emails[i] }))
.filter(x => x.r.status === 'rejected');
console.log(`sent ${results.length - failed.length}, failed ${failed.length}`);
// First success wins; total failure = AggregateError
try {
const fastest = await Promise.any([
fetch('https://cdn1.example.com/cfg'),
fetch('https://cdn2.example.com/cfg'),
]);
} catch (e) {
console.log(e instanceof AggregateError, e.errors.length);
}
// Modern timeout: actually aborts the request
const res = await fetch('/api/report', { signal: AbortSignal.timeout(5000) });
Key Points
- all: first rejection wins; others continue running (no auto-cancel)
- allSettled: never rejects; { status, value | reason } per input
- any: first fulfillment; AggregateError.errors on total failure
- race with empty iterable hangs forever; prefer AbortSignal.timeout for fetch
Q28Implement debounce and throttle from scratch, and justify which one a search box, a scroll handler, and a resize handler each need.
IntermediateHands-on Coding
Answer
This is the single most common hands-on exercise in Indian frontend interviews, and the follow-ups matter more than the happy path. Debounce delays invocation until the calls STOP for a quiet period: every call resets the timer, so only the final call in a burst executes. Throttle guarantees at most one invocation per interval while calls continue.
Mapping to use cases: a search-as-you-type box wants debounce (fire the API once typing pauses, typically 250-400 ms, saving both server load and out-of-order response bugs), infinite-scroll position checks want throttle (react continuously but boundedly while scrolling never pauses), and a resize handler recomputing layout wants debounce (only the final size matters). Implementation details that get probed: preserve this and arguments by using a regular function plus fn.apply(context, args) so the utility works on methods and event handlers (an arrow wrapper loses the caller's this); clearTimeout before re-setting; provide cancel() for unmount cleanup (React useEffect teardown calling debounced.cancel prevents setState-after-unmount) and flush() to force pending execution. For throttle, the trailing-call question is the differentiator: a naive timestamp throttle drops the final burst call, so the scroll position 'sticks' slightly stale; production throttles (lodash's) support leading and trailing edges.
Also expect 'why not just use lodash?': the answer is you would in production (its implementations handle edge timing correctly), but the exercise proves you understand closures, timers, and this. A last trap: debouncing an async function means callers cannot await its result naturally; returning a promise that resolves on the eventual invocation is the senior-level extension.
function debounce(fn, wait) {
let timer = null;
function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args); // regular function: caller's this preserved
}, wait);
}
debounced.cancel = () => { clearTimeout(timer); timer = null; };
return debounced;
}
function throttle(fn, interval) {
let last = 0, trailing = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - last);
if (remaining <= 0) {
last = now;
fn.apply(this, args);
} else if (!trailing) { // capture the final burst call
trailing = setTimeout(() => {
last = Date.now();
trailing = null;
fn.apply(this, args);
}, remaining);
}
};
}
searchInput.addEventListener('input', debounce(runSearch, 300));
window.addEventListener('scroll', throttle(updateProgress, 100));
Key Points
- Debounce: fires after calls stop; throttle: bounded rate during calls
- Use fn.apply(this, args) so methods and handlers keep their receiver
- cancel() in unmount cleanup prevents setState-after-unmount
- Trailing-edge handling is what separates naive from production throttle
Q29Beyond toy examples, what are generators actually good for, and how do next/return/throw talk to a running generator?
IntermediateIterators & Symbols
Answer
A function* returns a generator object without running any body code. Each next() call runs until the next yield, returning { value, done }; the generator is both iterator and iterable, so for...of, spread, and destructuring consume it directly. Communication is two-way: next(v) makes the CURRENTLY SUSPENDED yield expression evaluate to v (the first next()'s argument is discarded, a detail interviewers love), gen.return(v) triggers finally blocks and finishes the generator, and gen.throw(err) injects an exception at the suspension point, catchable by a try around the yield.
Because a generator's local state persists between calls without any external variable, real use cases follow: lazy and infinite sequences (an ID generator, paginated cursors that fetch on demand), backpressure-aware pipelines where the consumer controls the pace, implementing Symbol.iterator in one line for custom collections, and coroutine-style control flow, which is the historically important one: libraries like redux-saga and co drove generators with a runner that treated yielded promises as awaits, which is literally how async/await was modeled before it landed natively, and redux-saga still appears in legacy React codebases at Indian enterprises. The cleanup semantics matter in production: breaking out of a for...of over a generator calls its return(), firing finally blocks, which is where you close file handles or release connections; forgetting this is a resource leak. Async generators (async function*, consumed by for await...of) extend the same model to asynchronous streams and are the idiomatic way to wrap paginated APIs. Delegation with yield* flattens nested generators and forwards next/throw/return through, which composed parsers and tree traversals exploit.
function* pages(fetchPage) {
let cursor = null;
try {
do {
const { items, next } = yield fetchPage(cursor); // consumer sends result back
cursor = next;
} while (cursor);
} finally {
console.log('cleanup: cursor released'); // runs on early break too
}
}
// Two-way channel:
function* calc() {
const x = yield 'need x'; // next(10) makes this yield evaluate to 10
const y = yield 'need y';
return x + y;
}
const g = calc();
console.log(g.next().value); // 'need x' (first next: argument ignored)
console.log(g.next(10).value); // 'need y'
console.log(g.next(32)); // { value: 42, done: true }
// Infinite lazy sequence:
function* ids() { let n = 0; while (true) yield ++n; }
const gen = ids();
console.log(gen.next().value, gen.next().value); // 1 2
Key Points
- next(v) resumes the paused yield with v; first next's arg is discarded
- return()/early break fire finally blocks: the resource-cleanup hook
- Real uses: lazy pagination, custom iterables, redux-saga-style coroutines
- async function* + for await...of is the streaming-pagination idiom
Q30call, apply, bind, and partial application: how would you polyfill bind, and what breaks with new?
Intermediatethis & Binding
Answer
call invokes immediately with an explicit this and arguments listed individually; apply is identical but takes arguments as an array (mnemonic: a for array); bind invokes nothing, returning a new function with this permanently fixed and any provided arguments pre-filled, which makes bind JavaScript's built-in partial application: const log = console.log.bind(console, '[api]') prefixes every call. Since spread syntax, apply's main historic use (Math.max.apply(null, arr)) became Math.max(...arr), so apply survives mostly in reflective code and polyfills. The interview centerpiece is polyfilling bind, because it forces three pieces of understanding.
First, this preservation: the returned function must call the original with the bound receiver via a symbol-keyed temporary property or apply. Second, argument merging: bind-time args come first, call-time args append. Third, the new edge case almost everyone misses: the spec says calling a bound function with new IGNORES the bound this and constructs a real instance of the original function, so a correct polyfill checks whether it was invoked as a constructor (this instanceof boundFn) and, if so, delegates with the caller's new target semantics.
Also worth stating: bind is permanent (double-binding does not rebind; the first bind wins), bound functions have a name of 'bound originalName' and adjusted length, and each bind allocates a new function, which is why passing inline .bind() or arrows as React props defeats React.memo equality checks, the practical reason useCallback exists. Currying (unary chains: f(a)(b)(c)) differs from partial application (fixing some args); a generic curry that collects args until fn.length is satisfied is a common follow-up exercise.
Function.prototype.myBind = function (ctx, ...preset) {
const original = this;
function bound(...args) {
// 'new bound()' must ignore ctx and construct original:
if (this instanceof bound) {
return new original(...preset, ...args);
}
return original.apply(ctx, [...preset, ...args]);
}
bound.prototype = Object.create(original.prototype); // instanceof works
return bound;
};
function Point(x, y) { this.x = x; this.y = y; }
const OriginPoint = Point.myBind({ ignored: true }, 0);
const p = new OriginPoint(5);
console.log(p.x, p.y, p instanceof Point); // 0 5 true
// Generic curry until arity satisfied:
const curry = (fn) => function collect(...args) {
return args.length >= fn.length
? fn(...args)
: (...more) => collect(...args, ...more);
};
const add3 = curry((a, b, c) => a + b + c);
console.log(add3(1)(2)(3), add3(1, 2)(3)); // 6 6
Key Points
- call/apply invoke now; bind returns a partially applied function
- new on a bound function ignores the bound this (spec-mandated)
- Re-binding a bound function does nothing; first bind wins
- Fresh functions from bind/arrows break React.memo; hence useCallback
Q31When do Map and Set beat plain objects and arrays, and what exactly do WeakMap and WeakSet solve?
IntermediateCollections
Answer
Map keys can be ANY value including objects, functions, and NaN (SameValueZero equality, so NaN matches itself), while object keys are coerced to strings or symbols, which is why obj[{a:1}] collapses every object key into '[object Object]'. Map preserves insertion order reliably (objects mostly do too, but integer-like keys get reordered first, a classic display bug when using object keys like '10', '2'), exposes size directly versus Object.keys(obj).length, iterates natively with for...of over entries, and benchmarks better under heavy add/delete churn because engines optimize objects for stable shapes, not dictionary behavior. Objects still win for fixed, known shapes (records), JSON round trips, and destructuring ergonomics.
Set gives O(1) membership versus Array includes' O(n), making array dedupe [...new Set(arr)] and visited-node tracking idiomatic, and ES2025 added the long-missing algebra: union, intersection, difference, symmetricDifference, isSubsetOf, isSupersetOf, isDisjointFrom as native Set methods. The Weak variants answer a different question: how do I associate data WITH an object without keeping that object alive? WeakMap keys and WeakSet members must be objects and are held weakly, so when the key becomes otherwise unreachable, the entry disappears and its value can be collected.
That makes WeakMap the correct store for DOM-node metadata, per-object caches, and private state keyed by instances, where a normal Map would leak every object ever seen. The cost of that GC integration: no size, no iteration, no clear; entries are observable only via get/has with the key in hand, deliberately, because iteration would expose GC timing. WeakSet's typical use is brand-marking ('have I processed this object?') without ownership.
// Object keys collapse; Map keys do not
const m = new Map();
const k1 = { id: 1 }, k2 = { id: 2 };
m.set(k1, 'first').set(k2, 'second');
console.log(m.get(k1), m.size); // 'first' 2
const o = {};
o[k1] = 'first'; o[k2] = 'second';
console.log(Object.keys(o)); // ['[object Object]'] - one key!
// Set algebra (ES2025)
const applied = new Set(['a1', 'a2', 'a3']);
const shortlisted = new Set(['a2', 'a3', 'a9']);
console.log([...applied.intersection(shortlisted)]); // ['a2', 'a3']
console.log([...applied.difference(shortlisted)]); // ['a1']
// WeakMap: metadata that never blocks GC
const meta = new WeakMap();
function track(el) { meta.set(el, { renderedAt: Date.now() }); }
// when el's DOM node is removed and dereferenced, the entry vanishes
Key Points
- Map: any-type keys, SameValueZero, stable order, .size, churn-friendly
- Object integer-like keys iterate first: a real ordering bug source
- ES2025 Set methods: union/intersection/difference and subset checks
- WeakMap = GC-safe object metadata; no iteration by design
Q32Name the memory-leak patterns specific to JavaScript apps and walk through finding one with Chrome DevTools heap snapshots.
IntermediateMemory
Answer
The recurring leak patterns: (1) Forgotten timers: setInterval callbacks hold their closure forever until clearInterval, so a component that starts polling and unmounts without cleanup leaks its entire captured scope every mount cycle. (2) Event listeners on long-lived targets: window.addEventListener('resize', handler) from a short-lived view keeps the view reachable; remove listeners in teardown, or use { signal } with an AbortController so one abort() detaches every listener registered with it, the modern bulk-cleanup idiom. (3) Detached DOM nodes: removing an element from the document while a JavaScript variable, closure, or Map still references it (or one of its children!) keeps the whole subtree in memory; DevTools labels these 'Detached HTMLDivElement'. (4) Unbounded caches: a module-level Map used as a memo grows forever without an eviction policy; use an LRU or WeakMap when keys are objects. (5) Closures captured by long-lived subscriptions (RxJS, socket.io handlers) holding large parent scopes. (6) Accidental globals in sloppy scripts. The DevTools workflow interviewers want narrated: open Memory panel, take a heap snapshot, perform the suspected action several times (mount/unmount a route five times), take a second snapshot, and use the Comparison view sorted by size delta; growing counts of your component class name or Detached nodes point at the culprit, and the Retainers pane shows the exact reference path from GC roots, read it bottom-up to find who is holding on. The Allocation instrumentation timeline shows allocations that survive between snapshots as blue bars. In Node, take snapshots with the built-in inspector or v8.writeHeapSnapshot() and diff them the same way; a steadily climbing RSS in production dashboards with sawtooth GC is the alerting signature that starts this investigation.
// Leak: interval + listener + captured scope survive unmount
function mountTicker(el) {
const bigBuffer = new Array(1e6).fill(0); // captured below
const id = setInterval(() => render(el, bigBuffer), 1000);
window.addEventListener('resize', () => render(el, bigBuffer));
// el removed from DOM later, but interval + listener keep everything alive
}
// Fixed: one AbortController tears everything down
function mountTickerFixed(el) {
const ac = new AbortController();
const id = setInterval(() => render(el), 1000);
window.addEventListener('resize', () => render(el), { signal: ac.signal });
return function unmount() {
clearInterval(id);
ac.abort(); // detaches every { signal }-registered listener
el.remove();
};
}
Key Points
- Top culprits: intervals, window listeners, detached DOM, unbounded Maps
- AbortController { signal } gives one-call listener cleanup
- Heap snapshot comparison + Retainers path names the exact holder
- 'Detached' prefix in snapshots = DOM kept alive by JS references
Q33How does garbage collection work in V8, and what does 'reachability' mean for the code you write?
IntermediateMemory
Answer
JavaScript memory management is automatic but not magic: the collector frees objects that are no longer REACHABLE from the root set (globalThis, the currently executing stack, active closures' scopes, and internal handles). Reference counting is not the model, so circular references between two otherwise-unreachable objects are collected fine; what leaks memory is reachability you forgot about, like a module-level Map or a live event listener. V8 specifically runs a generational collector: new objects are born in a small 'new space' collected frequently by the Scavenger (a copying collector; most objects die young, so this is cheap), and survivors of a couple of scavenges get promoted to 'old space', collected by the major Mark-Sweep-Compact collector.
The marking phase walks from roots and flags live objects; sweep reclaims the rest; compact defragments. Because a naive mark phase would freeze the application, V8 does most marking concurrently and incrementally (the Orinoco project) with brief stop-the-world pauses, and interviewers like hearing that GC pauses still exist and show up as dropped frames or p99 latency spikes in Node services. What this means for your code: short-lived allocation is cheap, so do not contort code to avoid temporary objects; long-lived references are the expensive thing to audit.
Avoid keeping large object graphs reachable from long-lived scopes, null out references in caches you own, prefer WeakMap for object-keyed metadata, and in Node watch heap health via process.memoryUsage(), --max-old-space-size for the heap ceiling (containers commonly set it explicitly since default sizing predates cgroup awareness), and PerformanceObserver gc entries or heap snapshots for diagnosis. Delete operator trivia: delete obj.key removes a property (and deoptimizes the object's hidden class); it does not 'free memory' directly.
// Cycles are NOT leaks: reachability is what matters
function makeCycle() {
const a = {}, b = {};
a.peer = b; b.peer = a;
} // after return, nothing reaches a or b: both collectable
makeCycle();
// Reachability you forgot about IS the leak:
const cache = new Map(); // module-level = GC root path
function remember(user) { cache.set(user.id, user); } // grows forever
// Node: observing heap pressure
const { heapUsed, heapTotal, rss } = process.memoryUsage();
console.log(`heap ${Math.round(heapUsed / 1e6)}MB / ${Math.round(heapTotal / 1e6)}MB, rss ${Math.round(rss / 1e6)}MB`);
// node --max-old-space-size=1536 server.js (heap ceiling in MB)
Key Points
- Mark from roots; cycles collect fine; forgotten reachability leaks
- Generational: cheap Scavenger for young objects, Mark-Sweep-Compact for old
- Concurrent/incremental marking, but pauses still hit p99 latency
- Node: --max-old-space-size matters in containers; monitor memoryUsage()
Q34Implement Promise.all by hand, handling non-promise inputs, order preservation, and the empty-array case.
IntermediateHands-on Coding
Answer
A staple whiteboard exercise because a correct implementation requires four separate insights that shallow promise knowledge misses. One: results must appear in INPUT order, not completion order, so you write into results[index] captured per iteration rather than pushing. Two: completion is detected by counting settled promises, not by checking array length (sparse writes make results.length lie: assigning index 2 first gives length 3 while indexes 0 and 1 are still holes).
Three: inputs may not be promises at all; the spec resolves each element, so wrap with Promise.resolve to assimilate plain values and thenables uniformly. Four: the empty input must fulfill immediately with [], which falls out naturally only if you check count === total before the loop or guard explicitly; forgetting leaves the promise pending forever, and interviewers test exactly this. Rejection semantics: the first rejection rejects the outer promise; because a promise settles once, later reject calls are no-ops, so no explicit guard is needed, an elegant property worth saying out loud.
The spec also accepts any iterable, so iterating with for...of and a manual index is more faithful than assuming an array. Natural follow-ups: implement allSettled (never reject; record { status, value | reason } and count both paths), any (invert: count rejections, reject with AggregateError when all fail), and race (one loop, resolve and reject both wired to the first settler). Being fluent across all four conversions in one sitting is a strong senior signal.
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const results = [];
let total = 0;
let settled = 0;
for (const item of iterable) {
const index = total++; // capture per element
Promise.resolve(item).then(
(value) => {
results[index] = value; // input order, not finish order
if (++settled === total) resolve(results);
},
reject // first rejection wins; rest no-op
);
}
if (total === 0) resolve([]); // empty iterable: fulfill NOW
});
}
promiseAll([
new Promise(r => setTimeout(() => r('slow'), 50)),
'plain value', // non-promise assimilated
Promise.resolve(42),
]).then(console.log); // ['slow', 'plain value', 42]
promiseAll([]).then(r => console.log('empty ->', r)); // empty -> []
Key Points
- Write results[index]; count settlements, never trust results.length
- Promise.resolve each element: values and thenables assimilate
- Empty iterable must resolve immediately; forgetting hangs forever
- Settle-once semantics make the shared reject naturally idempotent
Q35What can Proxy do that getters cannot, how does Reflect pair with it, and what are the known Proxy blind spots?
IntermediateMetaprogramming
Answer
A Proxy wraps a target with traps that intercept fundamental operations: get, set, has (the in operator), deleteProperty, ownKeys (spread, Object.keys), apply (function calls), construct (new), getPrototypeOf, defineProperty, and more. Unlike an accessor getter, which must be defined per known property name, Proxy traps fire for EVERY property including ones that do not exist yet, enabling patterns getters cannot express: negative array indexes, case-insensitive config lookup, auto-vivifying nested objects, validation on all writes, and dependency tracking, which is the marquee production use: Vue 3's reactivity moved from Object.defineProperty (Vue 2, which could not detect added properties or index writes) to Proxy precisely to observe all mutations, and libraries like Immer and MobX are Proxy-based. Reflect mirrors every trap with a same-signature function (Reflect.get, Reflect.set, Reflect.has, ...), and the discipline is: inside a trap, delegate to the Reflect twin for default behavior instead of touching target[prop] directly; Reflect.get(target, prop, receiver) forwards the receiver so inherited getters see the proxy as this, and Reflect.set's boolean return integrates correctly with strict-mode failure semantics, details raw property access gets wrong.
The blind spots interviewers probe: class private fields (#x) bypass traps entirely, so proxying an instance breaks its methods (Vue 3's reactive() documents this limitation); some built-ins with internal slots (Map, Set, Date) throw when their methods run against a proxy this unless you bind methods back to the target in the get trap; identity splits because proxy !== target, breaking Sets and Maps holding originals; and invariant enforcement throws TypeError if a trap lies about a non-configurable property. Performance-wise, trapped operations defeat inline caches and are several times slower than plain access, so proxies belong at API boundaries (reactivity, validation layers), not in hot inner loops.
const handler = {
get(target, prop, receiver) {
if (typeof prop === 'string' && /^-\d+$/.test(prop)) {
return target[target.length + Number(prop)]; // negative indexes
}
return Reflect.get(target, prop, receiver); // default behavior
},
set(target, prop, value, receiver) {
if (prop !== 'length' && Number.isNaN(Number(prop))) {
throw new TypeError(`Arrays take numeric keys, got ${String(prop)}`);
}
return Reflect.set(target, prop, value, receiver);
},
};
const arr = new Proxy([10, 20, 30], handler);
console.log(arr[-1]); // 30
console.log(arr.at(-1)); // 30 (native ES2022 way, no proxy needed)
// Change tracking, the Vue 3 idea in miniature:
function reactive(obj, onChange) {
return new Proxy(obj, {
set(t, p, v, r) {
const ok = Reflect.set(t, p, v, r);
if (ok) onChange(p, v);
return ok;
},
});
}
const state = reactive({ count: 0 }, (p, v) => console.log('changed', p, v));
state.count = 1; // changed count 1
Key Points
- Traps cover unknown/future properties; getters cannot
- Always delegate to Reflect twins, forwarding the receiver
- Blind spots: #private fields, Map/Set internal slots, identity split
- Vue 3 moved to Proxy to catch added props and index writes
Q36How do dynamic import() and tree-shaking interact with how you write exports, and what makes a module un-shakeable?
IntermediateModules
Answer
Tree-shaking is dead-export elimination: because ESM imports/exports are static, bundlers (Rollup, esbuild, Vite's production build, webpack in production mode) construct the full module graph without executing code and drop exports nothing imports. What defeats it: CommonJS modules (require is dynamic, so bundlers must keep everything, which is why library authors ship dual builds with a module/exports field pointing at ESM); namespace re-export barrels (import * as utils, or index.ts files re-exporting fifty modules, can force retention and slow builds, which is why 'avoid barrel files' became standard advice and why Next.js added optimizePackageImports to rewrite them); and side effects: a module that runs code at load time (polyfills, CSS imports, registering globals) cannot be safely dropped, so bundlers consult the "sideEffects" field in package.json, false meaning 'pure, shake freely', or an array listing the impure files. Mark pure call results with the /* #__PURE__ */ annotation so a dropped binding also drops its initializer call.
Dynamic import() is the other lever: it returns a promise of the module namespace and becomes a SPLIT POINT, emitting a separate chunk fetched on demand. Route-level code splitting (React.lazy(() => import('./Settings')) with Suspense) is the standard pattern that keeps a dashboard's admin panel out of the login page's bundle. Notes that show production experience: import() accepts dynamic expressions but bundlers then include every possible match of the glob, so keep specifiers statically analyzable; preload likely-next chunks on hover or with modulepreload; a failed chunk load after a deploy (old HTML requesting a hashed chunk that no longer exists) throws ChunkLoadError-style failures, so wrap lazy routes in an error boundary that retries or reloads; and verify shaking claims with a bundle analyzer (rollup-plugin-visualizer, vite-bundle-visualizer) rather than trusting a library's marketing.
// package.json of a shakeable library:
// { "exports": { ".": { "import": "./dist/index.mjs" } }, "sideEffects": false }
// GOOD: named imports let the bundler keep only debounce
import { debounce } from 'es-toolkit';
// RISKY: namespace/barrel usage can retain far more
import * as _ from 'es-toolkit';
// Pure annotation: dropped binding drops the call too
const logger = /* #__PURE__ */ createLogger({ level: 'debug' });
// Route-level splitting: Settings ships as its own chunk
const Settings = React.lazy(() => import('./routes/Settings'));
// Conditional heavy dependency, loaded only when needed:
async function exportPdf(report) {
const { jsPDF } = await import('jspdf'); // separate chunk, cached after
return new jsPDF().text(report.title, 10, 10).save('report.pdf');
}
Key Points
- Static ESM + "sideEffects": false is what makes shaking safe
- CJS deps and barrel files are the usual shaking killers
- import() = chunk split point; keep specifiers analyzable
- Handle post-deploy stale-chunk load failures in an error boundary
Q37fetch does not reject on a 404. Build a production-grade wrapper: status handling, timeout, abort, and retry-safe JSON parsing.
IntermediateNetworking
Answer
The number one fetch misconception: the returned promise rejects ONLY on network-level failure (DNS, offline, CORS block, abort). A 404 or 500 fulfills normally with response.ok === false, so forgetting the ok check silently treats error pages as data until response.json() explodes on HTML with SyntaxError: Unexpected token '<'. A serious wrapper therefore: checks response.ok and throws a typed HttpError carrying status and a best-effort parsed body (APIs put error details in the body; losing them makes on-call debugging miserable); reads the body exactly ONCE (body is a stream; calling .json() after .text() throws TypeError: body stream already read; use response.clone() if you genuinely need two reads); and supports cancellation via AbortController, whose signal aborts the actual network request, rejecting with a DOMException named AbortError that you usually want to swallow rather than report.
Timeouts compose from the same primitive: AbortSignal.timeout(ms) creates a self-aborting signal, and AbortSignal.any([userSignal, AbortSignal.timeout(8000)]) (ES2024-era platform addition) merges user cancellation with a deadline, the modern replacement for Promise.race timeout hacks that abandoned but never stopped the request. Other production notes: fetch does not send cookies cross-origin unless credentials: 'include'; POST JSON needs an explicit Content-Type: application/json header; keepalive: true lets small requests outlive page unload (analytics beacons, where navigator.sendBeacon is the alternative); and in React, aborting the in-flight request in the useEffect cleanup both saves bandwidth and prevents the race where a stale response overwrites newer state, the AbortError catch making that path silent and clean.
class HttpError extends Error {
constructor(response, body) {
super(`HTTP ${response.status} ${response.url}`);
this.name = 'HttpError';
this.status = response.status;
this.body = body;
}
}
async function api(path, { signal, ...options } = {}) {
const response = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(8000)])
: AbortSignal.timeout(8000),
...options,
});
const text = await response.text(); // read body ONCE
const data = text ? JSON.parse(text) : null;
if (!response.ok) throw new HttpError(response, data);
return data;
}
// Cancellable search from a component:
const ac = new AbortController();
api(`/api/search?q=${encodeURIComponent(q)}`, { signal: ac.signal })
.then(render)
.catch(err => {
if (err.name !== 'AbortError') report(err); // aborts are not failures
});
// cleanup: ac.abort();
Key Points
- fetch fulfills on 4xx/5xx; always branch on response.ok
- Body is a one-read stream; clone() before double reads
- AbortSignal.timeout + AbortSignal.any: deadline plus user cancel
- Treat AbortError as flow control, never log it as a failure
Q38requestAnimationFrame vs setTimeout for visual updates, and what problems do IntersectionObserver and ResizeObserver remove?
IntermediateBrowser APIs
Answer
setTimeout(fn, 16) approximates 60 fps badly: timers are clamped, drift, keep firing in background tabs (wasting battery until throttled), and are not synchronized with the display refresh, so animation steps land mid-frame and tear or jank. requestAnimationFrame schedules the callback right before the next paint, aligned to the display's refresh rate (including 120 Hz screens, so never hardcode 16 ms steps; use the DOMHighResTimeStamp argument to compute delta time), pauses automatically in background tabs, and batches with style/layout recalculation. The rAF loop pattern: the callback re-registers itself and advances state by elapsed time, not frame count. For most UI motion, prefer CSS transitions/animations or the Web Animations API (element.animate), which can run compositor-only properties (transform, opacity) off the main thread entirely; rAF is for canvas rendering, scroll-linked effects, and physics you must compute in JS.
The observers replace polling-based patterns that were both slow and wrong. IntersectionObserver answers 'is this element in (or near) the viewport?' asynchronously, without forcing layout: pass rootMargin: '200px' to preload images or start fetching the next infinite-scroll page BEFORE the sentinel enters view, and threshold arrays for visibility percentages (ad viewability, autoplaying videos at 50%). It replaced scroll handlers calling getBoundingClientRect per element per scroll event, a layout-thrash machine.
Native <img loading="lazy"> now covers plain image lazy-loading, so reserve IO for sentinels and visibility logic. ResizeObserver fires when an ELEMENT's box changes (window resize events miss container-level changes entirely), enabling true container-responsive components; wrap callbacks in rAF or debounce if you mutate layout inside them, or you will meet the 'ResizeObserver loop limit exceeded' console error that floods error trackers.
// Delta-time rAF loop: correct on 60 Hz and 120 Hz alike
let prev = null;
function step(ts) {
if (prev !== null) {
const dt = (ts - prev) / 1000; // seconds since last frame
ball.x += velocity * dt;
ball.style.transform = `translateX(${ball.x}px)`;
}
prev = ts;
requestAnimationFrame(step);
}
requestAnimationFrame(step);
// Infinite scroll: fetch BEFORE the user reaches the end
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadNextPage();
},
{ rootMargin: '400px 0px' } // trigger 400px early
);
io.observe(document.querySelector('#scroll-sentinel'));
const ro = new ResizeObserver(([entry]) => {
card.classList.toggle('compact', entry.contentRect.width < 480);
});
ro.observe(card);
Key Points
- rAF aligns to refresh rate; use timestamp deltas, never assume 16 ms
- transform/opacity animations can skip the main thread; prefer CSS/WAAPI
- IntersectionObserver + rootMargin replaces scroll+getBoundingClientRect
- ResizeObserver sees container changes window resize events miss
Q39Object.freeze is shallow and const is not immutability. What are the real options for immutable updates, and why does React care?
IntermediateObjects & References
Answer
Three commonly confused mechanisms: const prevents rebinding the variable, not mutating the value; Object.freeze makes an object's OWN properties non-writable and non-configurable but leaves nested objects fully mutable (freeze is one level deep, and in sloppy mode frozen writes fail SILENTLY, versus throwing in strict mode, so a non-strict script can 'mutate' a frozen object with no error and no effect, a debugging nightmare); and structural immutability as a discipline: never mutate, always produce updated copies. React's rendering model is why the discipline matters: state updates are detected by reference comparison (Object.is on the state value, shallow comparison in memo), so mutating an object in place and setting it back means 'nothing changed' to React and the UI goes stale; conversely a correct immutable update changes exactly the references along the path to the modified leaf, letting memoized children whose props kept their references skip re-rendering. Hand-rolled immutable updates use spread at each level, which gets error-prone beyond two levels of nesting; Immer is the standard fix: produce(state, draft => { draft.user.address.city = 'Pune' }) lets you write mutable-looking code against a Proxy draft and emits a correctly structurally-shared new state, and Redux Toolkit wraps every reducer in Immer by default, which is why 'mutating' reducers are legal there.
ES2023 finally gave arrays non-mutating counterparts: toSorted, toReversed, toSpliced, and with(index, value) return updated copies, retiring the [...arr].sort() idiom and the classic bug where arr.sort() in a render mutated props in place. Freezing dev-time state (deepFreeze in tests or redux-immutable-state-invariant) converts silent mutation bugs into loud errors, a cheap and worthwhile safety net.
'use strict';
const config = Object.freeze({ api: { url: 'https://api.example.com' } });
try { config.api = null; } catch (e) { console.log('top level throws'); }
config.api.url = 'https://evil.example.com'; // nested: NOT frozen!
console.log(config.api.url); // changed. freeze is shallow
// Immutable update path, hand-rolled:
const next = {
...state,
user: { ...state.user, address: { ...state.user.address, city: 'Pune' } },
};
console.log(next.user.address !== state.user.address); // true (new refs on path)
console.log(next.orders === state.orders); // true (shared untouched)
// ES2023 non-mutating array methods:
const scores = [40, 10, 30];
const sorted = scores.toSorted((a, b) => a - b);
const patched = scores.with(1, 99);
console.log(scores); // [40, 10, 30] untouched
console.log(sorted, patched); // [10, 30, 40] [40, 99, 30]
Key Points
- freeze: shallow, and silent no-op writes outside strict mode
- React change detection is reference-based; in-place mutation = stale UI
- Immer/RTK: mutable-looking drafts, structurally shared output
- ES2023 toSorted/toReversed/toSpliced/with end the mutate-in-render bug
Q40Which modern regex features (named groups, lookbehind, d/s/u/v/y flags, matchAll) should you actually reach for, and what is catastrophic backtracking?
IntermediateStrings & Regex
Answer
The features that changed day-to-day regex work: named capture groups (?<year>\d{4}) make extractions self-documenting via match.groups.year and power readable replacements with $<year> in replace; lookbehind (?<=₹) matches position-after-a-pattern without consuming it, so extracting an amount after a currency marker no longer needs a throwaway group; the s (dotAll) flag lets . cross newlines; u enables correct Unicode handling and property escapes like \p{Script=Devanagari} (matching Hindi text is a nicely India-relevant demo), with the newer v flag superseding u to add set operations in character classes; y (sticky) anchors matching at lastIndex exactly, the primitive tokenizers use; and the d flag adds match.indices with start/end offsets per group, handy for editor-style highlighting. For iteration, str.matchAll(re) (the regex must be global) returns an iterator of full match objects including groups, replacing the old exec-in-a-while-loop; replaceAll does literal-string global replacement without regex escaping headaches, and replace accepts a callback receiving groups for computed rewrites. Two operational gotchas: a global regex object carries mutable lastIndex state, so reusing one /g regex across test() calls yields alternating true/false on the same input, a maddening bug, always reset lastIndex or use fresh literals; and String.prototype.match with /g returns only the matched strings, dropping groups, which is exactly why matchAll exists.
The security topic interviewers escalate to is catastrophic backtracking (ReDoS): nested quantifiers with overlapping alternatives like (a+)+$ force exponential retry on crafted non-matching input, and a single such regex validating user input can pin a Node event loop at 100% CPU, a denial of service. Defenses: avoid nested quantifiers, prefer possessive-style explicit character classes, precompile and review regexes touching user input, test with tools like safe-regex or recheck in CI, and put length limits before pattern checks.
// Named groups + lookbehind: parse '₹1,29,999 on 2026-08-11'
const re = /(?<=₹)(?<amount>[\d,]+).*?(?<date>\d{4}-\d{2}-\d{2})/u;
const m = '₹1,29,999 on 2026-08-11'.match(re);
console.log(m.groups.amount, m.groups.date); // 1,29,999 2026-08-11
// matchAll: full match objects, no lastIndex loop
const tags = 'id=42&ref=hero&id=7';
for (const hit of tags.matchAll(/id=(?<val>\d+)/g)) {
console.log(hit.groups.val); // 42, then 7
}
// The stateful /g trap:
const g = /ab/g;
console.log(g.test('abab'), g.test('abab')); // true true? NO: true, then continues
// ReDoS shape to never ship: nested quantifier + overlap
// /(a+)+$/.test('a'.repeat(30) + '!') // exponential backtracking
Key Points
- Named groups + $<name> replacements keep patterns maintainable
- \p{...} property escapes (u/v flags) handle Indic scripts correctly
- Reused /g regexes carry lastIndex state; classic alternating-test bug
- ReDoS: nested quantifiers on user input can pin the event loop
Q41Design an error hierarchy: Error subclasses, the cause option, AggregateError, and the two global rejection hooks.
IntermediateError Handling
Answer
Production JavaScript needs errors that are catchable by TYPE and preserve their origin story. Subclass Error per domain: class PaymentError extends Error, setting this.name (otherwise stack traces read 'Error' and error trackers group everything into one bucket) and any structured fields (code, status, retryable). The ES2022 cause option ended the era of lost root causes: new Error('order failed', { cause: dbError }) wraps at an abstraction boundary while keeping the original reachable at err.cause, so logs can print the full chain instead of a mystery 'order failed'; before cause, rethrowing meant either leaking low-level errors upward or destroying the trail.
AggregateError (thrown by Promise.any, and constructible yourself) carries an errors array for many-failures-at-once semantics, natural for batch validation. Catch discipline: instanceof branch on your types, handle what you can, and RETHROW what you cannot, ideally wrapped with cause; a bare catch (e) {} swallowing everything is the most common real-world sin, hiding even ReferenceErrors from typos. Know that throw can raise any value (throw 'oops') but non-Error throws lack stack traces and break instanceof handling, hence the eslint rule no-throw-literal.
The last-resort hooks differ by platform. Browsers: window.addEventListener('error', ...) for uncaught synchronous errors and resource failures, and 'unhandledrejection' for promise rejections nobody caught, where event.preventDefault() suppresses the console noise and event.reason is the error, this pair is exactly where Sentry-style SDKs attach. Node: process.on('uncaughtException') and process.on('unhandledRejection'), with the operational rule that after uncaughtException the process state is suspect, so log, flush, and exit rather than limp on; since Node 15 an unhandled rejection crashes the process by default, so every fire-and-forget async call in a server needs an explicit .catch.
class AppError extends Error {
constructor(message, { code, retryable = false, cause } = {}) {
super(message, { cause });
this.name = new.target.name; // subclass name lands in traces
this.code = code;
this.retryable = retryable;
}
}
class PaymentError extends AppError {}
async function charge(order) {
try {
return await gateway.charge(order);
} catch (err) {
throw new PaymentError('charge failed', {
code: 'PAYMENT_GATEWAY',
retryable: err.status >= 500,
cause: err, // root cause preserved
});
}
}
// Last-resort browser hook (where error SDKs live):
window.addEventListener('unhandledrejection', (event) => {
reportToSentry(event.reason);
event.preventDefault();
});
// Walking a cause chain in logs:
for (let e = err; e; e = e.cause) console.log(e.name, e.message);
Key Points
- Subclass + name + structured fields = type-based catch and clean grouping
- { cause } chains context across layers without losing the root error
- unhandledrejection/uncaughtException are reporting hooks, not handlers
- Node 15+: unhandled rejections crash; fire-and-forget needs .catch
Q42What does Vite actually do differently from webpack in dev and in production builds, and when does the difference matter?
IntermediateTooling
Answer
Webpack's dev model bundles first: it walks the whole dependency graph, transforms and concatenates everything into bundles, then serves them, so cold-start time grows with app size, and hot updates require rebundling affected chunks. Vite inverts this using native ES modules: the dev server (vite, default port 5173) serves your source files individually over ESM, transforming each file on demand with esbuild (written in Go, roughly 10-100x faster than Babel-based transforms for TS/JSX stripping), so cold start is near-instant regardless of app size, and HMR invalidates precisely the edited module boundary rather than a chunk. Third-party dependencies get pre-bundled once with esbuild into ESM (the .vite/deps cache) both for speed and to convert CommonJS packages, which is why a misbehaving dependency sometimes needs optimizeDeps.include/exclude tweaks or a rm -rf node_modules/.vite.
For production, Vite does NOT ship unbundled modules: it hands the graph to Rollup for real bundling, tree-shaking, chunk splitting, and asset hashing, because hundreds of tiny modules over HTTP still lose to a few well-split chunks. Configuration lives in vite.config.ts (plugins array, resolve.alias, server.proxy for API forwarding in dev, build.rollupOptions for chunking, import.meta.env with the VITE_ public-prefix rule for env vars, distinct from webpack's DefinePlugin/process.env). When does webpack still matter: module federation for established micro-frontend setups, deeply customized loader pipelines, and legacy codebases where migration cost exceeds benefit; interviews at companies with older React apps (CRA descendants) often ask exactly this trade-off. The ecosystem context worth naming in 2026: Vite underpins most new React/Vue/Svelte tooling, Rolldown (a Rust rebundler) is Vite's path to unifying dev and prod bundling, and esbuild/SWC have largely replaced Babel for transforms, with Babel remaining where custom transform plugins are needed.
// vite.config.ts: the settings that come up in practice
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: { alias: { '@': '/src' } },
server: {
port: 3005,
proxy: {
'/api': { target: 'http://localhost:5000', changeOrigin: true },
},
},
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: { vendor: ['react', 'react-dom'] },
},
},
},
});
// Env vars: only VITE_-prefixed values reach client code
console.log(import.meta.env.VITE_API_URL);
console.log(import.meta.env.DEV, import.meta.env.PROD);
Key Points
- Dev: on-demand native-ESM serving + esbuild transforms = size-independent startup
- Deps pre-bundled to ESM in .vite/deps; stale cache causes weird errors
- Prod: Rollup bundling anyway; unbundled ESM loses at scale
- import.meta.env + VITE_ prefix replaces process.env conventions
Q43How do you test async JavaScript reliably: fake timers, mocking fetch, flushing microtasks, and avoiding flaky sleeps?
IntermediateTesting
Answer
The failure mode that defines async testing is the arbitrary sleep: await new Promise(r => setTimeout(r, 500)) makes suites slow AND flaky, passing locally and failing on a loaded CI runner. The professional toolkit (Vitest and Jest share almost identical APIs): first, return or await the promise in the test; an async test that forgets await passes vacuously before the assertion runs, and expect(promise).resolves/.rejects matchers make intent explicit, with expect.assertions(n) guarding the try/catch style where a non-throwing path would skip the assertion silently. Second, fake timers: vi.useFakeTimers() (jest.useFakeTimers()) replaces setTimeout/setInterval/Date so a debounce test advances virtual time deterministically with vi.advanceTimersByTime(300); the subtle trap is code that mixes timers with promises, where you need the async advancing variants (advanceTimersByTimeAsync, runAllTimersAsync) so queued microtasks flush between timer ticks, otherwise awaited continuations never run and the test hangs; always restore real timers in afterEach to avoid cross-test contamination.
Third, mock the network at the right layer: vi.mock or vi.spyOn(global, 'fetch') for unit tests, but MSW (Mock Service Worker) is the 2026 standard for component/integration tests because it intercepts at the request layer, so the real fetch wrapper code, serialization, and error paths execute, and its http.get handlers are shared between tests and Storybook. Fourth, for UI, never assert immediately after triggering async work: Testing Library's findBy* queries and waitFor poll until the DOM settles, replacing brittle manual flushing. Finally test the unhappy paths deliberately: simulate a 500 (MSW HttpResponse with status 500), a network failure (HttpResponse.error()), and an abort, because production incidents live in exactly those branches; a suite that only tests 200-with-valid-JSON is theater. Coverage of retry logic pairs fake timers with mock sequencing: mockRejectedValueOnce then mockResolvedValueOnce verifies backoff without waiting real seconds.
import { describe, it, expect, vi, afterEach } from 'vitest';
import { debounce } from './debounce';
afterEach(() => vi.useRealTimers());
it('debounce fires once after the quiet period', () => {
vi.useFakeTimers();
const fn = vi.fn();
const d = debounce(fn, 300);
d(); d(); d();
vi.advanceTimersByTime(299);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries once on 500 then succeeds', async () => {
const spy = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(null, { status: 500 }))
.mockResolvedValueOnce(Response.json({ ok: true }));
await expect(fetchWithRetry('/api/x')).resolves.toEqual({ ok: true });
expect(spy).toHaveBeenCalledTimes(2);
});
it('propagates abort as AbortError', async () => {
const ac = new AbortController();
const p = api('/slow', { signal: ac.signal });
ac.abort();
await expect(p).rejects.toMatchObject({ name: 'AbortError' });
});
Key Points
- Arbitrary sleeps = flaky CI; fake timers + advanceTimersByTimeAsync instead
- Un-awaited async tests pass vacuously; use resolves/rejects matchers
- MSW intercepts at the request layer so real fetch code paths run
- Deliberately test 500s, network failure, and aborts, not just happy JSON
Q44Where does TypeScript actually intervene in a JavaScript program, and what can JSDoc give you without a compile step?
IntermediateTypeScript & JS
Answer
TypeScript is a compile-time layer that ERASES completely: interfaces, type annotations, and generics produce zero runtime code, so types cannot validate API responses at runtime; a response typed as User but actually missing fields flows through unchecked until something crashes far from the fetch. That erasure boundary is the deepest interview point: runtime validation needs a schema library (Zod's z.object({...}).parse(data) both validates and infers the static type, keeping one source of truth), and typeof/instanceof checks in code are real JavaScript that doubles as type narrowing. A few TS features are NOT pure erasure and thus matter to JS interop: enum generates an object (const enum inlines away, but breaks under the isolatedModules/single-file-transpile mode esbuild and SWC use), namespaces generate IIFEs, and class parameter properties (constructor(private x)) generate assignments; modern style avoids all three in favor of erasable syntax, and Node's built-in type stripping (running .ts files directly, stable in recent Node) works precisely because it strips annotations without transforming, rejecting the non-erasable features.
The JS-without-TS path is underrated and interviewers increasingly ask about it: // @ts-check at the top of a .js file plus JSDoc annotations (@param, @returns, @type, @typedef, @template for generics) gives you the full TypeScript checker in plain JavaScript, with checkJs in tsconfig enabling it project-wide; large codebases (famously webpack itself, and the Svelte codebase's much-discussed migration) type-check pure JS this way, keeping zero build overhead for library source while still publishing .d.ts declarations generated FROM JSDoc via tsc --declaration --emitDeclarationOnly --allowJs. Practical guidance: new application code defaults to TypeScript with strict: true (noImplicitAny, strictNullChecks catching the majority of undefined-is-not-a-function incidents); scripts, configs, and gradual migrations use checkJs + JSDoc; and either way the CI gate is tsc --noEmit, since editors alone let errors merge.
// @ts-check
/**
* @typedef {{ id: number, email: string, tier: 'free' | 'gold' }} User
*/
/**
* @param {User[]} users
* @param {User['tier']} tier
* @returns {User[]}
*/
function byTier(users, tier) {
return users.filter(u => u.tier === tier);
}
byTier([{ id: 1, email: 'a@b.c', tier: 'gold' }], 'gold');
// byTier([], 'platinum'); // editor + tsc error, no build step involved
// Types are erased: runtime validation needs a schema
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
email: z.string().email(),
tier: z.enum(['free', 'gold']),
});
/** @type {import('zod').infer<typeof UserSchema>} */
const user = UserSchema.parse(await res.json()); // throws on bad payloads
Key Points
- Types erase; runtime validation needs Zod-style schemas at boundaries
- enum/namespace/parameter properties are non-erasable; modern style avoids them
- @ts-check + JSDoc = full checker on plain .js, zero build step
- CI gate is tsc --noEmit; editor squiggles alone do not block merges
Q45Why does [10, 9, 80].sort() return [10, 80, 9], and what else about Array.prototype.sort surprises people?
IntermediateArrays
Answer
Default sort converts elements to STRINGS and compares UTF-16 code units, so numbers sort lexicographically: '10' < '80' < '9'. Any numeric sort needs a comparator: (a, b) => a - b ascending, b - a descending; the contract is negative means a first, positive means b first, zero means keep relative order. Comparator correctness is a real bug class: (a, b) => a > b returns booleans (true coerces to 1, false to 0, so 'a smaller' never reports negative) producing subtly wrong orders, and an inconsistent comparator (violating transitivity, or randomized like () => Math.random() - 0.5 for 'shuffling') yields implementation-defined garbage rather than a shuffle; Fisher-Yates is the correct shuffle.
The a - b comparator also breaks on NaN (comparisons return NaN, contract violated) and can overflow with giant magnitudes, so guard data or compare explicitly. Since ES2019 sort is guaranteed STABLE (equal elements keep their relative order), which is what makes multi-key sorting by chaining comparators with || correct: sort by department, then within it by salary, either as one combined comparator or two stable passes in reverse priority order. sort MUTATES the array and returns the same reference, the source of React bugs where sorting props or state in render silently reorders shared data; the ES2023 fix is toSorted, the non-mutating twin (with toReversed and toSpliced), replacing the defensive [...arr].sort(). For human-facing strings, code-unit comparison misorders case, accents, and Indic scripts; use localeCompare or, for sorting many strings, a reusable Intl.Collator (significantly faster than repeated localeCompare calls), with numeric: true getting you natural ordering like file2 before file10, and locale-aware collation handling Hindi vocabulary correctly under 'hi'. Undefined elements always sort to the end without visiting the comparator, and V8 implements sort with TimSort, O(n log n), a detail worth having if asked about complexity.
console.log([10, 9, 80].sort()); // [10, 80, 9] string compare!
console.log([10, 9, 80].sort((a, b) => a - b)); // [9, 10, 80]
// Boolean comparator bug: never do this
console.log([3, 1, 2].sort((a, b) => a > b)); // wrong contract, unreliable
// Stable multi-key: dept asc, then salary desc
const staff = [
{ dept: 'eng', name: 'R', salary: 32 },
{ dept: 'eng', name: 'A', salary: 45 },
{ dept: 'design', name: 'K', salary: 38 },
];
staff.sort((a, b) => a.dept.localeCompare(b.dept) || b.salary - a.salary);
// Natural + locale-aware ordering with a reused collator:
const files = ['file10.txt', 'file2.txt', 'File1.txt'];
const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
console.log(files.toSorted(collator.compare));
// ['File1.txt', 'file2.txt', 'file10.txt'] and the original is untouched
Key Points
- Default comparator stringifies; numbers need (a, b) => a - b
- Boolean-returning comparators violate the contract silently
- Stability (ES2019) enables chained multi-key sorts with ||
- sort mutates; toSorted (ES2023) and Intl.Collator for real-world text
Q46Implement a typed event emitter with once, unsubscribe, and error isolation, and explain where the pattern appears in platforms you use.
IntermediateHands-on Coding
Answer
The publish-subscribe exercise tests data-structure choice, closure handling, and edge-case discipline, and the pattern is everywhere: Node's EventEmitter underlies streams, HTTP servers, and process signals; the DOM's EventTarget is the browser twin (and is constructible: class Uploader extends EventTarget works with CustomEvent for detail payloads, giving you a standards-based emitter for free); socket.io, message buses, and state libraries are all elaborations. A solid implementation uses Map<string, Set<Function>>: Set gives O(1) add/delete and automatically ignores duplicate subscriptions of the same function reference. The edge cases that separate candidates: (1) unsubscribe during emit, if you iterate the live Set while a handler removes itself (which once must do), you need to snapshot ([...handlers]) before iterating to avoid skipping or double-running; (2) error isolation, one throwing listener must not prevent the rest from running, so wrap each call in try/catch and route failures to an 'error' event or console, mirroring Node's special-cased behavior where an 'error' event with NO listener throws and crashes the process, a real production trap when a stream errors without an error handler attached; (3) memory, subscriptions hold their closures, so an off() (and returning an unsubscribe function from on(), the style RxJS and most modern libraries use) is mandatory, and Node warns at 11 listeners (MaxListenersExceededWarning, tune with setMaxListeners) precisely because leaked subscriptions are so common; (4) once composes as a wrapper that removes itself before invoking, and preserving the original function reference for off(originalFn) requires storing the mapping, a subtlety worth mentioning even if you skip implementing it. Follow-ups to expect: add wildcard events, async handlers with sequential await, or convert the API to AbortSignal-based unsubscription like modern addEventListener.
class Emitter {
#listeners = new Map(); // event -> Set<fn>
on(event, fn) {
if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
this.#listeners.get(event).add(fn);
return () => this.off(event, fn); // unsubscribe handle
}
once(event, fn) {
const wrapper = (...args) => {
this.off(event, wrapper); // remove BEFORE invoking
fn(...args);
};
return this.on(event, wrapper);
}
off(event, fn) {
this.#listeners.get(event)?.delete(fn);
}
emit(event, ...args) {
const handlers = this.#listeners.get(event);
if (!handlers) return;
for (const fn of [...handlers]) { // snapshot: safe self-removal
try { fn(...args); }
catch (err) { console.error(`listener for '${event}' threw`, err); }
}
}
}
const bus = new Emitter();
const offOrder = bus.on('order', o => console.log('order', o.id));
bus.once('order', () => console.log('first order only'));
bus.emit('order', { id: 1 }); // both fire
bus.emit('order', { id: 2 }); // only the persistent listener
offOrder();
Key Points
- Map of Sets: O(1) subscribe/unsubscribe, duplicate-safe
- Snapshot before emit so once/self-removal cannot skip listeners
- Isolate listener errors; recall Node's unhandled 'error' event crash
- Return unsubscribe functions; leaked subscriptions are the leak vector
Q47How do Web Workers actually exchange data, what can and cannot cross the boundary, and when do transferables matter?
IntermediateConcurrency
Answer
JavaScript's main thread owns the DOM, so any long computation janks the UI. Web Workers run scripts on separate threads with a separate global scope (self, WorkerGlobalScope): no DOM access, no window, but timers, fetch, WebSocket, IndexedDB, and importScripts (or native ESM imports with new Worker(url, { type: 'module' }), the modern form that lets bundlers like Vite handle worker code through the same pipeline via new URL('./worker.js', import.meta.url)). Communication is message passing: postMessage on one side, an onmessage handler on the other.
The payload is deep-copied with the STRUCTURED CLONE algorithm, the same one behind structuredClone: objects, arrays, Maps, Sets, Dates, ArrayBuffers, Blobs all work; functions, DOM nodes, and class methods do not (a class instance arrives as a plain object, and a function throws DataCloneError). Copying a 100 MB ArrayBuffer per message defeats the purpose, which is where TRANSFERABLES come in: passing { transfer: [buffer] } as options MOVES ownership instead of copying, the sender's buffer becoming detached (byteLength 0, access throws), zero-copy and constant-time, essential for image processing, audio, and ML pipelines shipping big binary blocks between threads. Errors inside a worker surface via the worker's onerror and unhandled rejections via onmessageerror/reporting you wire yourself; always terminate() workers you are done with, or pool them, since each holds a thread and heap.
Ergonomics: raw postMessage code turns into message-type switch statements fast, and the Comlink library wraps a worker in a proxy so you await worker.methodName(args) directly, the de facto standard. In Node the equivalent is worker_threads (not the old process-based cluster) with the same structured-clone/transfer semantics plus SharedArrayBuffer support. The interview framing that lands: workers buy PARALLELISM for CPU-bound work (parsing huge CSVs, image resizing, crypto); they do nothing for I/O-bound latency, which async already handles without threads.
// worker.js (module worker)
self.onmessage = ({ data }) => {
if (data.type === 'hash-image') {
const view = new Uint8Array(data.pixels); // arrived via transfer: zero copy
let h = 0;
for (const byte of view) h = (h * 31 + byte) | 0;
self.postMessage({ type: 'done', hash: h });
}
};
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), {
type: 'module',
});
const pixels = new ArrayBuffer(50 * 1024 * 1024); // 50 MB frame
worker.postMessage(
{ type: 'hash-image', pixels },
{ transfer: [pixels] } // MOVE, not copy
);
console.log(pixels.byteLength); // 0: detached on this side
worker.onmessage = ({ data }) => {
if (data.type === 'done') console.log('hash', data.hash);
worker.terminate();
};
Key Points
- Structured clone copies payloads; functions/DOM nodes throw DataCloneError
- Transferables move ArrayBuffer ownership: zero-copy, sender detached
- Workers help CPU-bound work only; async already covers I/O waits
- Module workers + import.meta.url integrate with Vite/webpack bundling
Q48What is layout thrashing, which property reads force synchronous reflow, and how do you restructure code to avoid it?
IntermediatePerformance
Answer
The browser batches style and layout work: normally your JavaScript finishes, then style recalculation, layout, paint, and composite run once before the frame. Layout thrashing (forced synchronous reflow) happens when code alternates WRITES (styles, classes, DOM structure) with READS that need up-to-date geometry: offsetWidth/offsetHeight/offsetTop, clientWidth, scrollTop/scrollHeight, getBoundingClientRect(), getComputedStyle() for layout-dependent properties, and even focus(). After a write invalidates layout, such a read cannot be answered from cache, so the engine performs layout SYNCHRONOUSLY inside your loop; interleave read-write across 200 list items and you trigger 200 layouts in one frame instead of one, and DevTools Performance panel flags each as 'Forced reflow' (purple layout blocks with warning triangles) inside your script's flame chart, the diagnostic signature to name in interviews.
The core fix is batching: separate phases, first read every measurement into an array, then perform all writes; the classic FastDOM library institutionalized this read/write scheduling, and doing writes inside requestAnimationFrame while reading before it achieves the same phase separation manually. Complementary techniques: compute in variables instead of re-reading the DOM as a data store; animate only transform and opacity, which skip layout entirely and can run on the compositor thread (left/top/width animations relayout every frame, transform does not, this is the single highest-leverage CSS-adjacent fact in frontend perf interviews); apply class changes once rather than mutating style properties in sequence; build DOM off-screen with DocumentFragment or set innerHTML once instead of appending in a loop; and use content-visibility: auto or contain: layout to fence off subtrees so their changes cannot invalidate the world. In React and similar frameworks the same physics apply inside effects: measuring in useLayoutEffect then writing is fine once, but a component list each measuring-and-mutating in its own effect recreates the thrash; hoist measurement to a parent, or use ResizeObserver, which delivers geometry without forcing layout.
// THRASH: read-write interleaved per item = N forced reflows
items.forEach((el) => {
const h = el.offsetHeight; // read (forces layout after prior write)
el.style.height = `${h * 1.2}px`; // write (invalidates layout)
});
// FIXED: phase separation, one layout total
const heights = items.map(el => el.offsetHeight); // all reads
requestAnimationFrame(() => {
items.forEach((el, i) => {
el.style.height = `${heights[i] * 1.2}px`; // all writes
});
});
// Batch structural inserts:
const frag = document.createDocumentFragment();
for (const row of rows) frag.append(renderRow(row));
table.append(frag); // one insertion, one layout
// Compositor-friendly animation: no layout at all
card.animate(
[{ transform: 'translateY(8px)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: 200, easing: 'ease-out' }
);
Key Points
- Geometry reads after writes force synchronous layout inside your JS
- Culprit reads: offset*/client*/scroll* props, getBoundingClientRect
- Fix: batch all reads, then all writes (FastDOM pattern, rAF for writes)
- Animate transform/opacity only; left/top relayout every frame
Q49In Node, order setTimeout(0), setImmediate, process.nextTick, and a resolved promise callback, and explain the event loop phases behind the answer.
AdvancedNode & Event Loop
Answer
Node's event loop (libuv) cycles through phases: timers (expired setTimeout/setInterval callbacks), pending callbacks, poll (I/O events; the loop parks here when idle), check (setImmediate callbacks), and close callbacks. Orthogonal to the phases sit two higher-priority queues drained BETWEEN every callback: the process.nextTick queue first, then the promise microtask queue. So from the main script: nextTick runs first, then promise callbacks, then the loop enters phases where timers and immediates fire.
The famous trick question: setTimeout(fn, 0) vs setImmediate(fn) from the MAIN MODULE has NONDETERMINISTIC order, because the 0 ms timer is clamped to 1 ms and whether it has expired when the timers phase first runs depends on process startup time; but INSIDE an I/O callback (an fs.readFile handler), setImmediate always wins, because execution is in the poll phase and check comes before the loop wraps back to timers. That from-where-you-schedule dependency is the differentiator between candidates who memorized a listicle and those who understand the loop. Production implications: recursive process.nextTick starves the entire loop (I/O never runs, the docs warn explicitly), so yielding in batch processing should use setImmediate, which lets I/O interleave between batches; a synchronous CPU-heavy handler blocks every connection on the process since Node multiplexes all requests on one thread, which you detect by measuring event-loop delay with perf_hooks' monitorEventLoopDelay() (histogram of loop lag; alert when p99 climbs) or the loop utilization metric APMs expose; the fixes are chunking with setImmediate, worker_threads for real CPU work, or moving the work out of process. Also name the microtask-between-callbacks rule: an await in an HTTP handler resumes as a microtask without waiting for a full loop turn, which is why async/await code does not add per-await loop latency.
const fs = require('node:fs');
console.log('sync');
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('microtask'));
setTimeout(() => console.log('timeout 0'), 0);
setImmediate(() => console.log('immediate'));
// sync -> nextTick -> microtask -> then timeout/immediate in EITHER order
fs.readFile(__filename, () => {
// now inside the poll phase: order is deterministic
setTimeout(() => console.log('io: timeout'), 0);
setImmediate(() => console.log('io: immediate')); // ALWAYS first here
});
// Detecting a blocked loop:
const { monitorEventLoopDelay } = require('node:perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
console.log('loop p99 delay ms:', h.percentile(99) / 1e6);
}, 5000);
Key Points
- Phases: timers -> pending -> poll -> check -> close; nextTick+microtasks between callbacks
- timeout-vs-immediate order: random from main, immediate-first inside I/O
- Recursive nextTick starves I/O; yield with setImmediate in batch work
- monitorEventLoopDelay / loop utilization are the blocking-detection metrics
Q50SharedArrayBuffer and Atomics: how does JavaScript do real shared-memory parallelism, and why do pages need COOP/COEP headers?
AdvancedConcurrency
Answer
postMessage copies (or transfers, detaching the sender). SharedArrayBuffer (SAB) is the third option: one buffer mapped into MULTIPLE threads simultaneously, so a worker and the main thread read and write the same bytes through TypedArray views with no messaging. That immediately imports every classic concurrency hazard into JavaScript: torn or stale reads, lost updates from non-atomic read-modify-write, and instruction reordering.
The Atomics namespace is the answer: Atomics.load and Atomics.store give sequentially-consistent access, Atomics.add/sub/and/or/xor/exchange/compareExchange perform indivisible read-modify-write (compareExchange being the CAS primitive lock-free algorithms build on), and Atomics.wait puts a WORKER thread to sleep until Atomics.notify wakes it, a real futex-style blocking primitive; the main thread is forbidden from wait (it would freeze the UI; TypeError) and got Atomics.waitAsync, which returns a promise, for the same coordination without blocking. Standard patterns: an Int32Array slot as a counter incremented with Atomics.add across a worker pool; a mutex via compareExchange spinning-then-waiting; a ring buffer streaming audio frames between a worklet and a worker with head/tail indexes maintained atomically. The security history is mandatory context: SAB shipped in 2017, was disabled in 2018 after Spectre demonstrated cross-origin memory reading through high-resolution timing, and returned only behind cross-origin isolation: the document must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, after which crossOriginIsolated is true, SAB becomes constructible, and you also get unclamped performance.now() timers; without those headers new SharedArrayBuffer throws, and enabling COEP breaks embedding of third-party resources lacking CORP/CORS opt-in, which is the real deployment pain (ads, embeds, cross-origin images). Where you meet SAB in practice: ffmpeg.wasm and multithreaded WebAssembly (Wasm threads are SAB-backed), Figma/Google-Sheets-class apps, emscripten pthread builds, and pixel or DSP pipelines; for typical business apps, message passing remains the right default and interviewers respect that judgment.
// main.js (served with COOP: same-origin and COEP: require-corp)
console.log(crossOriginIsolated); // must be true, else SAB throws
const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);
const workers = [1, 2].map(() => {
const w = new Worker(new URL('./count.js', import.meta.url), { type: 'module' });
w.postMessage(sab); // shared, NOT copied
return w;
});
setTimeout(() => {
console.log('total', Atomics.load(counter, 0)); // exactly 200000
}, 500);
// count.js
self.onmessage = ({ data: sab }) => {
const counter = new Int32Array(sab);
for (let i = 0; i < 100000; i++) {
Atomics.add(counter, 0, 1); // counter[0]++ would lose updates
}
Atomics.notify(counter, 0);
};
Key Points
- SAB shares memory across threads; plain counter[0]++ loses updates
- Atomics: load/store/add/compareExchange, wait/notify futex semantics
- Main thread: waitAsync only; blocking wait is worker-only
- Requires crossOriginIsolated via COOP/COEP (post-Spectre lockdown)
Q51What are V8 hidden classes and inline caches, and which coding patterns silently deoptimize hot functions?
AdvancedEngine Internals
Answer
JavaScript objects are spec'd as dynamic dictionaries, but V8 makes property access fast by inferring static structure. Every object gets a hidden class (V8 calls them maps; 'shapes' elsewhere) describing its property layout; objects created with the same properties in the same ORDER share one hidden class, and adding a property transitions to a successor class along a transition tree. Property access sites then use inline caches (ICs): the first execution records 'for shape S, the field is at offset 4', and subsequent hits become a shape check plus a direct load.
An IC that always sees one shape is monomorphic (fastest); a few shapes, polymorphic; too many (V8's threshold is small, around four), megamorphic, degrading to hash lookups. The optimizing compiler tiers (Ignition interpreter, then Maglev and TurboFan in current V8) bake these shape assumptions into machine code with guards; when a guard fails, the function DEOPTIMIZES back to the interpreter, and repeated deopts mean the function stays slow. The patterns that cause this, all visible in real codebases: initializing object properties in different orders in different code paths (two shapes for the 'same' object); adding properties conditionally after construction instead of initializing all fields (possibly to null/undefined) in the constructor; using delete, which typically drops the object to dictionary mode (use a null/undefined assignment or a Map instead); storing mixed types in one field (sometimes a number, sometimes a string) breaking type feedback; and element-kind transitions in arrays, where V8 tracks PACKED_SMI < PACKED_DOUBLE < PACKED_ELEMENTS and holes: putting 1.5 or an object into an int array, or creating holes (arr[1000] = x, or delete on an index), permanently downgrades to slower element kinds, holey kinds forcing prototype-chain checks per read.
Verification beats folklore: node --allow-natives-syntax exposes %HaveSameMap(a, b) and %DebugPrint(obj), --trace-deopt logs deoptimization reasons, and Deopt Explorer visualizes them. The honest caveat that lands well in interviews: these effects matter in genuinely hot code (parsers, render loops, per-row transforms over large datasets); for a request handler running thousands of times a day, clarity wins, and stable object shapes are simply a free habit, one TypeScript classes and consistent constructors give you by default.
// Same shape: monomorphic access, fast path
function makePoint(x, y) { return { x, y }; }
// Shape split: two hidden classes for 'the same' object
function makeBad(flag) {
const o = { x: 1 };
if (flag) o.y = 2; // some callers see {x}, some {x, y}
return o;
}
// Constructor that keeps one shape: initialize EVERY field
class Order {
constructor(data) {
this.id = data.id;
this.amount = data.amount;
this.coupon = data.coupon ?? null; // present even when unused
this.notes = data.notes ?? null;
}
}
// Element kinds: keep arrays packed and same-typed
const a = [1, 2, 3]; // PACKED_SMI_ELEMENTS
a.push(4.5); // -> PACKED_DOUBLE (one-way transition)
const b = [1, 2, 3];
b[100] = 4; // -> HOLEY: every read now checks the chain
// delete a[0]; // holes again: prefer splice or overwrite
// Verify, do not guess:
// node --allow-natives-syntax -e "...%HaveSameMap(o1, o2)..."
// node --trace-deopt app.js | grep deoptimizing
Key Points
- Same properties in same order = shared hidden class = monomorphic ICs
- delete, conditional fields, and mixed-type fields trigger deopts/dictionary mode
- Array element kinds only downgrade: avoid holes and type mixing
- Measure with --trace-deopt and %HaveSameMap, not folklore
Q52INP replaced FID as a Core Web Vital. How do you find and fix the long tasks behind a bad INP score?
AdvancedPerformance
Answer
INP (Interaction to Next Paint) measures the WORST interaction latency on a page (roughly the slowest of click/tap/keypress interactions, at a high percentile across visits): from input to the next frame painted after handlers run; under 200 ms is 'good', and Google folds it into ranking signals, which is why SEO-sensitive Indian job portals and e-commerce teams track it obsessively. The mechanics: an interaction's latency = input delay (main thread busy when the event arrived) + processing (your handlers) + presentation delay (rendering the update). The main thread is busy because of LONG TASKS (over 50 ms), so the diagnostic chain is: field data first (web-vitals library's onINP with attribution reports the culprit element and phase breakdown to your analytics; CrUX gives the origin-level view), then reproduce in DevTools Performance with 4-6x CPU throttling, looking for red-flagged long tasks under the interaction.
Fixes by phase. Input delay: stop doing heavy work on the main thread at all (move parsing/scoring to a Web Worker), split startup hydration, and break up any task over 50 ms. Processing: do only what paints NOW in the handler and defer the rest; the modern yielding primitive is scheduler.yield() (await it between chunks; it continues at high priority ahead of other queued tasks), with setTimeout(0) chunking as the fallback, and scheduler.postTask offering explicit priorities ('user-blocking', 'user-visible', 'background') for coarse scheduling; isInputPending() lets a loop yield only when input is actually waiting. Presentation: avoid huge layouts (see layout thrashing), avoid rendering thousand-row lists synchronously (virtualize), and in React use transitions (startTransition) so state updates that need not block the paint do not.
Observability API specifics interviewers expect: PerformanceObserver with type 'event' and durationThreshold captures slow interactions, type 'longtask' captures blocking tasks (the newer 'long-animation-frame' entries, LoAF, attribute the scripts responsible, finally naming the third-party tag that tanked INP), and event.timeStamp vs performance.now() bounds handler cost. A war story shape that works: a marketplace's filter checkbox recomputed 3,000 cards synchronously; virtualizing the list and yielding between chunks took INP from 600 ms to 140 ms.
// Field data: what real users experience, with attribution
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
navigator.sendBeacon('/vitals', JSON.stringify({
inp: value,
element: attribution.interactionTarget,
inputDelay: attribution.inputDelay,
processing: attribution.processingDuration,
}));
});
// Lab: watch long tasks and slow events
new PerformanceObserver((list) => {
for (const t of list.getEntries()) console.warn('long task', t.duration);
}).observe({ type: 'longtask', buffered: true });
// Handler that paints first, computes later, yielding between chunks
filterBox.addEventListener('change', async (e) => {
showSpinner(); // cheap, paints this frame
const rows = await getRows();
for (const chunk of chunksOf(rows, 200)) {
renderChunk(chunk);
if ('scheduler' in window && scheduler.yield) {
await scheduler.yield(); // let input/paint through
} else {
await new Promise(r => setTimeout(r));
}
}
});
Key Points
- INP = worst interaction: input delay + processing + presentation
- Long tasks (>50 ms) are the enemy; LoAF entries name the guilty script
- scheduler.yield / postTask priorities are the modern chunking tools
- web-vitals attribution + CrUX for field truth; DevTools 6x CPU for lab
Q53Implement an LRU cache with O(1) operations in JavaScript, and explain why Map makes the doubly-linked list optional.
AdvancedHands-on Coding
Answer
The canonical design (and what you must implement in a language-agnostic DSA round) pairs a hash map with a doubly-linked list: the map gives O(1) key lookup into list nodes, the list maintains recency order with O(1) unlink/insert at the head, and eviction pops the tail. JavaScript hands you a shortcut that interviewers explicitly probe: Map preserves INSERTION order and its delete+set cycle is O(1), so re-inserting a key moves it to the back, making the Map itself the recency list; map.keys().next().value yields the oldest key for eviction. The result is a complete, correct LRU in about twenty lines: on get, if present, delete and re-set to refresh recency; on set, delete first (so updating an existing key also refreshes), insert, then if size exceeds capacity evict the first key.
State clearly that this relies on specified Map ordering semantics, not an implementation accident, and that the classic node-based version remains worth knowing because follow-ups break the shortcut: an LFU cache, O(1) removal of arbitrary nodes under different ordering rules, or implementing in a language whose maps do not order. Production considerations that elevate the answer: real caches evict by TTL as well as capacity (store expiresAt per entry and lazily drop stale hits), memoization caches keyed by objects should hold keys weakly or they leak (WeakMap trades iteration away for GC-safety, so capacity-bounded LRUs use strong keys deliberately), Node services usually reach for the battle-tested lru-cache package (which adds max size in bytes, TTL, stale-while-revalidate) rather than hand-rolling, and an unbounded module-level memo Map is the memory leak this structure exists to prevent. Complexity: get and set are O(1); space O(capacity). A crisp closing point: the delete-then-set trick is also the standard idiom for 'move to end' in any ordered-Map algorithm, useful beyond caches.
class LRUCache {
#map = new Map();
constructor(capacity) {
if (capacity < 1) throw new RangeError('capacity must be >= 1');
this.capacity = capacity;
}
get(key) {
if (!this.#map.has(key)) return undefined;
const value = this.#map.get(key);
this.#map.delete(key); // refresh recency:
this.#map.set(key, value); // re-insert at the back
return value;
}
set(key, value) {
if (this.#map.has(key)) this.#map.delete(key); // update = refresh too
this.#map.set(key, value);
if (this.#map.size > this.capacity) {
const oldest = this.#map.keys().next().value; // front = least recent
this.#map.delete(oldest);
}
}
}
const cache = new LRUCache(2);
cache.set('a', 1);
cache.set('b', 2);
cache.get('a'); // 'a' refreshed; 'b' is now oldest
cache.set('c', 3); // evicts 'b' (least recently used)
console.log(cache.get('b'), cache.get('a'), cache.get('c')); // undefined 1 3
Key Points
- Map insertion order + delete/set re-insert = recency list for free
- Refresh on BOTH get and update-set; evict keys().next().value
- Know the map+doubly-linked-list version for LFU/arbitrary-removal follow-ups
- Production: lru-cache package adds TTL, byte sizing, SWR semantics
Q54How do async iterators and for await...of let you stream a fetch response or paginate an API with backpressure?
AdvancedStreams & Async Iteration
Answer
The async iteration protocol mirrors the sync one at Symbol.asyncIterator: next() returns a PROMISE of { value, done }, and for await...of drives it, awaiting each step, with break triggering return() for cleanup exactly like sync generators. The crucial property is PULL-based backpressure: the producer runs only when the consumer asks for the next item, so a slow consumer naturally slows production, unlike callback/event push models that buffer unboundedly. async function* composes this trivially, and the two production patterns to have ready are pagination and streaming. Pagination: wrap a cursor API in an async generator that fetches page after page, yielding items one by one; the consumer writes a flat for await loop, can break at any time (the finally block releases resources; a return before full consumption also stops further network calls, saving quota), and never sees cursor bookkeeping.
Streaming: response.body on a fetch is a ReadableStream of Uint8Array chunks; ReadableStream is itself async-iterable in modern browsers and Node 18+, so for await (const chunk of response.body) processes gigabytes with constant memory, decode with a streaming TextDecoder (stream: true handles multi-byte characters split across chunk boundaries, a real bug when parsing SSE or NDJSON), split on newlines, and JSON.parse per line, which is precisely how LLM token streams and NDJSON exports are consumed; Node's readable streams (fs.createReadStream) speak the same protocol, and stream.pipeline or Readable.from bridge generators and streams both directions. Error semantics: a rejected next() makes for await throw, so try/catch around the loop covers mid-stream network failure; pass an AbortSignal into the wrapped fetches for cancellation. Sharp edges worth naming: for await also accepts SYNC iterables of promises but hides individual rejections poorly, so prefer Promise.all for parallel work, for await is inherently SEQUENTIAL, one item at a time, and unordered concurrency needs a pool or the (newer, availability-dependent) parallel helpers rather than this loop; and Symbol.asyncIterator objects are single-pass, spent after one loop.
// Pagination hidden behind an async generator
async function* allCandidates(query, { signal } = {}) {
let cursor = null;
try {
do {
const res = await fetch(
`/api/candidates?q=${encodeURIComponent(query)}` +
(cursor ? `&cursor=${cursor}` : ''),
{ signal }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const page = await res.json();
yield* page.items; // one item per consumer pull
cursor = page.nextCursor;
} while (cursor);
} finally {
console.log('pagination stopped'); // runs on break/return too
}
}
for await (const c of allCandidates('react bengaluru')) {
if (shortlist.length >= 50) break; // no further pages fetched
if (c.score > 0.8) shortlist.push(c);
}
// Streaming NDJSON from a fetch body with constant memory
const res = await fetch('/api/export.ndjson');
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true }); // split-safe UTF-8
const lines = buffer.split('\n');
buffer = lines.pop(); // keep the partial tail
for (const line of lines) if (line) handle(JSON.parse(line));
}
Key Points
- Pull-based: consumer pace throttles the producer (real backpressure)
- break/return stop the generator and fire finally: quota-friendly
- response.body is async-iterable; TextDecoder stream:true is chunk-split-safe
- for await is sequential; parallelism needs Promise.all or a pool
Q55Walk through what actually landed in ES2022 through ES2025 and which additions retire old workarounds.
AdvancedModern JavaScript
Answer
Interviewers use this to date your knowledge, so organize by edition and name the workaround each feature kills. ES2022: .at(-1) on arrays and strings (retires arr[arr.length - 1]), Object.hasOwn (retires the hasOwnProperty.call dance), class fields with #private, static blocks and #x in obj brand checks, top-level await in modules, error cause, and the regex d flag for match indices. ES2023: findLast/findLastIndex (retires reverse-then-find), the change-by-copy quartet toSorted/toReversed/toSpliced/with (retires [...arr].sort() and mutate-in-render bugs), hashbang grammar, and symbols as WeakMap keys.
ES2024: Object.groupBy and Map.groupBy (retire the reduce-into-object grouping idiom), Promise.withResolvers giving you { promise, resolve, reject } in one call (retires the let-resolve-escape-the-executor pattern that every deferred implementation hand-rolled), the regex v flag with set operations in character classes, Array.fromAsync (await-able Array.from over async iterables), Atomics.waitAsync, and String well-formedness helpers (isWellFormed/toWellFormed) for lone-surrogate safety before postMessage or fetch. ES2025: iterator helpers, the headline feature, giving Iterator.prototype map/filter/take/drop/flatMap/reduce/toArray so you can lazily chain over ANY iterable (including infinite generators) without materializing intermediate arrays the way array chaining does; native Set methods union/intersection/difference/symmetricDifference/isSubsetOf/isSupersetOf/isDisjointFrom (retiring hand-rolled set algebra); RegExp.escape (retiring every ad-hoc escapeRegExp util and its subtle bugs); duplicate named capture groups across alternation branches; import attributes with import ... with { type: 'json' } for JSON modules; Promise.try for uniform sync/async function invocation; and Float16Array for GPU/ML interop. Two calibration points close the answer well: features reach Baseline (all evergreen engines) at different speeds, so production use still checks support for the newest, and the decorators proposal remains stage 3, shipped via transpilers (TypeScript 5's standard decorators) rather than natively, a nuance that distinguishes accurate candidates from optimistic ones.
// ES2022-2023 ergonomics
const last = ['a', 'b', 'c'].at(-1); // 'c'
const safe = Object.hasOwn({ x: 1 }, 'x'); // true
const sorted = [3, 1, 2].toSorted(); // original untouched
// ES2024: grouping + deferred in one call
const byRole = Object.groupBy(users, u => u.role);
const { promise, resolve } = Promise.withResolvers();
queue.onDrain = resolve; // no executor gymnastics
await promise;
// ES2025: lazy iterator helpers over an infinite generator
function* naturals() { let n = 0; while (true) yield ++n; }
const firstSquares = naturals()
.map(n => n * n)
.filter(n => n % 2 === 0)
.take(3)
.toArray(); // [4, 16, 36], lazily
// ES2025: Set algebra + safe regex building
const a = new Set([1, 2, 3]), b = new Set([2, 3, 4]);
console.log([...a.symmetricDifference(b)]); // [1, 4]
const re = new RegExp(`^${RegExp.escape(userInput)}$`); // injection-safe
Key Points
- ES2022: at, hasOwn, #private + static blocks, TLA, error cause
- ES2024: groupBy, Promise.withResolvers, v flag, Array.fromAsync
- ES2025: iterator helpers (lazy chains), Set algebra, RegExp.escape
- Decorators are still transpiler-land; check Baseline before shipping newest
Q56Explain prototype pollution end to end: how __proto__ payloads work, a vulnerable merge function, and the layered defenses.
AdvancedSecurity
Answer
Prototype pollution exploits the property-write path: if attacker-controlled keys reach a recursive assignment, the key '__proto__' does not create a property named __proto__, it dereferences the target's prototype, so obj['__proto__']['isAdmin'] = true writes onto Object.prototype, and EVERY plain object in the process suddenly inherits isAdmin: true. The classic sink is a deep-merge or set-by-path utility fed by JSON.parse of a request body (JSON.parse itself is safe, it creates an own '__proto__' data property; the pollution happens when merge code walks that key with bracket assignment); lodash merge, jQuery extend, and dozens of packages shipped CVEs for exactly this, 'constructor.prototype' being the equivalent alternate path. Impact escalates beyond privilege flags: gadget chains turn pollution into RCE in Node when a polluted property flows into child_process options (the notorious shell/env gadgets), into DoS by breaking Object.prototype methods, and into XSS client-side when template engines read polluted config.
Defenses are layered. In your own code: when merging untrusted input, reject the three dangerous keys (__proto__, prototype, constructor), copy only Object.hasOwn keys, and only recurse into plain objects. Structurally: build lookup tables with Object.create(null) (no prototype, nothing to pollute or inherit) or use Map for attacker-keyed data, and validate request bodies against a schema (Zod, AJV) so unexpected keys never reach business logic.
Environment-level: Object.freeze(Object.prototype) at process boot makes pollution writes silent no-ops (some libraries misbehave; test first), and Node's --disable-proto=delete flag removes the __proto__ accessor entirely, closing that path while leaving constructor.prototype to code review. Detection: npm audit and Snyk flag known vulnerable merge utilities, and a unit test asserting ({}).polluted === undefined after your merge runs on a malicious payload is a cheap regression guard. In interviews, tie it to the sibling client-side sink: innerHTML with untrusted data is the XSS analog, defended with textContent by default, sanitization via DOMPurify only when HTML is genuinely required, a strict Content-Security-Policy, and Trusted Types (require-trusted-types-for 'script') which makes the browser reject raw string assignment to dangerous sinks outright.
// VULNERABLE deep merge: walks attacker keys with bracket assignment
function badMerge(target, src) {
for (const k in src) {
if (typeof src[k] === 'object' && src[k] !== null) {
target[k] ??= {};
badMerge(target[k], src[k]); // '__proto__' walks the prototype!
} else {
target[k] = src[k];
}
}
return target;
}
badMerge({}, JSON.parse('{"__proto__":{"isAdmin":true}}'));
console.log({}.isAdmin); // true: every object polluted
// HARDENED merge
const BANNED = new Set(['__proto__', 'prototype', 'constructor']);
function safeMerge(target, src) {
for (const k of Object.keys(src)) {
if (BANNED.has(k)) continue;
const v = src[k];
if (v && Object.getPrototypeOf(v) === Object.prototype) {
safeMerge((target[k] = Object.hasOwn(target, k) ? target[k] : {}), v);
} else {
target[k] = v;
}
}
return target;
}
// Boot-time hardening (verify library compatibility first):
// Object.freeze(Object.prototype);
// node --disable-proto=delete server.js
Key Points
- __proto__/constructor.prototype in merge paths write onto Object.prototype
- JSON.parse is safe; the recursive bracket-assignment merge is the sink
- Defenses: key denylist + hasOwn, null-prototype/Map stores, schema validation
- Hardening: freeze Object.prototype, --disable-proto, pollution regression tests
Q57WeakRef and FinalizationRegistry exist. When are they the right tool, and why do the docs themselves tell you to avoid them?
AdvancedMemory
Answer
A WeakRef holds a reference that does not keep its target alive: deref() returns the object if it has not been collected, or undefined after. FinalizationRegistry runs a callback sometime after a registered object is collected, receiving a held value you provide (never the object itself, which is already gone). Together they expose garbage-collection OBSERVABILITY, and that is precisely why MDN and TC39 attach unusual 'avoid if possible' warnings: collection timing is unspecified, varies across engines and versions, may happen much later than last use or never (a process can exit without running finalizers), and code whose correctness depends on when GC runs is nondeterministic by construction.
Debugging is miserable because behavior changes under DevTools (keeping a heap snapshot alive delays collection). So the legitimate uses are narrow, all sharing the shape 'purely an optimization, correct even if deref always fails or the finalizer never runs': caches that would rather recompute than retain (a map of id to WeakRef of a big parsed document lets memory pressure empty the cache naturally, with a FinalizationRegistry sweeping the dead map entries so the WeakRef wrappers themselves do not accumulate); resource-leak DETECTION in dev builds (register file handles or subscriptions; if the finalizer fires while the resource is still open, someone dropped it without closing, log a warning, this is exactly how Node flags unclosed FileHandles internally); and bridging to external memory in Wasm/native interop, where a registry frees the C++/Wasm allocation when the JS wrapper dies, the pattern emscripten bindings use. What NOT to do: never model ownership or program logic on finalizers (use explicit dispose methods; the approved explicit-resource-management proposal with using declarations and Symbol.dispose is the deterministic answer to cleanup, shipped in TypeScript 5.2+ and rolling into runtimes); never expect finalizers to release critical resources like locks or DB connections; and do not reach for WeakRef when WeakMap already fits, WeakMap for object-KEYED metadata needs no deref dance and no registry sweep, WeakRef is only for holding a weak VALUE you look up by a primitive key. A closing nuance that lands: because deref can return the object mid-function, an object can be resurrected into strong reachability, which is why the registry callback fires only after the LAST weak path is gone.
// Memory-pressure-friendly cache: correct even if entries vanish
class WeakCache {
#map = new Map(); // id -> WeakRef<result>
#registry = new FinalizationRegistry((id) => {
// target was collected; sweep the dead WeakRef wrapper
if (this.#map.get(id)?.deref() === undefined) this.#map.delete(id);
});
get(id, compute) {
const hit = this.#map.get(id)?.deref();
if (hit !== undefined) return hit; // still alive: reuse
const value = compute(id); // collected (or new): rebuild
this.#map.set(id, new WeakRef(value));
this.#registry.register(value, id);
return value;
}
}
const parsedDocs = new WeakCache();
const doc = parsedDocs.get('jd-77', (id) => parseHugeDocument(id));
// Dev-mode leak detector: finalizer as a tripwire, never as cleanup
const openHandles = new FinalizationRegistry((label) => {
console.warn(`Resource '${label}' was GC'd while still open. Missing close()?`);
});
function openTracked(path) {
const handle = open(path);
openHandles.register(handle, path);
return handle; // correct code calls handle.close() AND drops the ref
}
Key Points
- GC timing is unspecified: correctness must survive deref() failing forever
- Valid uses: recompute-friendly caches, dev leak tripwires, Wasm memory bridges
- Never finalize locks/connections; explicit dispose (using/Symbol.dispose) instead
- WeakMap covers object-keyed metadata; WeakRef is for weak values by primitive key
Q58Implement a concurrency limiter (p-limit style) that runs at most N async tasks at once, and explain where unbounded Promise.all breaks systems.
AdvancedHands-on Coding
Answer
Promise.all(userIds.map(fetchProfile)) STARTS every request the moment map runs; all is just waiting. Map ten thousand ids and you open ten thousand sockets: browsers queue past their per-host connection limits, Node exhausts file descriptors or floods the upstream, and rate-limited APIs return 429s that then trip naive retry storms; batch jobs hammering a database connection pool show the same failure. The fix is a semaphore over task FACTORIES: accept functions returning promises (not promises, which are already running, the single most instructive detail of this exercise), run up to N immediately, queue the rest, and on each settlement pull the next from the queue.
A clean implementation keeps two pieces of state: active count and a queue of thunks; limit(fn) returns a promise wired to fn's eventual result via Promise.withResolvers (or an executor), so callers still await natural return values and rejections, and crucially a rejected task must still trigger the next dequeue, so the bookkeeping lives in finally. Correctness details interviewers probe: results must map back to inputs in order (resolve each caller's own promise rather than collecting), errors must not stop the pump (use allSettled semantics at the call site if partial failure is acceptable), and the limiter must not grow the stack (dequeue via queueMicrotask or plain iteration, not recursion into itself synchronously). Production extensions that turn this into a systems conversation: add per-task AbortSignal wiring so cancelling the whole batch aborts queued tasks before they start; add a rate dimension (N per second is a different constraint from N in flight, token bucket handles it); expose queue depth as a metric, because a steadily growing queue is your backpressure alarm; and in real code reach for p-limit/p-queue (or p-map with a concurrency option) which add priorities, timeouts, and pause/resume. The one-liner mental model to leave them with: Promise.all controls WAITING, a limiter controls STARTING, and production incidents come from confusing the two.
function pLimit(concurrency) {
let active = 0;
const queue = [];
const pump = () => {
while (active < concurrency && queue.length) {
active++;
const { fn, resolve, reject } = queue.shift();
Promise.resolve()
.then(fn) // start ONLY now
.then(resolve, reject)
.finally(() => { active--; pump(); }); // rejected tasks still pump
}
};
return function limit(fn) {
const { promise, resolve, reject } = Promise.withResolvers();
queue.push({ fn, resolve, reject });
pump();
return promise;
};
}
// 3 in flight, 10,000 queued politely, order of results preserved by index
const limit = pLimit(3);
const profiles = await Promise.allSettled(
userIds.map(id => limit(() => fetchProfile(id)))
);
const failed = profiles.filter(p => p.status === 'rejected').length;
console.log(`done, ${failed} failures`);
Key Points
- Limit task FACTORIES; a promise in hand is already running
- finally drives the pump so failures never stall the queue
- In-flight cap and per-second rate are different constraints
- Real systems: p-limit/p-queue, queue depth as a backpressure metric
Q59Design frontend error monitoring from scratch: the two global hooks, source maps, 'Script error.', and turning minified traces into fixable bugs.
AdvancedProduction & Debugging
Answer
Client errors are invisible unless you catch and ship them, and the plumbing has sharp edges. Coverage starts with two globals: window.onerror (or addEventListener('error')) receives uncaught synchronous errors with message, source, line, column, and the Error object, and with capture-phase listening also resource-load failures (script/img tags erroring); 'unhandledrejection' catches promise rejections nobody handled, with event.reason as the error, plus 'rejectionhandled' for late saves. Framework layers need their own nets since they often swallow: React error boundaries (and react-dom's onUncaughtError/onCaughtError hooks in recent versions), Vue's app.config.errorHandler, and wrapper try/catch in event handlers, because handler exceptions never reach boundaries.
The first field problem you hit is 'Script error.' with zero detail: errors thrown inside cross-origin scripts (your CDN bundle, third-party tags) are masked by the browser unless the script tag carries crossorigin="anonymous" AND the CDN serves Access-Control-Allow-Origin, so fixing attribute+header is step one of any real rollout. The second problem is minified traces: chunk-7f3a.js:1:48211 is useless until mapped. Production builds emit source maps (Vite build.sourcemap: 'hidden' generates maps without advertising them in the bundle comment); you upload maps to the error tracker during CI (sentry-cli sourcemaps upload, keyed by a release identifier baked into the build) and do NOT serve them publicly unless you accept shipping readable source; the tracker symbolicates server-side, restoring file, line, and original function names.
Around that core: group by symbolicated stack fingerprint, not message text; attach release, route, and a breadcrumb trail (recent clicks, fetches, console entries) since a bare stack rarely reproduces; sample noisy errors and ignore known extensions/bot noise (a filter list for chrome-extension:// frames); track error RATE per release so a deploy that triples errors auto-alerts or rolls back; and beacon reports with navigator.sendBeacon or fetch keepalive so unload does not drop them. Wire the error.cause chain into reports (serialize the whole chain), and log unhandled rejections distinctly, their fix is usually a missing await, a different bug class from throwing handlers. Self-hosted GlitchTip/Sentry-OSS covers teams that cannot ship data to third parties, a relevant note for Indian fintech compliance.
// Minimal but production-shaped reporter
function serialize(err) {
const chain = [];
for (let e = err; e; e = e.cause) {
chain.push({ name: e?.name, message: e?.message, stack: e?.stack });
}
return chain;
}
function report(kind, err, extra = {}) {
const payload = JSON.stringify({
kind,
release: window.__RELEASE__, // baked in at build time
url: location.href,
errors: serialize(err),
...extra,
ts: Date.now(),
});
navigator.sendBeacon?.('/api/client-errors', payload) ||
fetch('/api/client-errors', { method: 'POST', body: payload, keepalive: true });
}
window.addEventListener('error', (e) => {
if (e.message === 'Script error.' && !e.error) {
report('cross-origin-masked', new Error(e.message)); // fix crossorigin+CORS!
return;
}
report('uncaught', e.error ?? new Error(e.message));
}, { capture: true });
window.addEventListener('unhandledrejection', (e) => {
report('unhandled-rejection', e.reason);
e.preventDefault();
});
// index.html: <script src="https://cdn.app.com/bundle.js"
// crossorigin="anonymous"></script>
Key Points
- error + unhandledrejection globals, plus framework boundaries that swallow
- 'Script error.' = missing crossorigin attr + CORS header on the CDN
- Hidden source maps uploaded per release; symbolicate server-side
- Breadcrumbs, release tagging, and rate-per-deploy make reports actionable
Q60Write a retry helper with exponential backoff, jitter, Retry-After respect, and abort support, and justify each policy choice.
AdvancedProduction & Debugging
Answer
Naive retry loops make outages worse: a thousand clients retrying a struggling service immediately and simultaneously is a self-inflicted DDoS, and the synchronized waves that fixed delays produce are called thundering herds. The production policy stack, each layer answering a failure mode: (1) Retry only what can succeed differently: network failures (fetch TypeError), 408/429/5xx, and explicitly idempotent operations; never blind-retry a POST that may have committed (dedupe with an idempotency key header the way payment APIs like Razorpay's and Stripe's do), and never retry 4xx validation failures, which will fail identically forever. (2) Exponential backoff, base * 2^attempt capped at a max, spreads pressure as failures persist. (3) JITTER decorrelates clients: full jitter (random between 0 and the computed delay) is the AWS-documented recommendation, and without it every client that failed together retries together. (4) Respect the server's Retry-After header on 429/503 (seconds or an HTTP date), servers know their recovery window better than your formula, and rate limiters expect compliance. (5) Bound everything: max attempts AND a total-time budget, because six retries of sixty seconds each is a ten-minute hang someone will experience. (6) Abort integration: the caller's AbortSignal must cancel both the in-flight fetch and the backoff SLEEP (an aborted sleep should reject immediately, implemented by listening on the signal inside the delay promise), otherwise 'cancel' takes a full backoff period to work. (7) Observability: emit attempt counts and final outcomes; a climbing retry rate is an early incident signal before error rate moves. The senior follow-up is the circuit breaker: after N consecutive failures stop calling entirely for a cooldown (open state), let one probe through (half-open), and close on success; retries handle transient blips, breakers handle sustained downstream death, and production clients (opossum in Node, resilience4j on JVM) compose both. Mention idempotency keys plus backoff plus breaker as one coherent reliability story and the interview usually follows you there.
const sleep = (ms, signal) =>
new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(t);
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
async function fetchWithRetry(url, options = {}, policy = {}) {
const {
retries = 4, baseMs = 300, capMs = 8000, signal,
retryOn = (r) => r.status === 429 || r.status >= 500 || r.status === 408,
} = { ...policy, signal: options.signal };
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url, options);
if (res.ok || !retryOn(res) || attempt >= retries) return res;
const retryAfter = Number(res.headers.get('Retry-After')) * 1000;
const expo = Math.min(capMs, baseMs * 2 ** attempt);
const delay = retryAfter > 0 ? retryAfter : Math.random() * expo; // full jitter
await sleep(delay, signal);
} catch (err) {
if (err.name === 'AbortError' || attempt >= retries) throw err;
await sleep(Math.random() * Math.min(capMs, baseMs * 2 ** attempt), signal);
}
}
}
const res = await fetchWithRetry('/api/orders', {
method: 'POST',
headers: { 'Idempotency-Key': crypto.randomUUID() }, // safe POST retries
body: payload,
signal: AbortSignal.timeout(30000), // total budget
});
Key Points
- Retry 429/5xx/network only; POSTs need idempotency keys first
- Full jitter (AWS guidance) decorrelates thundering herds
- Honor Retry-After; cap per-delay and total time; abort must cancel the sleep
- Circuit breaker is the follow-up: retries for blips, breakers for outages
Frequently Asked Questions
What does a JavaScript developer earn in India in 2026?
The broad band is ₹6-20 LPA, but the spread by employer type is enormous. Freshers at services companies (TCS, Infosys, Wipro) start around ₹3.5-7 LPA; product companies and funded startups pay ₹8-15 LPA for 1-3 years of experience; strong mid-level engineers at the likes of Flipkart, Swiggy, Razorpay, or CRED land ₹18-35 LPA; and senior or staff frontend/full-stack roles at top product firms and global captives (Microsoft, Atlassian, Walmart Global Tech) cross ₹40-60 LPA with stock. The lever that moves you between bands is depth, event loop, performance profiling, and system design of frontends, plus TypeScript and one framework known deeply rather than three known shallowly.
How long does it take to prepare for a JavaScript interview?
For someone already writing JavaScript daily, 3-4 focused weeks covers it: one week on language internals (closures, this, prototypes, event loop ordering puzzles), one on hands-on implementations (debounce, Promise.all, LRU, event emitter, since Indian product companies almost always include a machine-coding round), one on async patterns, performance, and browser APIs, and a final week of mock interviews and output-prediction drills. Coming from another language, budget 2-3 months, because the interview traps (coercion, hoisting, microtask ordering) are exactly the parts that differ from Java or Python. Practice by predicting snippet output BEFORE running it; passive video watching does not survive contact with a whiteboard.
What do interviewers expect from freshers versus experienced candidates?
Freshers are tested on language correctness: scoping, closures, array methods, promise basics, DOM events, and one or two output-prediction puzzles; a small machine-coding task (todo list, debounced search) checks whether you can build anything at all. At 2-4 years, expect implementation rounds (polyfill bind, build an emitter, flatten deeply nested structures), async error handling, and 'debug this' scenarios. Beyond 5 years the questions become systems: how would you cut INP on a slow dashboard, design an error-monitoring pipeline, structure a module federation or micro-frontend split, and justify caching and retry policies. Seniors get language trivia too, but failing the systems layer is what actually rejects them.
Is plain JavaScript still worth mastering in 2026 when every job posting says TypeScript?
Yes, because TypeScript IS JavaScript plus a static layer that erases at runtime. Every hard interview topic (event loop, closures, prototypes, coercion, memory) is pure JavaScript, and every production incident happens in the compiled JavaScript where types no longer exist. Postings say TypeScript because teams want the tooling discipline, but the interviews still probe JavaScript internals, and candidates who only know 'TypeScript' without the runtime model fail exactly there. The efficient path is to master JavaScript semantics and treat TypeScript as a two-week layer on top: strict mode config, generics, narrowing, and schema validation at boundaries. The reverse order does not work.
JavaScript vs Python as a primary skill for the Indian market: which pays off more?
They dominate different territories. JavaScript owns the frontend outright (React, Angular, Vue all compile to it) and holds a large share of backend and tooling via Node, so it is the single highest-liquidity skill for product-company web roles, and full-stack JS (React + Node) remains the most posted stack combination on Indian job boards. Python owns data science, ML/AI, and scripting-heavy backend niches, and AI-adjacent roles currently command premium salaries. If you want to build products and interfaces, JavaScript compounds better; if you want data/ML, Python does. Many strong engineers carry both, with JavaScript for shipping and Python for analysis, and hiring managers see that pairing as a plus rather than a lack of focus.
Which framework should I pair with core JavaScript to be most employable?
React, by a wide margin in India: it appears in more postings than Angular and Vue combined, powers the frontends at Flipkart, Swiggy, Zerodha, CRED, and most funded startups, and extends into React Native for mobile roles. Angular remains strong in enterprise and services contexts (banking projects, large Infosys/TCS/Accenture engagements), so it is the pragmatic second choice if you target that segment. Vue has passionate adoption but a smaller Indian market. Whichever you pick, add Node/Express basics, because full-stack capability moves you into a larger interview pool, and remember that framework churn is survivable only if the underlying JavaScript is solid; frameworks are two-month layers on a language that takes a year to know deeply.
Introduction
JavaScript interviews in 2026 are no longer about reciting definitions. Whether you are sitting across from a Flipkart frontend panel or a Razorpay platform team, the interviewer wants to see that you understand what the engine actually does: how the event loop schedules microtasks before the next render, why '0.1 + 0.2 !== 0.3', what a closure captures and when that capture leaks memory, and how ES modules differ from CommonJS at load time. The language itself has kept moving, with Object.groupBy, Promise.withResolvers, Set operations like union and intersection, and iterator helpers all landing in recent ECMAScript editions, and interviewers increasingly probe whether you have kept up.
The Indian market splits JavaScript roles into three broad lanes. Product companies (Zerodha, CRED, Swiggy) test depth: implement debounce from scratch, explain why a Promise chain swallowed an error, walk through a heap snapshot to find a detached DOM leak. Services majors (TCS, Infosys, Accenture) lean on fundamentals plus framework literacy, since consultants rotate across React, Angular, and Node codebases. Global captives (Microsoft, Atlassian, Walmart Global Tech) mix both with data-structure rounds where JavaScript is your implementation language, so Map, Set, and typed arrays need to be muscle memory rather than documentation lookups.
This guide contains 60 questions ordered basic to advanced, matching how real panels escalate. The basic section covers the semantics every candidate is expected to nail: coercion, this-binding, hoisting, promises. The intermediate section is where offers are decided: prototypes, the microtask queue, memory leaks, AbortController, Proxy, and testing async code. The advanced section covers what separates senior candidates: Node's event loop phases, worker threads with SharedArrayBuffer, V8 hidden classes, prototype pollution, and the ES2022-ES2025 additions that interviewers use to check whether your knowledge stopped in 2019. Work through the code examples in a console rather than just reading them.
Ready to practice JavaScript interviews?
Don't just read, practice these JavaScript questions live with an AI interviewer that asks follow-ups and scores your answers.