JavaScript Interview Questions
15 JavaScript questions — closures, the event loop, this binding, prototypal inheritance, and async/await. The concepts frontend and full-stack interviewers probe hardest. Tagged for FAANG and startups.
var, let, and const?JS▾| var | let | const | |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisting | Hoisted, init undefined | Hoisted to TDZ | Hoisted to TDZ |
| Reassign | Yes | Yes | No |
| Redeclare | Yes | No | No |
let/const are hoisted but not initialised. Accessing them before their declaration throws ReferenceError rather than returning undefined. Default to const, use let when reassigning, avoid var.== and ===?JS▾== (loose) coerces both operands to the same type before comparing. === (strict) compares value and type with no coercion.
1 == "1" // true — string coerced to number 1 === "1" // false — different types 0 == false // true null == undefined // true — special-cased null === undefined // false NaN === NaN // false — NaN is never equal to anything
=== unless you have a specific reason to want coercion. It avoids a whole class of subtle bugs.function makeCounter() { let count = 0; // private — no outside access return () => ++count; } const c = makeCounter(); c(); // 1 c(); // 2 — count persists between calls
this work? How do arrow functions change it?JS▾this is determined by how a function is called, not where it's defined:
obj.method()→thisisobj- Plain call
fn()→undefinedin strict mode (global object otherwise) fn.call(x)/apply/bind→ explicitly whatever you passnew Fn()→ the newly created object
this of their own — they capture it lexically from the enclosing scope. That makes them ideal for callbacks but wrong for object methods that need their own this.
const obj = { name: "Ada", greet() { return `Hi, ${this.name}`; } }; const fn = obj.greet; fn(); // "Hi, undefined" — lost its receiver obj.greet.bind(obj)(); // "Hi, Ada"
document.all historically): false, 0, "" (empty string), null, undefined, and NaN.
[], {}, "0", and "false" are all truthy. An empty array in a boolean context is true. Also note 0 == false is true but [] == false is also true due to coercion.function mutate(x, obj) { x = 99; // local copy — caller unaffected obj.val = 99; // same object — caller sees it obj = {}; // reassigns local ref only } let n = 1, o = { val: 1 }; mutate(n, o); n; // 1 o.val; // 99
{...obj} (shallow) or structuredClone(obj) (deep).
let different from var?JS▾Declarations are processed before any code runs. A var is created and initialised to undefined, so reading it early gives undefined. A let or const is created but left uninitialised — the temporal dead zone — and reading it early throws.
console.log(a); var a = 1; // undefined console.log(b); let b = 1; // ReferenceError: Cannot access 'b' before initialization
Function declarations are hoisted whole and callable before their line; function expressions assigned to a variable follow that variable’s rules.
null and undefined?JS▾undefined is the absence the language produces: an unassigned variable, a missing parameter, a property that is not there, a function with no return. null is the absence a programmer writes deliberately.
typeof undefined // "undefined" typeof null // "object" — a bug kept for compatibility null == undefined // true (loose) null === undefined // false (strict)
Default parameters and ?? treat only null and undefined as missing, which is what makes them safer than || when 0 or "" is a legitimate value.
Spread and Object.assign copy one level: nested objects are still shared references, so mutating a nested value shows up in both copies.
const shallow = { ...original };
const deep = structuredClone(original); // modern, handles Dates, Maps, cycles
const crude = JSON.parse(JSON.stringify(original)); // loses Dates, undefined, functions
structuredClone is the right default now. The JSON round-trip is still common but silently mangles anything that is not plain JSON.
Array.prototype.map give you that forEach does not?JS▾map returns a new array of the results, so it composes: xs.map(f).filter(g). forEach returns undefined and exists only for side effects.
map for side effects and ignoring the result allocates an array nobody reads — a reviewer reads it as a mistake. Using forEach to build an array by pushing is the same mistake in reverse.Neither can be broken out of early. When you need that, use a for...of, or some/find which short-circuit by design.
slice and splice?JS▾slice(start, end) returns a copy of a section and leaves the original alone. splice(start, count, ...items) mutates the array in place, removing and inserting, and returns what it removed.
const xs = [1, 2, 3, 4]; xs.slice(1, 3); // [2, 3] xs unchanged xs.splice(1, 2); // [2, 3] xs is now [1, 4]
Same story across the array methods: sort, reverse and fill mutate; concat, flat and the newer toSorted/toReversed do not.
- Microtasks: Promise callbacks (
.then),queueMicrotask,MutationObserver - Macrotasks:
setTimeout,setInterval, I/O, UI rendering
console.log("1"); setTimeout(() => console.log("4"), 0); // macrotask Promise.resolve().then(() => console.log("3")); // microtask console.log("2"); // Output: 1, 2, 3, 4
setTimeout(fn, 0) queued earlier.- Pending — initial, outcome unknown
- Fulfilled — resolved with a value, triggers
.then() - Rejected — failed with a reason, triggers
.catch()
.catch() for the whole chain.
async/await and how does it relate to Promises?JS▾async/await is syntactic sugar over Promises. An async function always returns a Promise; await pauses the function until a Promise settles, without blocking the thread.
async function load(id) { try { const res = await fetch(`/api/${id}`); if (!res.ok) throw new Error(res.status); return await res.json(); } catch (err) { console.error(err); return null; } }
await inside a loop runs sequentially. For concurrency, collect Promises and await Promise.all(...).Promise.all, allSettled, race, and any.JS▾| Method | Resolves when | Rejects when |
|---|---|---|
all | Every promise fulfills | Any single one rejects (fail-fast) |
allSettled | All settle | Never — always resolves |
race | First settles (either way) | If the first to settle rejects |
any | First fulfills | All reject (AggregateError) |
all when you need every result, allSettled when partial failure is acceptable, race for timeouts, and any for "first success wins".
await?JS▾You get the Promise itself, not its value, and the function continues immediately. If the Promise later rejects, nothing is handling it — an unhandled rejection that crashes Node or logs an error nobody attributes to the right line.
const user = getUser(); // a Promise, not a user
if (user.isAdmin) { /* undefined: always falsy */ }
await Promise.all([a(), b()]); // both start, then both awaited
Starting the calls first and awaiting after is how you run work concurrently on purpose — awaiting each in turn serialises them for no reason.
try/catch miss?JS▾try/catch around an await catches a rejection. It does not catch a rejection from a Promise you never awaited, nor one thrown inside a callback that runs later, such as a setTimeout body.
try { await risky(); } catch (e) { /* caught */ }
try { risky(); } catch (e) { /* NOT caught: no await */ }
setTimeout(() => { throw new Error(); }, 0); // NOT caught
For fire-and-forget work, attach .catch() explicitly. At the process edge, listen for unhandledrejection so nothing fails silently.
JavaScript runs one piece of code at a time, but await yields — anything can run between the start and end of an async function. Two overlapping calls can interleave and clobber shared state.
let cache = null;
async function get() {
if (!cache) cache = await fetchIt(); // two callers both see null and both fetch
return cache;
}
// fix: cache the Promise, not the value
let inflight = null;
function get() { return inflight ??= fetchIt(); }
The general shape of the fix is to make the in-progress operation itself the thing you store, so the second caller joins the first instead of starting a second.
Promises are not cancellable, so you cancel the operation behind them with an AbortController. fetch takes its signal and rejects with an AbortError.
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal })
.catch(e => { if (e.name !== 'AbortError') throw e; });
ctrl.abort(); // e.g. the user typed again, or the component unmounted
This is what stops a stale search response from overwriting a newer one, and what prevents a state update after a component has gone away.
setTimeout(fn, 0) and queueMicrotask(fn)?JS▾queueMicrotask runs in the microtask queue, which drains completely before the next macrotask — the same queue Promise callbacks use. setTimeout(fn, 0) schedules a macrotask, so it runs after the current microtask queue empties and after rendering has had its chance.
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('micro'));
console.log('sync');
// sync, promise, micro, timeout
setTimeout when you actually want to yield to the browser.[[Prototype]]) to another object. When you access a property JS can't find on the object, it walks up this prototype chain until it finds the property or reaches null. This is how methods are shared without copying them onto every instance.
class Animal { speak() { return "..."; } } const dog = new Animal(); dog.speak(); // found on Animal.prototype Object.getPrototypeOf(dog) === Animal.prototype; // true
class syntax is sugar over this same mechanism — JS has no separate class-based system underneath.
debounce. How does it differ from throttle?JS▾function debounce(fn, delay) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } // input.addEventListener("input", debounce(search, 300));
list.addEventListener("click", (e) => { if (e.target.matches(".item")) { console.log("clicked", e.target.textContent); } });
??) and optional chaining (?.) operators?JS▾a ?? b— returnsbonly whenaisnullorundefined(not for other falsy values like0or"", unlike||).obj?.a?.b— short-circuits toundefinedif any link isnull/undefined, instead of throwing.
0 || "default" // "default" — 0 is falsy 0 ?? "default" // 0 — only null/undefined trigger fallback user?.address?.city // undefined instead of TypeError
const add = (a) => (b) => (c) => a + b + c; add(1)(2)(3); // 6 const add5 = add(5); // partial application add5(10)(20); // 35
A function* can pause at yield and resume where it left off, producing values lazily. That makes infinite or expensive sequences cheap — you compute only what is consumed.
function* ids() { let i = 0; while (true) yield ++i; }
const it = ids();
it.next().value; // 1
it.next().value; // 2
They are the machinery under async/await, and are still the clean way to write custom iterables, paginate an API lazily, or walk a tree without building the whole list.
ES modules are static: import/export are analysed before execution, which is what makes tree-shaking and circular-import detection possible. CommonJS require() is a synchronous function call resolved at runtime, so the dependency graph is only known by running the code.
// ESM — hoisted, live bindings, always strict mode
import { helper } from './helper.js';
export const value = 1;
// CJS — runtime, copies the value at require time
const { helper } = require('./helper');
module.exports = { value: 1 };
ESM bindings are live: reassign an export and importers see the new value. A CommonJS destructure captures a snapshot.
Caching a pure function’s result by its arguments, so a repeat call is a lookup. It pays when the function is expensive and called repeatedly with the same inputs.
const memo = (fn, cache = new Map()) => (...args) => {
const k = JSON.stringify(args);
if (!cache.has(k)) cache.set(k, fn(...args));
return cache.get(k);
};
Map keyed on user input grows forever; bound it, or use a WeakMap keyed on objects so entries can be collected.call, apply and bind.JS▾All three set this. call invokes immediately with arguments listed; apply invokes immediately with arguments as an array; bind invokes nothing and returns a new function with this fixed.
greet.call(user, 'hi'); greet.apply(user, ['hi']); const bound = greet.bind(user); // call later
bind is what makes a method safe to pass as a callback in code that predates arrow functions. Note an arrow function ignores all three — its this is captured from the enclosing scope and cannot be reassigned.
Map and a plain object, and between Set and an array?JS▾Map takes any value as a key (including objects), keeps insertion order, reports size directly, and has no prototype keys to collide with. An object coerces every key to a string and inherits from Object.prototype.
const m = new Map([[objKey, 1]]); m.get(objKey); // 1 — an object as a real key const s = new Set([1, 1, 2]); s.has(1); // O(1); array.includes is O(n)
Reach for Map when keys are dynamic or not strings, and Set for membership tests and de-duplication. A plain object is still right for a fixed, known shape — and it is the only one of the four that serialises to JSON directly.