30% offEnding soon

JavaScript Interview Questions: What They Actually Ask

24 min read

JavaScript interview questions come from five different rounds, and each round is graded differently. Recall questions ("what is a closure") and output-prediction questions ("what does this log") are scored on precision of wording. Implementation questions, component builds and framework follow-ups are scored on edge cases and cleanup, which is why a reduce polyfill that works on [1, 2, 3] still fails the test file: [].reduce((a, b) => a + b) has to throw a TypeError, map has to keep the holes in a sparse array, and a bound function has to ignore its bound this when it is called with new. This article walks all five rounds, publishes the assertions each implementation gets checked against, and flags the standard answers that ES2023 through ES2025 have made out of date.

The five rounds a JavaScript interview actually runs

Sort every JavaScript question you have been asked into five groups and preparation stops being one undifferentiated pile. These are the rounds the questions come from, roughly in the order a loop runs them.

  1. Recall. A definition question with a thirty-second answer. "What is a closure." "Difference between null and undefined." Usually a phone screen, often asked while the interviewer is still reading your CV.
  2. Output prediction. A short snippet and one question: what does this log. The answer almost always turns on a specification detail rather than on logic.
  3. Implement the built-in. Write debounce, or a polyfill of Array.prototype.reduce, Function.prototype.bind or Promise.all, from scratch, with no library.
  4. Build a component. An accordion, a clock, a typeahead, in twenty-five to forty minutes, in a real editor, usually with the page visible.
  5. Framework follow-up. The same component or the same cleanup problem, asked again in React, Vue or Angular, to see whether you understand the reactivity model or just the syntax.

The split matters because the failure modes are different. Rounds 1 and 2 fail on precision: you knew roughly what a closure was, and roughly was not enough, or you knew sort was "weird with numbers" without being able to say what it actually does. Rounds 3, 4 and 5 fail somewhere else entirely. Your code runs, your happy path works, and then the empty array throws, or the interval keeps ticking after the component is gone, or the second click on the accordion header leaves two panels open. Reading answers fixes rounds 1 and 2. Only writing code and running it against assertions fixes the other three.

One caveat on the frame itself: this is a teaching structure drawn from the kinds of questions that get asked, not a surveyed industry standard. Plenty of loops merge rounds 3 and 4, or skip round 5 when you are staying on the same stack.

Round 1: definitions you should answer in thirty seconds

Give the definition, then the sentence that proves you have hit the thing in practice. That second sentence is what separates a memorised answer from an understood one.

A closure is a function together with the variables from the scope where it was defined, kept alive after that scope has returned. The follow-up is the classic loop: var has one binding shared by every iteration, let gets a fresh binding per iteration, so three callbacks queued in a for loop log 3, 3, 3 with var and 0, 1, 2 with let.

Hoisting and the temporal dead zone are two different things and interviewers listen for that. Function declarations and var bindings are created before the code in their scope runs, with var initialised to undefined. let, const and class bindings are created too, but they stay uninitialised until execution reaches the declaration, and touching them before that point throws. The tell is typeof, which is normally the safe operator:

console.log(typeof neverDeclared);

try {
  typeof tooEarly;
} catch (err) {
  console.log(err.constructor.name);
}

let tooEarly = 1;
undefined
ReferenceError

typeof on a name that was never declared gives "undefined". typeof on a let binding before its initialiser throws a ReferenceError, because of the temporal dead zone.

== applies type coercion before comparing and === does not. null == undefined is true, null === undefined is false, and NaN is not equal to itself under either operator. Say you use === everywhere except the deliberate x == null check for "null or undefined".

There are four ways this gets bound in a normal function: the default binding when the function is called bare, the implicit binding to whatever is before the dot, the explicit binding from call, apply or bind, and the new binding when the function is called with new. Arrow functions sit outside all four. They have no own this, arguments, super or new.target, so this comes from the enclosing scope, call, apply and bind cannot change it, and they cannot be constructed:

const Point = (x, y) => ({ x, y });

try {
  new Point(1, 2);
} catch (err) {
  console.log(err.constructor.name);
}
console.log('prototype' in Point);
TypeError
false

