The JavaScript interview handbook

The fourteen topics that come up in almost every front-end interview, with the short answer, the code, and the follow-up question the interviewer is really waiting for.

Rowan Vale··10 min read

I have sat on both sides of enough front-end interviews to notice that the technical half is remarkably repetitive. The same fourteen topics come up, in roughly the same order, and each one has a short correct answer and a longer answer that gets you the offer.

This is my notebook for all fourteen, written out properly. Read it end to end the week before an interview, or jump to whichever chapter you are shaky on.

Table of contents

  1. Array methods
  2. var, let and const
  3. Hoisting
  4. Equality: == versus ===
  5. The this keyword
  6. call, apply and bind
  7. Local storage and session storage
  8. Timers
  9. The event loop
  10. Promises
  11. async / await
  12. Closures
  13. Prototypes
  14. Debouncing and throttling

Array methods

Almost every interview opens here, because it is a cheap way to find out whether you write loops or transformations. The four that matter are map(), filter(), reduce() and find().

map()

map() walks an array and returns a new array of the same length, built from whatever the callback returns.

const prices = [12, 18, 40];
const withTax = prices.map((price) => price * 1.21);

console.log(withTax); // [14.52, 21.78, 48.4]
console.log(prices); // [12, 18, 40] — untouched

The follow-up question is always about that second line. map() does not mutate; it produces. If you find yourself calling map() and ignoring the result, you wanted forEach().

filter()

filter() returns a new array containing only the elements for which the callback returned a truthy value. The length changes, the elements do not.

const users = [
  { name: 'Ada', active: true },
  { name: 'Grace', active: false },
  { name: 'Alan', active: true },
];

const active = users.filter((user) => user.active);
console.log(active.length); // 2

reduce()

reduce() folds an array down to a single value. It takes an accumulator, the current element, and an initial value — and the initial value is the part people forget.

const items = [
  { label: 'Keyboard', total: 89 },
  { label: 'Cable', total: 12 },
  { label: 'Stand', total: 45 },
];

const total = items.reduce((sum, item) => sum + item.total, 0);
console.log(total); // 146

Leave off that 0 and an empty array throws a TypeError instead of returning zero. That single character is the most common bug in production code that uses reduce().

reduce() is also how you group things without reaching for a library:

const byLetter = ['apple', 'apricot', 'banana'].reduce((groups, word) => {
  const key = word[0];
  groups[key] ??= [];
  groups[key].push(word);
  return groups;
}, {});

console.log(byLetter); // { a: ['apple', 'apricot'], b: ['banana'] }

find() and findIndex()

find() returns the first matching element, or undefined. findIndex() returns its position, or -1. Both stop as soon as they hit a match, which is the reason to prefer them over filter()[0].

const first = users.find((user) => !user.active);
console.log(first.name); // 'Grace'

The difference between map() and forEach()

Expect this one verbatim.

map() returns a new array; forEach() returns undefined.

map() is for transforming; forEach() is for side effects.

map() can be chained; forEach() ends the chain.

var, let and const

Three declarations, three different behaviours, and one of them is a historical accident.

var

var is function-scoped and hoisted, which produces the classic surprise:

function demo() {
  if (true) {
    var message = 'inside the block';
  }
  console.log(message); // 'inside the block'
}

The if block did not create a scope. var also allows redeclaration in the same scope without complaint, which is how two people can define the same variable in a 400-line file and never find out.

let

let is block-scoped and may be reassigned but not redeclared:

let count = 0;
count = 1; // fine
// let count = 2;  // SyntaxError: already declared

const

const is block-scoped and cannot be reassigned. It does not make the value immutable — a distinction interviewers love:

const config = { theme: 'light' };
config.theme = 'dark'; // allowed, the binding did not change
// config = {};        // TypeError: assignment to constant variable

The rule I actually follow: const everywhere, let when you genuinely reassign, var never.

Hoisting

Declarations are processed before any code runs. What differs is what the name holds at that point.

console.log(a); // undefined — declared, not yet assigned
var a = 1;

