25 Hard JavaScript Interview Questions & Answers (Senior Developer Edition)
Ace senior and staff frontend engineering interviews with 25 deeply technical JavaScript questions and solutions. Covers the Event Loop, microtasks vs macrotasks, V8 hidden classes, closures, prototype pollution, memory leaks, and custom polyfills.
Amit Srivastava
Published on September 30, 2026
25 Hard JavaScript Interview Questions & Answers (Senior Developer Edition)
Senior and Staff frontend engineering interviews require more than reciting API syntax or writing basic algorithms. Interviewers assess your comprehension of the underlying JavaScript runtime: V8 engine heuristics, execution contexts, Event Loop scheduling phases, prototype delegation, and resilient asynchronous design.
This guide analyzes 25 advanced JavaScript interview questions frequently encountered in technical rounds, paired with concise summaries, architectural breakdowns, runnable code implementations, and key edge cases to look out for.
Q1: In what exact order does the JavaScript Event Loop execute tasks, microtasks, and animation frame callbacks?#
Quick Answer:
The Event Loop executes JavaScript on a single thread by continuously processing:
Current Synchronous Call Stack: Runs until completely empty.
All Microtasks (Promise.then, queueMicrotask, MutationObserver): The microtask queue is drained completely before the browser moves on, even if microtasks schedule more microtasks.
Animation Callbacks (requestAnimationFrame): Executed right before the browser calculates style, layout, and repaints (if a render step is due).
One Macrotask (setTimeout, setInterval, setImmediate, I/O, UI events): Exactly one task is pulled from the macrotask queue.
Repeat: Drain all microtasks again after that single macrotask completes.
Detailed Breakdown:
Consider this classic interview puzzle:
Lines 1 and 7 run synchronously on the call stack.
The microtask queue is drained: 3 executes, enqueuing 4. Next, 4 runs, followed by 5 (the chained promise).
Once the microtask queue is empty, the browser renders a frame. requestAnimationFrame (6) fires.
Finally, the event loop picks the oldest macrotask from the task queue: 2 (setTimeout).
Interviewer Trap: Interviewers often ask: "What happens if a microtask recursively enqueues another microtask?"Answer: It starves the Event Loop! The browser will never reach the next macrotask or render phase, completely freezing the UI tab.
Q2: What actually happens under the hood when JavaScript executes an async/await function?#
Quick Answer:async/await is syntactic sugar built on top of Generators and Promises. When the engine hits an await expr:
It evaluates expr and wraps the result in Promise.resolve(expr).
It suspends execution of the current async function, saving its execution context on the heap.
It attaches the remainder of the function as a then() callback on the promise (queued in the Microtask Queue).
Control immediately yields back to the caller function on the call stack.
Code De-sugaring:
// Modern async/awaitasync function loadUserData(userId) { console.log('A: Loading'); const user = await fetchUser(userId); console.log('B: Got User', user); return user.name;}// How the JavaScript engine translates it conceptually:function loadUserDataDesugared(userId) { return new Promise((resolve, reject) => { console.log('A: Loading'); // Runs synchronously until first await! Promise.resolve(fetchUser(userId)) .
Interviewer Trap: Remember that code before the first await inside an async function runs synchronously, not asynchronously!
Q3: What is Event Loop Starvation, and how does modern scheduler.yield() solve the Long-Task problem?#
Quick Answer:
Any continuous JavaScript task running on the main thread for more than 50ms is classified by Chromium as a Long Task. Long tasks block the main thread, causing dropped frames (jank) and high INP (Interaction to Next Paint).
Historically, developers yielded back to the browser using setTimeout(fn, 0). However, setTimeout has a minimum 4ms clamp and loses queue priority. Modern web engines provide scheduler.yield(), which yields control to the browser for user input and rendering, and immediately resumes the task as a high-priority task.
// Processing 50,000 heavy records without freezing the UIasync function processMassiveDataset(items) { for (let i = 0; i < items.length; i++) { processItem(items[i]); // Yield every 5ms to maintain 60-120 FPS if (performance.now() - lastYieldTime > 5) { if ('scheduler' in window && 'yield' in window.scheduler) { await window.scheduler.yield(); // Native cooperative multitasking! } else { await new Promise((resolve) => setTimeout
Q4: How does the Node.js Event Loop differ from the browser Event Loop?#
Quick Answer:
While the browser Event Loop is governed by the WHATWG HTML standard with a single microtask queue and task queue, Node.js uses libuv, which structures execution into distinct sequential phases:
Timers Phase: Executes callbacks scheduled by setTimeout and setInterval.
Pending Callbacks Phase: Executes I/O callbacks deferred to the next loop iteration.
Idle, Prepare Phase: Used internally by libuv.
Poll Phase: Retrieves new I/O events; executes I/O related callbacks.
Check Phase: Executes callbacks registered by setImmediate().
Close Callbacks Phase: Handles socket/handle closures (e.g., socket.on('close')).
Furthermore, Node.js features process.nextTick(), which executes in a dedicated nextTickQueue that drains before standard Promise microtasks between each phase!
// Node.js execution puzzlesetImmediate(() => console.log('1: setImmediate'));setTimeout(() => console.log('2: setTimeout'), 0);process.nextTick(() => console.log('3: nextTick'));Promise.resolve().then(() => console.log('4: Promise'));// Output in Node.js:// 3: nextTick (Drains first before standard microtasks!)// 4: Promise (Standard microtask)// 2: setTimeout or 1: setImmediate (Depends on process startup timing)
Q5: How do Web Workers, SharedArrayBuffer, and Atomics achieve thread-safe concurrency in JavaScript?#
Quick Answer:
JavaScript is single-threaded per realm, but Web Workers run on separate OS threads. Standard workers communicate by copying memory via postMessage (Structured Clone).
For zero-copy high-performance computing, SharedArrayBuffer shares a single block of raw binary memory between threads. To avoid data races, JavaScript provides the Atomics global object, which supplies atomic operations (Atomics.add, Atomics.compareExchange, Atomics.wait, Atomics.notify) that cannot be interrupted by another thread.
// Thread-safe counter across workersconst sharedBuffer = new SharedArrayBuffer(4); // 4 bytes for 1 Int32const sharedArray = new Int32Array(sharedBuffer);// Thread 1 (Worker A) atomically increments index 0Atomics.add(sharedArray, 0, 1);// Thread 2 (Worker B) atomically reads without race conditionsconst value = Atomics.load(sharedArray, 0);// Thread 3 can pause until notified (available only in Worker threads)// Atomics.wait(sharedArray, index, expectedValue, timeoutMs);
Q6: What is the exact difference between the Lexical Environment, Variable Environment, and Execution Context?#
Quick Answer:
Execution Context: The wrapper environment created when running code. Contains the LexicalEnvironment, VariableEnvironment, and this binding.
VariableEnvironment: Stores variables declared with var and function declarations. In ES6+, it exists to maintain legacy scoping rules.
LexicalEnvironment: Stores block-scoped variables (let, const, class). Whenever a block { ... } begins, a new Lexical Environment is pushed onto the scope chain.
The Temporal Dead Zone (TDZ):
When an Execution Context is created, all declarations are hoisted:
var variables are hoisted and initialized to undefined.
let and const variables are hoisted into the Lexical Environment, but remain uninitialized. Accessing them before their syntactic declaration throws a ReferenceError (TDZ).
function scopeProof() { // TDZ for 'a' starts here // console.log(a); // ReferenceError: Cannot access 'a' before initialization console.log(b); // undefined (var is initialized to undefined on creation) let a = 10; // TDZ ends for 'a' var b = 20;}
Q7: What causes a Stale Closure in complex JavaScript architectures, and how do you resolve it?#
Quick Answer:
A closure captures variables by reference, but if an asynchronous callback closes over a variable whose value is later replaced (or if a function captures a primitive value from an earlier scope instantiation), the callback reads the old value.
// The classic stale closure trapfunction createRateLimiter(limit) { let calls = 0; return function makeCall() { calls++; setTimeout(() => { // If 'calls' is inspected or cached here without care: console.log('Calls count:', calls); }, 1000); };}
The 3 Architectural Solutions:
Container Object / Ref Pattern: Store the mutable state inside a stable object reference (ref = { current: value }).
Functional Updates: Pass an updater callback (prev) => next so the latest state is injected by the consumer at execution time.
Explicit Teardown / Re-subscription: Clear and recreate the async timer or event subscription whenever dependencies change.
Q8: How does the V8 Garbage Collector work (Scavenge vs. Mark-Sweep-Compact)?#
Quick Answer:
V8 uses a Generational Garbage Collector based on the Weak Generational Hypothesis: most objects die young. Memory is divided into:
Young Generation (1-64 MB): Short-lived objects. Uses the ultra-fast Scavenger (Cheney's algorithm). Memory is split into two semi-spaces: From-Space and To-Space. Live objects are copied to To-Space, and dead objects are dropped instantly. Objects surviving two scavenges are promoted to the Old Generation.
Old Generation: Long-lived objects. Uses Major GC (Mark-Sweep-Compact):
Marking: Traverses root pointers (window, DOM, call stack) and marks reachable objects.
Sweeping: Adds unreachable memory addresses to a free-list.
Compacting: Defragments memory by shifting remaining objects together to eliminate memory holes.
V8 performs Concurrent Marking and Incremental Marking on background threads to prevent main-thread UI freezing.
Q9: What are the top 4 Hidden Memory Leaks in Single Page Applications (SPAs)?#
Quick Answer:
Detached DOM Trees: Storing a reference to a DOM element in JavaScript after it has been removed from the document with element.remove(). The entire DOM subtree remains retained in heap memory.
Uncleaned Event Listeners & Timers: Forgotten setInterval or window.addEventListener holding references to parent components in their closure scope.
Console Logging in Production:console.log(largeObject) retains largeObject indefinitely in memory because DevTools holds a live reference to it.
Global Singletons and Caches without Eviction: Using a plain const cache = {} instead of WeakMap or an LRU cache with an upper limit.
// Memory leak: Detached DOM node retained in memorylet elements = { button: document.getElementById('submit-btn'),};// Even if you remove it from the DOM:document.body.removeChild(document.getElementById('submit-btn'));// The DOM node CANNOT be garbage collected because 'elements.button' still holds it!// Solution: Clean up referenceselements.button = null;
Q10: Why do WeakMap and WeakSet not allow iteration, and why must keys be objects or non-registered symbols?#
Quick Answer:
In a standard Map, keys are held strongly. In a WeakMap, the reference from key to value is held weakly using Ephemeron tables. If there are no other strong references to the key object, the key and its associated value can be garbage-collected automatically.
Why no iteration or .size?
Whether an unreferenced key has been collected depends on the unpredictable timing of the engine's Garbage Collection passes. If WeakMap supported .keys() or .size, the output would vary depending on whether GC ran 1ms ago. JavaScript forbids non-deterministic API behavior linked to engine GC cycles.
// Storing private component metadata without memory leaksconst privateMetadata = new WeakMap();class Widget { constructor(domNode) { // When domNode is deleted from the page, this entry is automatically GC'd! privateMetadata.set(domNode, { clickCount: 0 }); }}
Part 3: Object Models, Prototypes & Engine Optimizations#
Q11: Explain Prototype Chain Resolution and the difference between __proto__, prototype, and Object.getPrototypeOf().#
Quick Answer:
prototype: A property found only on functions/classes. It defines the blueprint of properties that will be assigned as the prototype of instances created via new MyFunc().
__proto__: An accessor property on Object.prototype that exposes the internal [[Prototype]] link of an instance.
Object.getPrototypeOf(obj): The official ECMAScript standard method to inspect an object's prototype.
function User(name) { this.name = name; }User.prototype.sayHi = function() { return this.name; };const dev = new User('Amit');console.log(dev.__proto__ === User.prototype); // trueconsole.log(Object.getPrototypeOf(dev) === User.prototype); // trueconsole.log(User.prototype.__proto__ === Object.prototype); // true
Q12: What are V8 "Hidden Classes" (Shapes) and how does writing bad code trigger Megamorphic de-optimizations?#
Quick Answer:
JavaScript is dynamically typed, but the CPU needs fixed byte offsets in memory to read properties quickly. V8 solves this by dynamically creating internal Hidden Classes (Shapes).
When properties are added to an object in the same order, objects share the same Shape:
Monomorphic: Encountered exactly 1 shape. (Blazing fast, direct memory offset).
Polymorphic: Encountered 2 to 4 shapes. (Checks a small lookup table).
Megamorphic: Encountered 5+ different shapes. (V8 bails out of optimized JIT compilation and reverts to a slow hash table lookup!).
Staff Engineer Rule: Never use delete obj.prop in performance-critical code! delete mutates the object shape into dictionary mode. Set obj.prop = undefined instead.
Q13: What is the exact precedence hierarchy of the this keyword in JavaScript?#
Quick Answer:this is evaluated at call time according to a strict 4-tier precedence hierarchy:
new Binding: If called with new, this points to the newly instantiated object.
Explicit Binding: If called with .call(ctx), .apply(ctx), or .bind(ctx), this points to ctx.
Implicit Binding: If called as a method (obj.method()), this points to the context object before the dot (obj).
The Arrow Function Exception:
Arrow functions do not have their own this binding. They resolve this lexically from their enclosing scope. Because of this, calling .bind(), .call(), or .apply() on an arrow function has zero effect on this!
Q14: Why is Object.create(null) preferred over {} for dictionary maps, and what is Object.hasOwn()?#
Quick Answer:
A plain object literal {} inherits from Object.prototype. This introduces dangerous flaws when used as a dictionary:
Keys like "toString", "valueOf", or "__proto__" already exist on the prototype.
Malicious user payloads can overwrite obj.hasOwnProperty, breaking validation.
Object.create(null) creates a completely pure dictionary with no prototype chain (__proto__ === undefined).
const safeMap = Object.create(null);safeMap['key'] = 'value';console.log(safeMap.toString); // undefined! (No prototype pollution)// Modern standard (ES2022+):// Object.hasOwn(obj, prop) safely checks property existence without prototype traversal:const user = { name: 'Amit', hasOwnProperty: null }; // Malicious overrideconsole.log(Object.hasOwn(user, 'name')); // true (Safe!)
Q15: How does JavaScript's ToPrimitive algorithm resolve type coercion for + and ==?#
Quick Answer:
When JavaScript evaluates binary operations like obj1 + obj2 or abstract equality obj == 0, it invokes the internal ToPrimitive(input, preferredType) algorithm:
If the object has a Symbol.toPrimitive method, it is executed.
Otherwise, if preferredType is "string": calls .toString() first; if that returns an object, calls .valueOf().
If preferredType is "number" or default: calls .valueOf() first; if that returns an object, calls .toString().
const customObj = { value: 42, [Symbol.toPrimitive](hint) { if (hint === 'number') return this.value; if (hint === 'string') return `Value is ${this.value}`; return this.value; // Default hint },};console.log(+customObj); // 42 (hint: "number")console.log(`${customObj}`); // "Value is 42" (hint: "string")console.log(customObj + ' items'); // "42 items" (hint: "default")
Part 4: Advanced Asynchronous Patterns & Hand-Written Polyfills#
Q16: How do you write a production-grade polyfill for Promise.all() from scratch?#
Quick Answer:
A proper Promise.all() implementation must:
Return a new Promise.
Handle non-array or empty iterables by resolving to [] immediately.
Wrap each item with Promise.resolve(item) (to handle non-promise primitives).
Maintain the exact input order in the output array, regardless of resolution timing.
Reject immediately on the first promise rejection (fail-fast).
function promiseAll(iterable) { return new Promise((resolve, reject) => { // 1. Must handle iterables (Array, Set, etc.) const items = Array.from(iterable); const total = items.length; if (total === 0) { resolve([]); return; } const results = new Array(total); let completedCount = 0; items.forEach((item, index
Q17: How do you implement Promise.allSettled() from scratch?#
Quick Answer:
Unlike Promise.all(), Promise.allSettled() never rejects. It waits for every promise to either resolve or reject, and maps the result into an array of descriptor objects:
{ status: 'fulfilled', value }
{ status: 'rejected', reason }
function promiseAllSettled(iterable) { return new Promise((resolve) => { const items = Array.from(iterable); const total = items.length; if (total === 0) { resolve([]); return; } const results = new Array(total); let settledCount = 0; items.forEach((item, index) => { Promise.
Q18: How do you implement an Async Concurrency Limiter (Task Pool with max concurrency N)?#
Quick Answer:
When triggering hundreds of asynchronous operations (e.g., uploading 1,000 files or crawling pages), launching all promises simultaneously crashes browser network sockets or hits API rate limits. An Async Pool guarantees that at most N promises run concurrently.
class ConcurrencyLimiter { constructor(concurrency) { this.concurrency = concurrency; this.running = 0; this.queue = []; } run(asyncFn) { return new Promise((resolve, reject) => { this.queue.push({ asyncFn, resolve, reject }); this._drain(); }); } _drain() { while (this.running < this.concurrency && this
Q19: How do Generators and Iterators work under the hood ([Symbol.iterator]), and how do they enable Infinite Streams?#
Quick Answer:
An Iterator is an object conforming to the Iterator protocol: it has a .next() method returning { value, done }.
An Iterable is an object with a [Symbol.iterator]() method returning an Iterator.
A Generator function (function*) compiles into an interruptible state machine. Calling .next() runs code until the next yield, suspends execution, and retains local variables without consuming memory for subsequent items.
Q20: How do you implement a production-grade Debounce and Throttle with leading, trailing, and cancel options?#
Quick Answer:
Debounce: Postpones execution until delay milliseconds have passed since the last call. (Search inputs, window resize).
Throttle: Guarantees execution at most once every interval milliseconds. (Infinite scroll, mousemove).
function debounce(fn, delayMs, { leading = false, trailing = true } = {}) { let timerId = null; let lastArgs = null; let lastThis = null; const debounced = function (...args) { lastArgs = args; lastThis = this; const callNow = leading && !timerId; if (timerId) clearTimeout(timerId);
Part 5: Metaprogramming, Core APIs & Web Security#
Q21: How do JavaScript Proxy and Reflect work, and why does Vue 3 use them instead of Object.defineProperty?#
Quick Answer:
A Proxy wraps a target object and intercepts internal engine operations (called traps), such as get, set, deleteProperty, and has.
Reflect mirrors the internal operations of JavaScript, returning a boolean rather than throwing errors.
Why Vue 3 rewrote reactivity using Proxy:
Object.defineProperty could only observe pre-existing object properties. New properties added later (obj.newKey = 1) or deleted properties were invisible without Vue.set().
Object.defineProperty could not intercept direct array mutations by index (arr[0] = 5) or array length changes.
Proxy intercepts all property operations dynamically, including arrays and Map/Set structures!
Q24: What is Prototype Pollution, and how can an attacker hijack client and server application logic?#
Quick Answer:Prototype Pollution occurs when an application recursively merges or assigns properties from untrusted user input (like JSON payloads) without validating reserved keys like __proto__, constructor, or prototype.
If an attacker injects { "__proto__": { "isAdmin": true } }, every plain object in the runtime environment inherits isAdmin = true through Object.prototype!
Use Object.create(null) for objects storing user input.
Freeze the prototype in production: Object.freeze(Object.prototype).
Q25: How do the AbortController and AbortSignal APIs enable composable cancellation across fetch, event listeners, and custom timers?#
Quick Answer:
Historically, asynchronous cancellation was fragmented: clearTimeout for timers, removeEventListener for events, and no native way to cancel fetch requests.
AbortController standardizes cancellation across the entire web platform. A single AbortSignal can cancel an HTTP request, detach DOM event listeners, and abort long-running tasks simultaneously!
const controller = new AbortController();const { signal } = controller;// 1. Automatically cancel fetch requestfetch('/api/heavy-data', { signal }).catch((err) => { if (err.name === 'AbortError') console.log('Fetch successfully aborted!');});// 2. Automatically remove DOM event listener without calling removeEventListener!window.addEventListener('scroll', onScroll, { signal });// 3. Modern AbortSignal helpers (ES2023+):// Auto-timeout after 3 seconds:const timeoutSignal = AbortSignal.timeout(3000);// Compose multiple signals:
Want to test your mastery of these concepts with interactive sandboxes and real-world system design scenarios? Explore DeepFrontend's structured tracks:
When answering advanced JavaScript questions in a senior interview loop:
Explain the "Why": Don't just say what happens; explain the browser engine trade-off (e.g. "V8 uses Hidden Classes because dynamic property hash lookups would be 10x slower on the CPU").
Highlight edge cases and memory implications: Always mention memory leaks, cleanup functions, and thread-safety.
Be precise with terminology: Differentiate between microtasks vs macrotasks, lexical environments vs execution contexts, and prototype links vs constructor functions.
);
});
console.log('7: Sync End');
then
((
user
)
=>
{
console.log('B: Got User', user); // Resumed in Microtask queue
resolve(user.name);
})
.catch(reject);
});
}
(resolve,
0
));
// Legacy fallback
}
lastYieldTime = performance.now();
}
}
}
console.log(Object.prototype.__proto__); // null (End of the chain)
Default Binding: In non-strict mode, this falls back to window / global. In strict mode ('use strict'), it is undefined.
)
=>
{
// 2. Wrap item to handle primitives or promises
Promise.resolve(item)
.then((value) => {
results[index] = value; // Store at exact original index!
completedCount++;
if (completedCount === total) {
resolve(results);
}
})
.catch(reject); // 3. Fail fast on first rejection