Prototypal inheritance: every object has an internal link to another object, and a property lookup that misses walks that chain. A class declaration creates a constructor function whose methods live on its .prototype object, and calling that constructor without new throws a TypeError.

Event delegation is one listener on a shared ancestor instead of one per row, using event.target to work out what was actually clicked. It works because events bubble from the target up through its ancestors, after a capture phase that runs top down first. Delegation also survives rows being added later, which is the real reason to use it.

null is an assigned "no value", undefined is the absence of one: an unassigned variable, a missing property, a parameter you did not pass. JSON.stringify drops undefined properties from objects.

For Map versus a plain object: Map keys can be any value, an object's keys are strings or symbols only. Map iterates in insertion order and reports .size directly, and it has no default keys (it contains only what you put in it), so user-supplied keys cannot collide with inherited names like constructor, which is the collision an object gets from Object.prototype and which Object.create(null) avoids. MDN notes Map is optimised for frequent additions and removals.

A WeakMap takes objects, or non-registered symbols, as keys and holds them weakly, so being a key does not keep an object alive. It has no .size and cannot be iterated, and that is by design: exposing its keys would let you observe garbage collection.

Round 2: output prediction, and why the answer is usually a spec detail

These snippets are short on purpose. The interviewer is not testing whether you can trace control flow, they are testing whether you know one specific piece of behaviour, so answer with the rule and not just the value.

typeof null returns "object". The reason is historical: in the first JavaScript implementation, values were stored as a type tag plus a value, objects carried tag 0, and null was the null pointer 0x00, which read as tag 0. A fix that would have made typeof null === "null" was proposed for ECMAScript and rejected.

Default sort is the one everyone half-remembers:

console.log([1, 30, 4, 21, 100000].sort().join(' '));

const nums = [1, 30, 4, 21, 100000];
console.log(nums.sort() === nums);
1 100000 21 30 4
true

With no comparator, sort converts each element to a string and compares UTF-16 code unit sequences, which is why 100000 lands second. It sorts in place and returns a reference to the same array, and it has been required to be stable since ECMAScript 2019, so equal elements keep their original relative order.

Equality of NaN and the limits of a double show up as a pair:

console.log([1, 2, NaN].indexOf(NaN));
console.log([1, 2, NaN].includes(NaN));
console.log([1, , 3].includes(undefined));
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2);
-1
true
true
true

indexOf uses strict equality, and NaN === NaN is false, so it never finds it. includes uses SameValueZero, which treats NaN as matching itself. The third line is a different rule again: includes reads empty slots as undefined. And Number.MAX_SAFE_INTEGER is 9007199254740991, or 2^53 − 1, because a double has 52 stored mantissa bits plus an implicit leading 1, giving 53 bits of integer precision, so adding 1 and adding 2 to it produce the same value.

The last staple of this round is ordering across the microtask and task queues. Promise callbacks are handled as microtasks, setTimeout callbacks as tasks, and a function passed to then() is never called synchronously, even when the promise is already resolved:

setTimeout(() => console.log(4), 0);

Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));

console.log(1);
1
2
3
4

Synchronous code finishes first. Then the microtask queue drains completely, including microtasks queued by other microtasks, which is how 3 gets ahead of 4. Only then does the timer callback run.

logs1. Run the scriptconsole.log(1)12. Drain microtasks.then(() => log(2))2.then(() => log(3))33 is queued by 2, and still runs3. Run one tasksetTimeout(fn, 0)4next turn
One turn of the loop: the script, then every microtask, then one task.

Round 3: implement the built-in, and the rubric nobody publishes

Your code is judged by assertions, so prepare against assertions. Every question below is listed with the behaviour a passing implementation needs, which is normally the part the written explanations leave out.

Array.prototype.reduce has three graded behaviours, and the first one is a throw:

try {
  [].reduce((a, b) => a + b);
} catch (err) {
  console.log(err.constructor.name);
}

console.log([1, 2, , 4].reduce((a, b) => a + b));
console.log([1, 2, undefined, 4].reduce((a, b) => a + b));
TypeError
7
NaN