console.log(b); // ReferenceError: cannot access 'b' before initialization
let b = 2;

let and const are hoisted too, but into the temporal dead zone: the binding exists and is unusable until the declaration is evaluated. Saying that phrase out loud is usually enough to move the conversation on.

Function declarations hoist completely; function expressions do not:

greet(); // 'hello'
function greet() {
  console.log('hello');
}

speak(); // TypeError: speak is not a function
var speak = function () {
  console.log('hi');
};

== versus ===

=== compares type and value. == coerces first, using a table nobody has memorised on purpose.

0 == '0'; // true
0 == []; // true
'0' == []; // false
null == undefined; // true
null === undefined; // false
NaN == NaN; // false

Use === everywhere. The one defensible == is value == null, which is a compact way of saying “null or undefined” — and even that reads better as value ?? fallback.

For the genuinely hard cases:

Object.is(NaN, NaN); // true
Object.is(0, -0); // false

The this keyword

this is decided by how a function is called, not where it is written. Four rules, checked in order:

  1. Called with newthis is the new object.
  2. Called with call, apply or bindthis is what you passed.
  3. Called as a method, obj.fn()this is obj.
  4. Anything else → undefined in modules and strict mode, globalThis otherwise.
const counter = {
  count: 0,
  increment() {
    this.count += 1;
  },
};

counter.increment();
console.log(counter.count); // 1

const loose = counter.increment;
loose(); // TypeError: cannot read properties of undefined

Arrow functions have no this of their own; they close over the one in scope where they were written. That is why the callback below works and the function version does not:

class Ticker {
  ticks = 0;

  start() {
    setInterval(() => {
      this.ticks += 1; // `this` is still the Ticker
    }, 1000);
  }
}

call, apply and bind

Three ways to set this explicitly. The differences are small and almost always asked together.

function describe(role, team) {
  return `${this.name} — ${role}, ${team}`;
}

const person = { name: 'Ada' };

describe.call(person, 'engineer', 'platform'); // arguments listed
describe.apply(person, ['engineer', 'platform']); // arguments in an array
const bound = describe.bind(person); // returns a new function
bound('engineer', 'platform');

call invokes immediately, arguments one by one.

apply invokes immediately, arguments as an array.

bind invokes nothing; it returns a bound copy.

bind also does partial application, which is a nice thing to mention:

const asEngineer = describe.bind(person, 'engineer');
asEngineer('platform'); // 'Ada — engineer, platform'

Local storage and session storage

Both are synchronous key/value stores, both hold strings only, both are scoped to the origin. The difference is lifetime.

Local storage

Survives reloads and browser restarts until something removes it.

localStorage.setItem('theme', JSON.stringify({ accent: 'blue' }));

const stored = JSON.parse(localStorage.getItem('theme') ?? '{}');
localStorage.removeItem('theme');

Session storage

Same API, cleared when the tab closes. A duplicated tab gets a copy; a new tab gets nothing.

Three things worth saying without being asked:

  • Both are synchronous and block the main thread — do not put a megabyte of state in there.
  • Both throw in private browsing modes and when the quota is exceeded, so every access belongs in a try.
  • Neither is a place for tokens you would mind a script reading.

Timers

setTimeout schedules one run after a delay; setInterval schedules repeated runs. Neither promises the delay you asked for — they promise at least that delay, once the stack is clear.

const id = setTimeout(() => console.log('later'), 200);
clearTimeout(id);

const tick = setInterval(() => console.log('tick'), 1000);
clearInterval(tick);

The interesting version of the question is why the loop below logs three threes with var and zero-one-two with let:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i)); // 3, 3, 3
}

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j)); // 0, 1, 2
}

var has one binding shared by every callback. let creates a fresh binding per iteration.

The event loop

The one that separates “I use promises” from “I know what they do.”

JavaScript runs one thing at a time on one stack. Anything asynchronous is handed off, and its callback is put in a queue. When the stack empties, the loop drains every microtask, then takes one macrotask, then repeats.

