Interview prep · Junior → Senior
JavaScript Interview Questions
The questions a JavaScript interviewer actually asks about the language and the browser — types and
coercion, scope and closures, this, prototypes, promises and the event loop, iteration, the DOM
and its event model, modules, and the classic gotchas — each answered with a short example and a link
to the source. I keep these to the language, its runtime, and the core browser model, not framework trivia
or whiteboard puzzles. Pair it with the
JavaScript version reference
and the open JavaScript roles on the jobs board.
Difficulty
Showing 0 of 0 questions
No questions match that combination. .
1 · Core language & types
What are the primitive types in JavaScript, and how do they differ from objects? Junior
There are seven primitives: string, number, boolean, null, undefined, symbol, and bigint. Everything else — arrays, functions, dates, plain objects — is an object. Primitives are immutable and compared/copied by value; objects are mutable and handled by reference (the variable holds a reference, so two names can point at the same object).
let a = 10, b = a; // copy of the value b = 20; // a is still 10 const o = { n: 1 }, p = o; // both reference the same object p.n = 99; // o.n is now 99 too
Why it's asked / follow-up: value-vs-reference is the root of a whole class of “why did my object change?” bugs. Follow-up: “how do you copy an object so the original is untouched?” — a shallow copy ({...o} / Object.assign) or structuredClone(o) for a deep one.
What does typeof return, and what are its quirks?
Junior
typeof returns a lowercase string naming the operand's type. The two quirks every interviewer wants you to know: typeof null is "object" (a historical bug, kept for compatibility), and typeof a function is "function" even though functions are objects. It's also safe on undeclared identifiers — it returns "undefined" instead of throwing.
typeof 42 // "number" typeof "hi" // "string" typeof undefined // "undefined" typeof null // "object" ← the famous bug typeof (() => {}) // "function" typeof [] // "object" → use Array.isArray() for arrays
Why it's asked / follow-up: it surfaces the typeof null trap and the limits of typeof for distinguishing object kinds. Follow-up: “how do you check for an array?” — Array.isArray(x), never typeof.
Source: MDN — typeof.
What is the difference between == and ===?
Junior
=== (strict equality) compares value and type with no conversion. == (loose equality) performs type coercion first, applying a set of rules most people don't fully memorize — which is exactly why the convention is to always use ===. The one common exception is x == null, which neatly matches both null and undefined.
0 == "" // true — both coerce to 0 / falsy 0 == "0" // true "" == "0" // false — no longer numeric coercion null == undefined // true 0 === "" // false — strict: different types
Why it's asked / follow-up: coercion bugs are common and the inconsistency above (0=="0" but ""!="0") shows why. Follow-up: “when is == acceptable?” — the == null idiom; otherwise prefer ===.
What is the difference between null and undefined?
Junior
undefined means “no value has been assigned” — it's what the engine gives an uninitialized variable, a missing argument, or a missing property. null is an intentional “no value,” set by your code. Both are falsy; the ?? nullish-coalescing and ?. optional-chaining operators treat the two together.
let x; // undefined (uninitialized) const y = null; // deliberately empty ({}).missing // undefined (absent property) x ?? "default" // "default" — ?? covers null AND undefined
Why it's asked / follow-up: it checks whether you distinguish “never set” from “set to nothing.” Follow-up: “typeof each?” — typeof undefined is "undefined", but typeof null is "object".
Source: MDN — undefined.
What is NaN, and why is Number.isNaN preferred over the global isNaN?
Mid
NaN (“not a number”) is the numeric result of an invalid math operation. Its defining oddity: it is not equal to itself, so NaN === NaN is false. The global isNaN() coerces its argument first, so it gives false positives; Number.isNaN() does no coercion and only returns true for an actual NaN.
NaN === NaN // false isNaN("hello") // true — coerces "hello" to NaN first (misleading) Number.isNaN("hello") // false — it's a string, not NaN Number.isNaN(0 / 0) // true
Why it's asked / follow-up: the self-inequality and the two isNaNs are a classic “do you know the footguns” probe. Follow-up: “how else can you detect NaN?” — Object.is(x, NaN), or the self-inequality trick x !== x.
Source: MDN — Number.isNaN.
2 · Scope, closures & hoisting
What is the difference between var, let, and const?
Junior
var is function-scoped, can be redeclared, and is hoisted as undefined. let and const are block-scoped ({ }), can't be redeclared in the same scope, and live in the temporal dead zone until their declaration runs. const forbids reassignment of the binding — it does not freeze the object, so a const array can still be pushed to. Modern code uses const by default and let when reassignment is needed; var is effectively legacy.
const nums = [1, 2]; nums.push(3); // fine — mutating, not reassigning nums = []; // TypeError: Assignment to constant variable if (true) { var v = 1; let l = 2; } v; // 1 — var leaked out of the block l; // ReferenceError — let stayed in the block
Why it's asked / follow-up: it's the most basic modern-JS fluency check. Follow-up: “why prefer const?” — it signals intent and prevents accidental reassignment; reach for let only when you truly rebind.
Source: MDN — let. Arrived with ES2015 (ES6).
What is hoisting? Mid
Hoisting is that declarations are processed before any code runs, so a name exists throughout its scope. But what is hoisted differs: var is hoisted and initialized to undefined; function declarations are hoisted whole (callable before their line); let / const are hoisted but stay uninitialized in the temporal dead zone, so touching them early throws.
greet(); // "hi" — function declaration is fully hoisted function greet() { console.log("hi"); } console.log(x); // undefined — var hoisted, not yet assigned var x = 5; console.log(y); // ReferenceError — let is in the TDZ let y = 5;
Why it's asked / follow-up: it explains both “why was my var undefined” and “why did let throw.” Follow-up: “are function expressions hoisted?” — only the variable is; the function value isn't assigned until that line.
Source: MDN — Hoisting.
What is the temporal dead zone? Mid
The temporal dead zone (TDZ) is the span from the start of a block to the line where a let or const is declared. The name is reserved in scope (so it shadows any outer one), but reading or writing it in that window throws a ReferenceError. It exists to turn “used before declared” into a loud error rather than the silent undefined that var produced.
{
// TDZ for `total` starts here
console.log(total); // ReferenceError, not undefined
let total = 0; // TDZ ends — `total` now usable
}
Why it's asked / follow-up: it's the “why is let safer than var” payoff. Follow-up: “does typeof dodge it?” — no; typeof on a TDZ variable still throws, unlike on a never-declared name.
Source: MDN — Temporal dead zone.
What is a closure? Mid
A closure is a function bundled together with the lexical scope it was created in — it keeps access to those outer variables even after the outer function has returned. It's how you get private state, factory functions, and the data-hiding patterns JavaScript had before classes and modules.
function counter() { let n = 0; // private to each counter return () => ++n; // closes over n } const next = counter(); next(); // 1 next(); // 2 — n persists between calls, unreachable from outside
Why it's asked / follow-up: closures underpin callbacks, event handlers, and module-style privacy; they're one of the most-asked JS concepts. Follow-up: “what does a closure capture — the value or the variable?” — the variable, which is the cause of the var-in-a-loop trap (see Classic gotchas).
Source: MDN — Closures.
What is an IIFE, and why was it used? Mid
An IIFE (immediately invoked function expression) is a function defined and called on the spot. Before block scoping and modules, it was the standard way to get a private scope — variables inside didn't leak to the global namespace — and to build the “module pattern.” Today let/const blocks and ES modules cover most of its old uses, but you'll still see it in older code and bundler output.
(function () { var secret = 42; // scoped to the IIFE, not global // ... setup work ... })(); const api = (() => { // module pattern: expose only what you return let count = 0; return { inc: () => ++count }; })();
Why it's asked / follow-up: it ties together scope, closures, and JS history. Follow-up: “why the wrapping parentheses?” — they make the parser read function as an expression (a value to call) rather than a declaration.
Source: MDN — IIFE.
3 · this, functions & binding
How is the value of this determined?
Mid
For an ordinary function, this is set by how the function is called, not where it's defined. Four rules, in order of precedence: called with new → the new object; called with call/apply/bind → the bound value; called as obj.method() → obj; called plain → undefined in strict mode (the global object otherwise). Arrow functions ignore all of this and inherit this lexically.
const obj = { name: "A", hi() { return this.name; } }; obj.hi(); // "A" — called as a method const f = obj.hi; f(); // undefined — plain call, `this` is lost
Why it's asked / follow-up: mis-set this is one of the most common JS bugs. Follow-up: “why did f() lose its this?” — detaching the method drops the call-site object (see “losing this” in Classic gotchas).
Source: MDN — this.
What do call, apply, and bind do?
Mid
All three set this explicitly. call invokes the function now with arguments listed individually; apply is the same but takes the arguments as an array; bind doesn't invoke — it returns a new function permanently bound to that this (and optionally some leading arguments, i.e. partial application).
function greet(g) { return g + ", " + this.name; } const user = { name: "Ada" }; greet.call(user, "Hi"); // "Hi, Ada" greet.apply(user, ["Hi"]); // "Hi, Ada" — args as an array const bound = greet.bind(user); bound("Hey"); // "Hey, Ada" — this is fixed forever
Why it's asked / follow-up: bind is the classic fix for “my callback lost its this.” Follow-up: “call vs apply?” — identical except how arguments are passed (spread ... has made apply largely unnecessary).
Source: MDN — Function.prototype.bind.
How do arrow functions differ from regular functions? Mid
The big one: an arrow function has no own this — it captures this from the enclosing lexical scope, which is exactly what you want for callbacks inside a method. Arrows also have no arguments object, can't be called with new, and have no prototype. Use a regular function when you need a dynamic this (object methods, constructors); use an arrow to preserve the surrounding this.
const timer = { secs: 0, start() { setInterval(() => { this.secs++; }, 1000); // arrow keeps `this` = timer } }; // A regular function here would set this = undefined/global instead.
Why it's asked / follow-up: the lexical-this behavior is the reason arrows exist and a frequent source of “why is this wrong” answers. Follow-up: “when should you NOT use an arrow?” — as an object method or constructor, where you need the call-site this.
Source: MDN — Arrow functions. Arrived with ES2015 (ES6).
What are default, rest, and spread parameters? Junior
Default parameters supply a value when an argument is missing/undefined. Rest (...args in a parameter list) gathers any extra arguments into a real array. Spread (... at a call site or in a literal) expands an iterable into individual elements. Rest and spread share syntax but do opposite things — gather vs. expand.
function sum(first = 0, ...rest) { // default + rest (array) return rest.reduce((a, b) => a + b, first); } sum(1, 2, 3); // 6 const nums = [2, 3]; sum(1, ...nums); // 6 — spread expands the array into args const merged = [...nums, 4]; // [2, 3, 4]
Why it's asked / follow-up: they're everywhere in modern code (copying arrays/objects, forwarding args). Follow-up: “rest vs arguments?” — rest is a real Array; arguments is an array-like and absent in arrows.
Source: MDN — Spread syntax / rest parameters.
What does “functions are first-class” mean? Junior
Functions are ordinary values: you can store them in variables, put them in arrays or objects, pass them as arguments, and return them from other functions. That's what makes callbacks, array methods (map/filter), event handlers, and higher-order functions possible. A function that takes or returns a function is a higher-order function.
const ops = { add: (a, b) => a + b }; // stored in an object [1, 2, 3].map(ops.add.bind(null, 10)); // [11, 12, 13] — passed as a value const twice = fn => x => fn(fn(x)); // returns a function twice(n => n + 1)(5); // 7
Why it's asked / follow-up: it's the foundation of functional patterns in JS. Follow-up: “what's a higher-order function?” — one that takes a function as an argument or returns one (map, setTimeout, a decorator).
Source: MDN — First-class function.
4 · Prototypes, objects & classes
What is the prototype chain? Mid
Every object has an internal link, [[Prototype]], to another object. When you read a property that isn't on the object itself, the engine walks up this chain until it finds the property or reaches null. That's how method sharing and inheritance work in JavaScript — no classes required underneath. Don't confuse obj.__proto__ (the link an instance follows) with Fn.prototype (the object that becomes the [[Prototype]] of instances created by new Fn()).
const arr = [1, 2]; arr.map; // found on Array.prototype, not on arr itself Object.getPrototypeOf(arr) === Array.prototype; // true arr.hasOwnProperty("map"); // false — it's inherited, not own
Why it's asked / follow-up: it's the mechanism every JS object model rests on. Follow-up: “__proto__ vs prototype?” — __proto__ is the per-object link; prototype is a property on constructor functions that seeds it.
How does prototypal inheritance work, and how is it different from classical inheritance? Senior
In class-based languages, a class is a blueprint and instances are stamped from it. In JavaScript, objects simply delegate to other objects: an object's [[Prototype]] is itself a live object, and a failed property lookup is forwarded to it. Object.create(proto) makes a new object with a chosen prototype directly — the purest expression of the model. class syntax (next question) is sugar over exactly this.
const animal = { speak() { return this.sound; } }; const dog = Object.create(animal); // dog delegates to animal dog.sound = "woof"; dog.speak(); // "woof" — speak found on animal, this = dog
Why it's asked / follow-up: it separates people who memorized class from people who understand what it compiles to. Follow-up: “what does Object.create(null) give you?” — an object with no prototype at all (no inherited toString etc.), handy as a clean map.
Source: MDN — Object.create.
Are JavaScript classes “real” classes?
Mid
No — class is syntactic sugar over prototypes. Methods you declare in a class body land on ClassName.prototype; extends wires up the prototype chain; super calls up it. The sugar is worthwhile (clearer syntax, #private fields, a guard against calling a constructor without new), but underneath it's the same delegation model.
class Animal { constructor(sound) { this.sound = sound; } speak() { return this.sound; } } class Dog extends Animal { speak() { return super.speak() + "!"; } } typeof Dog; // "function" — a class is a function Dog.prototype.speak; // the method lives on the prototype
Why it's asked / follow-up: it checks whether you know what the sugar desugars to. Follow-up: “what does extends actually do?” — sets the subclass's prototype (and its [[Prototype]] for static members) to the parent.
Source: MDN — Classes. Arrived with ES2015 (ES6).
What does Object.freeze do, and what are its limits?
Mid
Object.freeze(obj) makes an object's existing properties non-writable and non-configurable — you can't add, remove, or change them. The key limit: it is shallow. Nested objects are still mutable. (A real deep freeze means recursing yourself.) In strict mode, a write to a frozen property throws; in sloppy mode it fails silently.
const cfg = Object.freeze({ env: "prod", opts: { debug: false } }); cfg.env = "dev"; // ignored (throws in strict mode) cfg.opts.debug = true; // WORKS — nested object isn't frozen Object.isFrozen(cfg); // true
Why it's asked / follow-up: the shallow-ness is the gotcha most people miss. Follow-up: “freeze vs const?” — const stops reassigning the variable; freeze stops mutating the object's own properties. They solve different problems.
Source: MDN — Object.freeze.
5 · Async: callbacks, promises, async/await
What is a Promise, and what are its states? Mid
A Promise is an object representing a value that may not exist yet — the eventual result of an async operation. It is in one of three states: pending, then either fulfilled (with a value) or rejected (with a reason), and once settled it never changes. You react with .then() (success), .catch() (failure), and .finally(); because each returns a new promise, they chain — flattening what used to be nested callbacks.
fetch("/api/user") .then(res => res.json()) // returns a promise → chains .then(user => render(user)) .catch(err => show(err)) // any rejection above lands here .finally(() => hideSpinner());
Why it's asked / follow-up: promises are the backbone of all modern async JS. Follow-up: “what does returning a value vs a promise inside .then do?” — either way the next .then waits; a returned promise is adopted (unwrapped) before the chain continues.
Source: MDN — Using promises. Arrived with ES2015 (ES6).
How do async / await work?
Mid
async/await is syntax over promises that lets you write asynchronous code that reads sequentially. An async function always returns a promise; await pauses the function until the awaited promise settles, then resumes with its value (or throws its rejection). It doesn't block the thread — the function suspends and the event loop keeps running.
async function load() { try { const res = await fetch("/api/user"); const user = await res.json(); return user; // resolves the returned promise } catch (err) { // a rejected await is caught here, like sync code } }
Why it's asked / follow-up: it's how most production async code is written now. Follow-up: “how do you run two awaits in parallel?” — start both promises first, then await Promise.all([a, b]) — awaiting them one after another serializes work that could overlap.
Source: MDN — async function. Arrived with ES2017.
How do you handle errors in async code? Mid
With promises, attach .catch() (it handles a rejection anywhere earlier in the chain). With async/await, wrap awaits in try/catch exactly like synchronous code. The common bug is a floating promise — calling an async function without awaiting it or catching it, so a rejection becomes an unhandled rejection.
// BAD: nothing awaits or catches this → unhandled rejection load(); // GOOD: await inside try/catch, or attach .catch() try { await load(); } catch (e) { report(e); } load().catch(report); // equivalent for a fire-and-forget call
Why it's asked / follow-up: unhandled rejections are a top source of silent production failures. Follow-up: “does a try/catch catch an error in a non-awaited promise inside the block?” — no; only the awaited one. You must await (or .catch) it.
Source: MDN — Using promises: error handling.
What's the difference between Promise.all, race, allSettled, and any?
Senior
All four take an iterable of promises and run them concurrently, differing in how they settle. all — fulfills with all values, or rejects as soon as any one rejects (fail-fast). allSettled — waits for every promise and reports each as fulfilled/rejected (never short-circuits). race — settles as soon as the first one settles, win or lose. any — fulfills with the first fulfilled one, ignoring rejections until all fail.
await Promise.all([a, b]); // [aVal, bVal] — or throws on first reject await Promise.allSettled([a, b]); // [{status,value|reason}, ...] await Promise.race([req, timeout]);// whichever settles first (timeout pattern) await Promise.any([m1, m2, m3]); // first success; AggregateError if all fail
Why it's asked / follow-up: picking the right combinator is a real fluency signal. Follow-up: “which would you use for a fetch with a timeout?” — race against a rejecting timer; “for independent calls where one failing shouldn't kill the rest?” — allSettled.
Source: MDN — Promise.all (and the sibling combinators).
What is “callback hell,” and how do promises fix it? Senior
Callback hell is the deep right-drift you get when async steps each nest inside the previous one's callback — hard to read, and worse, each level needs its own error handling. Promises flatten the nesting into a linear chain with one shared .catch; async/await goes further and makes it read like ordinary sequential code.
// Callback hell: getUser(id, (u) => { getOrders(u, (o) => { getItems(o, (i) => { /* ...and error handling at every level */ }); }); }); // async/await — flat, one try/catch: const u = await getUser(id); const o = await getOrders(u); const i = await getItems(o);
Why it's asked / follow-up: it tells the story of why promises and async/await were added. Follow-up: “is sequential await always right?” — no; if steps are independent, run them concurrently with Promise.all instead of awaiting in series.
Source: MDN — Using promises.
6 · The event loop & concurrency
Explain the event loop. Senior
JavaScript runs on a single thread with a call stack. Slow work (timers, network, DOM events) is handed off to the host (browser / Node), which queues a callback when it's done. The event loop's job: when the stack is empty, take the next queued task and run it to completion. Crucially there are two queues — the macrotask queue (timers, I/O, UI events) and the higher-priority microtask queue (promise reactions, queueMicrotask). After each macrotask the loop drains all microtasks before rendering or taking the next macrotask.
console.log("1"); setTimeout(() => console.log("2"), 0); // macrotask Promise.resolve().then(() => console.log("3")); // microtask console.log("4"); // logs 1, 4, 3, 2 — sync first, then microtasks, then the timer
Why it's asked / follow-up: it's the signature senior JavaScript question and the thing the open web most often explains wrong. Follow-up: “why does a long synchronous loop freeze the UI?” — it never yields the stack, so the loop can't process clicks or repaint.
Source: MDN — JavaScript execution model.
Why does Promise.then run before setTimeout(…, 0)?
Senior
Because they go in different queues. A setTimeout callback is a macrotask; a promise reaction is a microtask. The event loop empties the entire microtask queue after the current task and before the next macrotask — so any pending .then runs ahead of even a zero-delay timer. (And setTimeout(…, 0) isn't really 0 ms; the spec clamps nested timers to a minimum of ~4 ms.)
setTimeout(() => console.log("timeout"), 0); Promise.resolve().then(() => console.log("promise")); queueMicrotask(() => console.log("microtask")); // promise, microtask, timeout — both microtasks drain before the timer
Why it's asked / follow-up: it's the precise test of whether you actually understand the two-queue model. Follow-up: “can microtasks starve the loop?” — yes; a microtask that keeps scheduling more microtasks can block rendering and macrotasks indefinitely.
Source: MDN — Using microtasks.
If JavaScript is single-threaded, how can it do things concurrently? Mid
Your JavaScript runs on one thread, but the host environment isn't single-threaded. When you call fetch or setTimeout, the work is handed to the browser/Node, which runs it elsewhere and queues a callback when done. So JS is “concurrent” in the sense of not blocking on I/O, not in the sense of running your code in parallel. For genuine CPU parallelism you reach for Web Workers (or worker threads in Node), which run on separate threads and communicate by messages.
// The fetch happens off-thread; this line doesn't block: fetch("/slow").then(handle); doOtherWork(); // runs immediately, before the response arrives // True parallelism: offload CPU work to another thread const worker = new Worker("crunch.js");
Why it's asked / follow-up: it separates “non-blocking” from “parallel,” a distinction people routinely blur. Follow-up: “how do workers share data?” — by message passing (structured clone), or a SharedArrayBuffer for shared memory.
Source: MDN — JavaScript execution model.
When an async function hits await, what actually happens?
Senior
await doesn't block. The function runs synchronously up to the await, then suspends and returns control to the caller; everything after the await is scheduled as a microtask that resumes once the awaited promise settles. So code after an await always runs in a later microtask tick, even if the awaited value was already available.
async function f() { console.log("A"); // runs synchronously await null; // suspend; rest becomes a microtask console.log("C"); // runs after the current stack unwinds } f(); console.log("B"); // logs A, B, C — "C" is deferred to a microtask
Why it's asked / follow-up: it connects async/await to the microtask model from the previous questions. Follow-up: “does await on a non-promise still defer?” — yes; the value is wrapped and the continuation is queued as a microtask regardless.
Source: MDN — await.
7 · Arrays, iteration & functional methods
What do map, filter, and reduce do?
Junior
All three are non-mutating array methods that take a callback. map transforms each element and returns a new array of the same length. filter keeps the elements for which the callback returns truthy. reduce folds the array down to a single accumulated value. They return new arrays/values, leaving the original untouched.
const nums = [1, 2, 3, 4]; nums.map(n => n * 2); // [2, 4, 6, 8] nums.filter(n => n % 2 === 0); // [2, 4] nums.reduce((sum, n) => sum + n, 0); // 10
Why it's asked / follow-up: they're the everyday functional toolkit and a smell test for idiomatic JS. Follow-up: “why pass an initial value to reduce?” — it avoids a throw on an empty array and fixes the accumulator's type.
Source: MDN — Array.prototype.reduce (and map / filter).
How do you copy or merge an array without mutating it? Junior
Spread (...) is the idiomatic shallow copy and merge: [...arr] clones, [...a, ...b] concatenates. Modern non-mutating methods help too: toSorted, toReversed, and with return new arrays instead of changing the original (unlike the old sort/reverse, which mutate in place). Remember spread is shallow — nested objects are shared.
const a = [3, 1, 2]; const copy = [...a]; // shallow clone const sorted = a.toSorted(); // [1, 2, 3] — a is untouched a.sort(); // MUTATES a in place — older API
Why it's asked / follow-up: accidental mutation (especially of state in UI frameworks) is a frequent bug. Follow-up: “does spread deep-copy?” — no; use structuredClone() for a deep copy.
Source: MDN — Spread syntax.
What is destructuring? Mid
Destructuring unpacks values from arrays or properties from objects into distinct variables in one expression, with support for defaults, renaming, and nesting. It's heavily used for pulling fields out of objects, swapping variables, and giving function parameters a self-documenting shape.
const { name, role = "dev" } = user; // object, with a default const [first, , third] = items; // array, skipping one function draw({ x = 0, y = 0 } = {}) {} // destructured params [a, b] = [b, a]; // swap without a temp
Why it's asked / follow-up: it's pervasive in modern code, especially around props and config objects. Follow-up: “why the = {} on the parameter?” — so calling with no argument doesn't throw when it tries to destructure undefined.
Source: MDN — Destructuring assignment. Arrived with ES2015 (ES6).
What are iterators and generators? Senior
An object is iterable if it implements Symbol.iterator, returning an iterator — an object with a next() that yields { value, done }. That protocol is what for...of, spread, and destructuring all consume. A generator (function*) is the easy way to build one: each yield produces a value and pauses, so values are computed lazily — even infinitely.
function* ids() { let n = 1; while (true) yield n++; // lazy, infinite — only computes on demand } const g = ids(); g.next().value; // 1 g.next().value; // 2
Why it's asked / follow-up: it explains how for...of works and unlocks lazy/streaming patterns. Follow-up: “how do you make your own object iterable?” — give it a [Symbol.iterator]() method (often itself a generator).
Source: MDN — Iteration protocols.
What's the difference between for...of and for...in?
Mid
for...of iterates the values of an iterable (arrays, strings, Maps, Sets). for...in iterates the enumerable property keys of an object — including inherited ones — and on an array gives you index strings, not elements. Rule of thumb: for...of for arrays/iterables, for...in only for plain-object keys (and even then, prefer Object.keys).
const arr = ["a", "b"]; for (const v of arr) {} // "a", "b" — values for (const k in arr) {} // "0", "1" — index strings (+ any added keys) for (const k of Object.keys(obj)) {} // safe way to walk an object's own keys
Why it's asked / follow-up: using for...in on an array is a classic bug. Follow-up: “why can for...in surprise you?” — it walks the prototype chain's enumerable keys too, so a polyfill on Array.prototype would show up.
Source: MDN — for...in / for...of.
8 · DOM & browser events
Why use addEventListener instead of onclick?
Mid
An onclick property holds a single handler — assigning a new one overwrites the old. addEventListener lets you attach multiple handlers, choose the capture vs. bubble phase, pass options like { once: true } or { passive: true }, and removeEventListener later. It also keeps behavior out of HTML attributes.
function onClick() { /* ... */ } btn.addEventListener("click", onClick); btn.addEventListener("click", logIt); // both run — not overwritten btn.removeEventListener("click", onClick); // needs the same reference
Why it's asked / follow-up: it's the baseline of DOM event handling. Follow-up: “why can't you remove an inline arrow handler?” — removeEventListener needs the exact same function reference, which an inline arrow doesn't give you.
Source: MDN — EventTarget.addEventListener.
Explain event bubbling vs. capturing, and stopPropagation vs. preventDefault.
Mid
A dispatched event travels in three phases: capture (document down to the target), target, then bubble (target back up). Handlers run on bubble by default; pass { capture: true } to run on the way down. event.stopPropagation() halts that travel (other elements' handlers won't fire); event.preventDefault() cancels the browser's default action (following a link, submitting a form) but lets propagation continue. They're independent.
form.addEventListener("submit", e => { e.preventDefault(); // stop the page reload, keep handling the event }); child.addEventListener("click", e => { e.stopPropagation(); // parent's click handler won't see this });
Why it's asked / follow-up: conflating the two methods is extremely common. Follow-up: “what does stopImmediatePropagation add?” — it also stops other handlers on the same element from running, not just ancestors.
Source: MDN — Event.stopPropagation / preventDefault.
What is event delegation, and why does it scale? Senior
Event delegation attaches a single listener to a common ancestor and uses event.target to figure out which descendant was acted on — relying on bubbling. It scales because you avoid N listeners on N children, and it automatically covers elements added after the listener was attached (which per-element handlers wouldn't).
list.addEventListener("click", (e) => { const item = e.target.closest("li"); if (!item) return; // click wasn't on/inside an <li> select(item.dataset.id); // works for items added later, too });
Why it's asked / follow-up: it's the standard answer to “how do you handle clicks on a dynamic list efficiently.” Follow-up: “why closest() rather than checking e.target directly?” — the click may land on a child element inside the row, so you walk up to the row you care about.
Source: MDN — Event delegation.
What is the difference between debounce and throttle? Mid
Both limit how often a handler runs on a high-frequency event (scroll, resize, input). Debounce waits until the event has stopped for N ms, then fires once — ideal for search-as-you-type. Throttle fires at most once per N ms during a burst — ideal for scroll position updates.
function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); // reset the timer on each call t = setTimeout(() => fn(...args), ms); // fire only after a quiet gap }; } search.addEventListener("input", debounce(query, 300));
Why it's asked / follow-up: it's a practical performance question with a tiny closure-based implementation interviewers like to see written. Follow-up: “which for a typeahead vs. a scroll spy?” — debounce the typeahead, throttle the scroll spy.
Source: MDN — Scroll event throttling.
9 · Modules & tooling
What's the difference between ES modules and CommonJS? Mid
ES modules (ESM) — the standard import/export syntax — are static (bindings resolved before execution, enabling tree-shaking), the imports are live read-only bindings, and they load asynchronously. CommonJS — Node's older require/module.exports — is dynamic and synchronous, and require returns a copied value. ESM is the standard for browsers and modern Node; CommonJS remains widespread in the Node ecosystem.
// ES modules (standard) import { sum } from "./math.js"; export function sum(a, b) { return a + b; } // CommonJS (Node legacy) const { sum } = require("./math"); module.exports = { sum };
Why it's asked / follow-up: the ESM/CJS split is a daily friction in Node projects. Follow-up: “why can't you require an ESM file directly?” — ESM is async and statically analyzed; the interop is via dynamic import() or .mjs/"type": "module".
Source: MDN — JavaScript modules. Standardized in ES2015 (ES6).
What's the difference between a default and a named export? Mid
A module can have any number of named exports (imported by their exact name in braces) and at most one default export (imported under any name you choose, no braces). Named exports help tooling and refactors because the names are fixed; defaults are convenient for a module's single main thing.
// exporter export const PI = 3.14159; // named export default function area(r) {} // default // importer import area, { PI } from "./circle.js"; // default + named, renamable
Why it's asked / follow-up: import/export mistakes are a routine source of build errors. Follow-up: “default or named — which do you prefer?” — many teams favor named exports for grep-ability and consistent naming; there's no single right answer, just trade-offs.
Source: MDN — export.
What is dynamic import(), and when would you use it?
Senior
Static import runs at the top of a module and loads eagerly. Dynamic import() is a function-like form that returns a promise for the module, so you can load code on demand — behind a route, a click, or a feature flag. That's the basis of code splitting: ship a small initial bundle and pull in the rest only when needed.
button.addEventListener("click", async () => { const { renderChart } = await import("./chart.js"); // loaded on click renderChart(data); });
Why it's asked / follow-up: lazy-loading is central to modern web performance. Follow-up: “where can static import not go?” — it can't be conditional or inside a function; that's exactly the gap dynamic import() fills.
Source: MDN — import() (dynamic import). Standardized in ES2020.
Why do bundlers and transpilers exist? Senior
A transpiler (Babel, the TypeScript compiler, swc) rewrites newer or non-standard syntax into a form older engines understand — modern JS down-leveled, JSX into function calls, TypeScript stripped to JS. A bundler (Vite/Rollup, esbuild, webpack) follows the import graph and combines many modules into a few optimized files, doing tree-shaking, minification, and code-splitting along the way. Native ESM reduced the need for bundling in dev, but production builds still benefit from the optimization passes.
// What a transpiler down-levels (modern → broadly supported): const f = (x) => x?.value ?? 0; // optional chaining + nullish coalescing // → var f = function (x) { ... } (ES5-compatible output)
Why it's asked / follow-up: it probes whether you understand the build pipeline you rely on daily. Follow-up: “is TypeScript a transpiler?” — its compiler is; it type-checks and emits JS (see the TypeScript version reference).
Source: MDN — JavaScript modules.
10 · Classic gotchas
Why is 0.1 + 0.2 !== 0.3?
Mid
JavaScript numbers are IEEE-754 double-precision floats (it has just one number type). Values like 0.1 and 0.2 can't be represented exactly in binary, so their sum is a hair off 0.3. This is not a JS bug — it's how binary floating point works everywhere. Compare with a tolerance (Number.EPSILON), or use integers (cents) or a decimal library for money.
0.1 + 0.2; // 0.30000000000000004 0.1 + 0.2 === 0.3; // false Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true — compare with tolerance
Why it's asked / follow-up: it checks whether you understand the single float number type and never compute money in floats. Follow-up: “how do you handle currency?” — integer minor units (cents) or a decimal/BigInt-based approach.
Source: MDN — Number.EPSILON.
Why is typeof null === "object"?
Mid
It's a bug from the very first JavaScript implementation: values were tagged by a few low bits, the object tag was 0, and null was the null pointer (all zero bits) — so it read as an object. A fix was proposed and rejected because it would break too much existing code, so it's enshrined forever. Check for null explicitly, not via typeof.
typeof null; // "object" ← the historical bug // correct null check: value === null; // "is it a real object?" guard: value !== null && typeof value === "object";
Why it's asked / follow-up: it's a JS-trivia favorite that also has a practical guard attached. Follow-up: “how do you test for a plain object then?” — rule out null first, then typeof x === "object" (and often !Array.isArray(x)).
Source: MDN — typeof null.
Why do all the callbacks in this var loop log the same number?
Senior
With var there is one shared i for the whole function, and every closure captures that same variable. By the time the timeouts run, the loop has finished and i holds its final value. The modern fix is let, which creates a fresh binding per iteration.
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // 3, 3, 3 — one shared i } for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // 0, 1, 2 — fresh i each iteration }
Why it's asked / follow-up: it's the canonical closures-plus-scope question and the best argument for let. Follow-up: “how was this solved before let?” — an IIFE per iteration capturing i by value, or forEach (whose callback gets its own parameter).
Source: MDN — Closures in loops.
Why does passing a method as a callback “lose” this?
Mid
Because this is set by the call site, not the definition. obj.method is just a function reference; once it's detached — handed to setTimeout, an event listener, or map — it's called plain, so this is undefined (strict) or the global object. Fix it by binding or wrapping in an arrow that preserves the outer this.
const counter = { n: 0, inc() { this.n++; } }; setTimeout(counter.inc, 0); // `this` is lost → NaN / error setTimeout(counter.inc.bind(counter), 0); // bound — works setTimeout(() => counter.inc(), 0); // arrow keeps the call — works
Why it's asked / follow-up: it's the practical payoff of the “how is this determined” question. Follow-up: “how do class components avoid it?” — class fields with arrow functions, or binding in the constructor.
Source: MDN — this.
Why is [] == ![] true?
Senior
It's coercion stacked on coercion. ![] is false (a non-empty object is truthy, so its negation is false). Now [] == false: == converts the boolean to 0, then converts the array to a primitive — [] becomes "", which becomes 0. So it's 0 == 0 → true. The lesson isn't to memorize the rules; it's to use === so none of this happens.
![]; // false (arrays are truthy) [] == ![]; // true ([]→""→0, false→0) [] === ![]; // false — strict equality: no coercion "1" + 1; // "11" (+ prefers string concat) "1" - 1; // 0 (- forces numeric)
Why it's asked / follow-up: it's a stress-test of the coercion rules — and the “just use ===” takeaway is the point. Follow-up: “why does + concatenate but - subtract on strings?” — + is overloaded for strings and prefers concatenation; the other arithmetic operators always coerce to number.
Every answer links its primary source inline — MDN Web Docs and the ECMA-262 specification (TC39). The questions are a curated set of the topics a JavaScript interviewer commonly covers, not a copy of any question bank. Language-feature details cross-link to the JavaScript version reference; typing and tooling notes cross-link to the TypeScript version reference. Last updated June 2026.
Mungomash LLC · More on JavaScript