An empty array with no initialValue throws a TypeError. Empty slots are skipped rather than visited as undefined, which is why the sparse array sums to 7 and the explicit-undefined array poisons the accumulator. The callback also receives four arguments: accumulator, currentValue, currentIndex and the array itself.

[1, 2, , 4]reduce0123124holeskipped7index 2 does not exist, length is 4[1, 2, undefined, 4]0123124undefinedvisitedNaNindex 2 holds undefined, length is 4
A hole is a missing index; undefined is a value sitting in one.

Array.prototype.map skips holes as well, but it keeps them in the result:

const doubled = [1, , 3].map((x) => x * 2);

console.log(doubled.length);
console.log(1 in doubled);
console.log(doubled[0], doubled[2]);
3
false
2 6

A polyfill that writes result[i] = undefined for the hole passes a length check and fails 1 in doubled. See Array.prototype.find for the same shape of problem with a shorter implementation.

Function.prototype.bind is the one that ends rounds, because the follow-up is new:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

const BoundPoint = Point.bind({ nope: true }, 1);
const p = new BoundPoint(2);

console.log(p.x, p.y);
console.log(BoundPoint.name);
console.log(Point.length, BoundPoint.length);
1 2
bound Point
2 1

Constructing a bound function ignores the bound this entirely, while the bound arguments are still prepended. The name gets a "bound " prefix, and length is the target's length minus the number of bound arguments, floored at 0. call and apply are the easy pair next to it: call takes the arguments as a list, apply takes them as a single array-like, and most bind polyfills lean on apply internally. There is a dedicated Function.prototype.apply question if you want to write that one first.

Promise.all is graded on order and on what happens after the first failure:

const slow = new Promise((resolve) => setTimeout(() => resolve('slow'), 20));
const quick = Promise.resolve('quick');

Promise.all([slow, quick]).then((values) => console.log(values.join(',')));
slow,quick

Results come back in input order, never completion order. An implementation that pushes into an array as each promise settles will pass a test with equal delays and fail this one.

Promise.all([
  Promise.reject(new Error('first')),
  Promise.reject(new Error('second')),
]).catch((err) => console.log('caught', err.message));
caught first

The first rejection wins, and later rejections are ignored: they do not fire an unhandledrejection event. Promise.all([]) is a separate assertion, because an empty iterable produces an already-fulfilled promise synchronously, while a non-empty one always fulfils asynchronously.

The siblings each have their own rubric. Promise.any ignores rejections and settles on the first fulfilment, and if everything rejects it rejects with an AggregateError whose errors property holds the reasons:

Promise.any([
  Promise.reject(new Error('a')),
  Promise.reject(new Error('b')),
]).catch((err) => console.log(err.constructor.name, err.errors.length));
AggregateError 2

Promise.race settles on the first promise to settle either way, which is the distinction from any. Promise.allSettled never rejects: it fulfils with one object per input, { status: "fulfilled", value } or { status: "rejected", reason }.