The call stack empties, the microtask queue drains completely, then one macrotask runs, and the loop repeats.

Microtasks are promise callbacks and queueMicrotask. Macrotasks are timers, I/O and events. Which is why this prints in an order that surprises people the first time:

console.log('1');

setTimeout(() => console.log('2'));

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

console.log('4');

// 1, 4, 3, 2

Synchronous code first, then the microtask, then the timer — even with a zero delay.

An endless stream of microtasks starves the macrotask queue entirely. That is how a page with no infinite loop still manages to freeze.

Promises

A promise is an object representing a value that is not here yet. It is pending, then either fulfilled or rejected, and it never changes again.

function loadUser(id) {
  return fetch(`/api/users/${id}`).then((response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  });
}

loadUser(7)
  .then((user) => console.log(user.name))
  .catch((error) => console.error(error))
  .finally(() => console.log('done either way'));

Know the four combinators, because at least one always comes up:

Promise.all([a, b]); // all fulfil, or reject on the first failure
Promise.allSettled([a, b]); // never rejects; array of status objects
Promise.race([a, b]); // settles as the first one settles
Promise.any([a, b]); // first fulfilment; rejects only if all reject

Promise.all for “I need all of these”, allSettled for “tell me how each one went”, race for timeouts.

async and await

async/await is promise plumbing with the shape of ordinary code. An async function always returns a promise, and await pauses it until the awaited promise settles.

async function loadDashboard(id) {
  try {
    const [user, invoices] = await Promise.all([loadUser(id), loadInvoices(id)]);
    return { user, invoices };
  } catch (error) {
    console.error('dashboard failed', error);
    return null;
  }
}

The mistake to avoid — and the one interviewers plant — is awaiting in a loop when the calls are independent:

// serial: three round trips, one after another
for (const id of ids) {
  results.push(await loadUser(id));
}

// parallel: one round trip's worth of waiting
const results = await Promise.all(ids.map(loadUser));

Closures

A closure is a function that keeps access to the scope it was created in, even after that scope has returned.

function makeCounter() {
  let count = 0;
  return {
    increment: () => (count += 1),
    value: () => count,
  };
}

const counter = makeCounter();
counter.increment();
counter.increment();
console.log(counter.value()); // 2

count is unreachable from outside and still alive. That is the whole idea, and it is what every module pattern, every memoiser and every debounce is built on:

function once(fn) {
  let called = false;
  let result;
  return (...args) => {
    if (!called) {
      called = true;
      result = fn(...args);
    }
    return result;
  };
}

Prototypes

Every object has a hidden link to another object. Look up a property that is not there, and the engine follows the link, and the next, until it finds it or reaches null.

const base = {
  greet() {
    return `hello, ${this.name}`;
  },
};

const user = Object.create(base);
user.name = 'Ada';

console.log(user.greet()); // 'hello, Ada'
console.log(Object.hasOwn(user, 'greet')); // false — it came from base

class is syntax over exactly this:

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return `${this.name} makes a sound`;
  }
}

class Dog extends Animal {
  speak() {
    return `${super.speak()}, specifically a bark`;
  }
}

Methods live on Animal.prototype, not on each instance — which is why ten thousand instances do not cost ten thousand copies of speak.

Debouncing and throttling

Two ways to stop an event firing more often than you can afford, and a frequent live-coding request.

Debounce waits for the noise to stop, then runs once:

function debounce(fn, wait = 200) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

Throttle runs at most once per interval, whatever happens in between:

function throttle(fn, interval = 200) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last < interval) return;
    last = now;
    fn(...args);
  };
}

Search-as-you-type is a debounce. Scroll position and pointer tracking are throttles. Saying which one you would use, and why, matters more than the implementation.

How to actually use this

Do not memorise the code. Retype it. Every one of these snippets is under twenty lines, and typing them from memory once is worth reading them five times.

Then, for each chapter, prepare the second answer — the one that starts “the reason that matters in practice is.” That answer is what the interview is for.