The remaining classics, with the assertion that usually catches people:

  • Deep clone. Cycles must not blow the stack, and Date, Map, Set and arrays need their types back. Mention structuredClone: it handles circular references, but it throws a DataCloneError on functions and DOM nodes and it does not preserve prototypes, so a class instance comes back as a plain object.

  • Deep equal. NaN equal to NaN, +0 distinguished from -0 or explicitly not, Date and RegExp compared by value, and a cycle guard.

  • debounce. The options worth knowing are the ones lodash documents: leading (default false), trailing (default true) and maxWait, plus cancel() and flush() on the returned function. Ask which of those are in scope before you start.

  • throttle. State whether the trailing call fires, and preserve the last arguments if it does.

  • curry. f(1)(2)(3) and f(1, 2)(3) must both work, and fn.length is how you know when to invoke:

    function curry(fn) {
      return function curried(...args) {
        if (args.length >= fn.length) return fn(...args);
        return (...rest) => curried(...args, ...rest);
      };
    }
    
    const add = (a, b, c) => a + b + c;
    const curried = curry(add);
    
    console.log(curried(1)(2)(3));
    console.log(curried(1, 2)(3));
    console.log(curried(1)(2, 3));
    6
    6
    6
    
  • memoize. Say out loud what your cache key is. JSON.stringify on the argument list breaks on functions and key order; a Map keyed on the first argument is fine if you say that is the constraint. Caching a rejected promise forever is a real bug.

  • Event emitter. on, off, once, emit. Iterate over a copy of the listener array so that a listener removing itself mid-emit does not skip the next one, make once remove itself, and decide what emit returns. The two assertions:

    class Emitter {
      constructor() {
        this.listeners = new Map();
      }
      on(event, fn) {
        if (!this.listeners.has(event)) this.listeners.set(event, []);
        this.listeners.get(event).push(fn);
        return this;
      }
      off(event, fn) {
        const fns = this.listeners.get(event);
        if (!fns) return this;
        const i = fns.indexOf(fn);
        if (i > -1) fns.splice(i, 1);
        return this;
      }
      once(event, fn) {
        const wrapper = (...args) => {
          this.off(event, wrapper);
          fn(...args);
        };
        return this.on(event, wrapper);
      }
      emit(event, ...args) {
        const fns = this.listeners.get(event);
        if (!fns) return false;
        for (const fn of [...fns]) fn(...args);
        return true;
      }
    }
    
    const bus = new Emitter();
    const seen = [];
    const a = () => { seen.push('a'); bus.off('tick', a); };
    const b = () => seen.push('b');
    const c = () => seen.push('c');
    bus.on('tick', a);
    bus.on('tick', b);
    bus.on('tick', c);
    bus.emit('tick');
    console.log(seen.join(','));
    
    let n = 0;
    bus.once('ping', () => n++);
    bus.emit('ping');
    bus.emit('ping');
    console.log(n);
    a,b,c
    1
    
  • Flatten. flat() defaults to depth 1, flat(Infinity) goes all the way down, and it removes empty slots:

console.log([1, [2, [3, [4]]]].flat().length);
console.log([1, [2, [3, [4]]]].flat(Infinity).join(','));
console.log([1, , 3].flat().length);
3
1,2,3,4
2

JSON.stringify and JSON.parse are the two heavyweight versions of this round, and String to Number (parseInt) is the small one that hides more edge cases than it looks like it should.

Round 4: build a component under a timer

This is the round where a working developer is most likely to run out of time, and the one the question lists skip. You get twenty-five to forty minutes and a brief: an accordion, tabs, a star rating, a digital or analog clock, a typeahead with debounced search, a modal with a focus trap, infinite scroll, a todo list.

What gets scored first is the state shape, because everything else follows from it. An accordion that allows one open panel wants a single open index (with null for all-closed), and one that allows several wants a Set of open ids. Pick one, say which brief you are assuming, and the rest of the code writes itself. Star rating has the same fork: a committed value plus a transient hoverValue, and if you keep only one you will find out when the mouse leaves.

That is checkable in the same shape as round 3. For the single-open accordion:

click(header(1));
console.log(panel(1).hidden, header(1).getAttribute('aria-expanded'));
click(header(1));
console.log(panel(1).hidden);
click(header(2));
console.log(document.querySelectorAll('[aria-expanded="true"]').length);
false true
true
1

The second click collapsing the panel it opened is the assertion most half-built accordions fail, and aria-expanded belongs on the header, not the panel. The clock has one assertion of its own: unmount it, wait past a tick, and nothing logs, because clearInterval ran on teardown.

Accessibility is checkable in seconds and interviewers do check it. The W3C ARIA Authoring Practices accordion pattern puts an element with role="button" in each header, wraps that button in an element with role="heading" carrying an aria-level that suits the page (a real <h3> is the simplest way), sets aria-expanded on the button to reflect whether the panel is visible, and points aria-controls at the id of the panel. Using a real <button> gives you Enter and Space handling for free. Arrow key navigation between headers is optional in that pattern, so do not burn time on it before the core behaviour works. The FAQ Disclosure build is exactly this pattern at its smallest.

Teardown is the second thing scored, and it is where the clock questions get their teeth. setInterval for a clock must be cleared when the component goes away, listeners added on window must be removed, an IntersectionObserver for infinite scroll should be disconnected, and in-flight requests want an AbortController so a stale response cannot overwrite fresh state. Typeahead has one more trap on top: even with a debounce, an earlier slow request can land after a later fast one, so either cancel the previous request or ignore responses that no longer match the current query. useEventListener and Job Board are the two builds where teardown is most of the grade, and Modal Dialog II is where focus handling is: move focus into the dialog, keep Tab inside it, close on Escape, and return focus to the element that opened it.

time"re"sentreply 2"reac"sentreply 1reply 2 overwrites reply 1input says reac, results are for refix: abort it, or ignore stale replies
Two searches in flight; the older, slower reply lands last and wins.

Narrate while you type. "I am keeping a single index because the brief says one at a time" is a sentence that earns marks even if you later change your mind. Coded Interview Example walks a full thirty-five minute session if you want to see the pacing.

Round 5: the framework follow-up (React, Vue, Angular)

If you are moving between stacks, this is the round that tests whether the move is real. The interviewer takes the accordion or the clock you just built and asks for it again in their framework, and the question underneath is always one of three things.

Where does the state live. In React you reach for useState in the nearest common owner and pass values down. In Vue you reach for ref or reactive, and the same value can sit in the component or be lifted into a composable. Angular puts it in the component class or in a service. The wrong answer is not naming the wrong API, it is not being able to say why the state sits where you put it.

How does cleanup work. This is the same clock and the same clearInterval from round 4, expressed three ways: the function you return from React's useEffect, Vue's onUnmounted, Angular's ngOnDestroy. Interviewers ask this because a subscription that outlives its component is a bug that routinely reaches production, and because the answer shows whether you understand when your framework decides a component is finished.

What causes the update. This is the real probe, and it is the one you cannot look up mid-interview. React re-runs the component function when a useState or useReducer update schedules a render, and by default it re-renders the children below it. Vue tracks reads per ref and per property, so only the effects that read the changed value re-run. Angular runs change detection over the component tree, and signals narrow that to the consumers that actually read the signal. Syntax you can check in a document in ten seconds. The reactivity model is what gets tested.

The third thing they listen for is whether you can name the underlying DOM behaviour without the framework. If you can describe the accordion as a button toggling aria-expanded and a panel's visibility, and only then say how your framework expresses it, you have answered the question for all three at once. React interview questions, React JavaScript interview questions and Angular interview questions go round by round through each stack's version.

The modern JavaScript that interviewers have started asking about

Older question lists teach answers that a current interviewer will follow up on. Here is what has changed, using the publication years from TC39's finished-proposals list. Each of these has an old answer the lists still teach and a new one the interviewer is listening for: reduce into an object literal is now Object.groupBy, let resolve; const p = new Promise((r) => (resolve = r)); is now Promise.withResolvers(), and [...a].filter((x) => b.has(x)) is now a.intersection(b).

From 2022: .at() with negative indices, Object.hasOwn(obj, key) as the static replacement for Object.prototype.hasOwnProperty.call, error cause (new Error("failed", { cause: err })), and top-level await in modules.

From 2023: the copying array methods, toSorted, toReversed, with and toSpliced, plus findLast and findLastIndex. The copying methods are the direct answer to the sort mutation question in round 2:

const scores = [10, 9, 100];

console.log(scores.toSorted((a, b) => a - b).join(','));
console.log(scores.join(','));
9,10,100
10,9,100

From 2024: Promise.withResolvers(), which returns { promise, resolve, reject } and removes the let resolve dance from deferred code, array grouping via Object.groupBy and Map.groupBy, and ArrayBuffer transfer. Object.groupBy returns a null-prototype object keyed by group name and has been Baseline since March 2024; MDN suggests Map.groupBy when your keys are arbitrary values rather than strings.

From 2025: seven new Set methods (union, intersection, difference, symmetricDifference, isDisjointFrom, isSubsetOf, isSupersetOf), available across major browsers since June 2024. Also synchronous iterator helpers (map, filter, take, drop, flatMap, reduce, toArray, forEach, some, every, find on iterators, evaluated lazily, so they work on infinite generators), Promise.try for running a callback that might throw synchronously and getting a rejected promise either way, RegExp.escape for building a pattern out of user input, import attributes and JSON modules, RegExp modifiers, duplicate named capture groups, and Float16Array. ECMAScript 2025, the 16th edition of ECMA-262, was approved on 25 June 2025.

Two more worth having ready. structuredClone is the built-in deep clone, Baseline in browsers since March 2022 and a global in Node from v17.0.0, and its limits are the interesting part: circular references are fine, functions and DOM nodes throw a DataCloneError, and prototypes are not preserved. Temporal is the date and time replacement, and MDN currently marks it as limited availability, not Baseline, because it does not work in some of the most widely used browsers. Treat it as a "know it exists, use the polyfill" answer.

A practice loop that actually checks your answer

Weight your time towards the rounds that fail on edge cases. If you have two weeks, something like four days on rounds 1 and 2 combined, five days on round 3, four days on round 4, and one on the framework version of whatever you built.

The single habit that changes the implementation round: write the assertions before the implementation. Open the editor, and before a line of reduce, type the four things it must do. Throws on empty with no initial value. Skips holes. Four callback arguments. Returns the initial value untouched for an empty array when one is given. Then make them pass. You will find the gaps in five minutes instead of finding them in the interview.

Re-solve rather than re-read. Reading a solution you already understand feels like progress and produces almost none, because recognising a correct answer and generating one from an empty file are different skills, and only the second one is tested. Come back to the same question three days later with a blank editor and see whether the sparse-array branch still appears without prompting.

Say the answer out loud while you type. Rounds 4 and 5 are partly graded on narration, and the first time you try to talk and write simultaneously should not be with an interviewer watching.

That leaves verification, which is the part reading cannot give you. Running your attempt against the assertions above tells you which branch you missed and why, and that is what UIReady is built around: the same question in React, Vue, Angular and vanilla JavaScript, in a real editor, checked by real Jest tests, with a lifetime pass if you want the whole bank rather than a fortnight of it. Start with the JavaScript coding interview guide for round 3, or worked interview coding examples if you would rather see a graded attempt before making your own.

Frequently asked questions

What are the most common JavaScript interview questions?
They cluster into five rounds: definition recall (closures, `this`, hoisting versus the temporal dead zone), output prediction (`typeof null`, the default `sort` order, microtask ordering), implementing a built-in (debounce, deep clone, polyfills of `reduce`, `bind` and `Promise.all`), building a UI component under a timer (accordion, tabs, clock, typeahead), and a framework follow-up in React, Vue or Angular. The first two are graded on precision of wording. The last three are graded on edge cases and cleanup.
Why does [1, 30, 4, 21, 100000].sort() return the wrong order?
`Array.prototype.sort` with no comparator converts every element to a string and compares UTF-16 code unit sequences, so the result is `[1, 100000, 21, 30, 4]`. Pass `(a, b) => a - b` to sort numerically. `sort` also mutates the array and returns a reference to that same array, while `toSorted` (ES2023) returns a sorted copy and leaves the original alone.
What edge cases does a reduce polyfill have to handle?
Throwing a `TypeError` when the array has no elements and no `initialValue` is provided, skipping empty slots in sparse arrays instead of passing `undefined` for them, and calling the callback with four arguments: accumulator, currentValue, currentIndex, array. The difference is visible in one line: `[1, 2, , 4].reduce((a, b) => a + b)` is 7, while `[1, 2, undefined, 4].reduce((a, b) => a + b)` is `NaN`.
Do JavaScript interviews ask about ES2024 and ES2025 features?
They tend to appear as the modern alternative to an older answer rather than as questions of their own. Knowing that `toSorted`, `toReversed`, `with` and `toSpliced` were published in 2023, that `Object.groupBy` and `Promise.withResolvers` were published in 2024, and that the new Set methods, iterator helpers, `Promise.try` and `RegExp.escape` were published in 2025 is usually enough. ECMAScript 2025, the 16th edition, was approved on 25 June 2025.
How should I practise for the implementation round?
Write the edge-case assertions before you write the implementation, then make them pass. Re-solve each problem from an empty editor instead of re-reading a solution you already understand, because recognising a correct answer and producing one under a timer are different skills.