Full-Stack Questions

JavaScript Coding Round: Polyfills and Utilities

Tested solutions for debounce, throttle, curry, memoize, deep clone, promise polyfills, a pool, an event emitter, an LRU cache and live-coding tips.

Lesson 1735 min read#javascript#coding

On this page

  1. Words used in this lesson
  2. How to behave in a live coding round
  3. Interview questions
  4. Key takeaways
  5. What's next

In one line: a JavaScript coding round asks you to rebuild small tools (debounce, Promise.all, bind, an LRU cache) while someone watches. They grade your thinking, edge cases and tests at least as much as the final code.

At 4+ years you are not given clever puzzles. You are asked for the helpers that sit inside lodash, React and Node, written from a blank file in 10 to 30 minutes. Each task below has a short model solution, the interviewer's checks, the cost and the follow-ups. Algorithm-heavy problems are in the DSA course. The language rules behind these tasks are in Closures, First-Class Functions and Async JS and the Event Loop.

Every solution was run with its own tests in Node 22 (DOM tasks in Chromium). The Promise class passes the official Promises/A+ suite (872 tests). The code is short on purpose: it is what you can type in an interview.

Words used in this lesson

  • A polyfill is code that adds a missing feature by writing it with older features. "Write a polyfill for X" means "build X yourself".
  • Time complexity says how the work grows with input size n: O(1) is constant, O(n) is linear. Space complexity says the same for memory.
  • An AbortSignal is an object that tells running work "stop now"; an AbortController creates it.
  • Backoff is waiting longer after each failure; jitter is a random part added to that wait.
  • A hole is a missing index in an array, like the gap in [1, , 3]. It is not the same as undefined.
  • An edge case is an unusual input that must still work: empty, null, one item, a huge one.

Features used (all work in Node 22 and current Chrome, Firefox and Safari): ??= (ES2021), #private fields and Object.hasOwn (ES2022), findLastIndex (ES2023), Object.groupBy (ES2024), Set.prototype.intersection (ES2025). structuredClone, IntersectionObserver and AbortSignal are web platform APIs, not ECMAScript.

How to behave in a live coding round

  1. Repeat the task and ask two questions. "Should the first call run at once? What if the input is null?"
  2. Write an example and a test first. Three lines of expected output prove it works.
  3. Think aloud. Say the plan before you type.
  4. Write the simple version first, then add edge cases one at a time.
  5. Name things clearly. lastArgs, pending, results beat a, x, tmp.
  6. Say the complexity without being asked.
  7. Trace your code by hand with the first example before you say "done".
  8. Name what you left out. If you are stuck, say so, write the slow version, then improve it. Silence is the worst answer.
JavaScript
const cases = [[[1, 2, 3, 4, 5], 2, [[1, 2], [3, 4], [5]]], [[], 3, []]];for (const [input, size, want] of cases) assert.deepEqual(chunk(input, size), want);

Interview questions

(Must know) Write debounce with leading, trailing, cancel and flush.

Task. debounce(fn, wait) waits until calls stop for wait ms, then runs fn once. They check: the timer resets on each call; this and arguments are kept.

JavaScript
function debounce(fn, wait, { leading = false, trailing = true } = {}) {  let timer, lastArgs, lastThis, result;  const invoke = () => {    const args = lastArgs, self = lastThis;    lastArgs = lastThis = undefined;    result = fn.apply(self, args);    return result;  };  function debounced(...args) {    lastArgs = args;    lastThis = this;    const callNow = leading && timer === undefined;    clearTimeout(timer);    timer = setTimeout(() => {      timer = undefined;      if (trailing && lastArgs) invoke();    }, wait);    if (callNow) invoke();    return result;  }  debounced.cancel = () => {    clearTimeout(timer);    timer = lastArgs = lastThis = undefined;  };  debounced.flush = () => {    if (timer === undefined) return result;    clearTimeout(timer);    timer = undefined;    if (trailing && lastArgs) invoke();    return result;  };  return debounced;}
  • Leading runs on the first call of a burst; trailing runs after the quiet time with the last arguments. A single call does not fire twice.
  • cancel drops the pending call; flush runs it now.
  • Cost: O(1). Mistakes: an arrow function as debounced (loses this); no cancel on unmount.
  • Test it with mock.timers.enable({ apis: ['setTimeout', 'Date'] }) from node:test and tick(100).
(Must know) Write throttle. How is it different from debounce?

Difference in one line: debounce waits for silence and runs once; throttle runs at most once per period while events keep coming. Debounce suits search-as-you-type; throttle suits scroll and resize.

JavaScript
function throttle(fn, wait) {  let last = -Infinity, timer, lastArgs, lastThis;  function run() {    last = Date.now();    timer = undefined;    const args = lastArgs, self = lastThis;    lastArgs = lastThis = undefined;    fn.apply(self, args);  }  function throttled(...args) {    lastArgs = args;    lastThis = this;    const remaining = wait - (Date.now() - last);    if (remaining <= 0) {      clearTimeout(timer);      run();    } else if (timer === undefined) {      timer = setTimeout(run, remaining);    }  }  throttled.cancel = () => {    clearTimeout(timer);    timer = lastArgs = lastThis = undefined;  };  return throttled;}

They check: a trailing call (so the last value is not lost) with the latest arguments. Cost: O(1). Mistake: a timestamp-only version drops the final event.

(Must know) Write once. What if the function throws?
JavaScript
function once(fn) {  let called = false, result;  return function (...args) {    if (!called) {      called = true;      result = fn.apply(this, args);    }    return result;  };}

A closure holds called and result. Trap: called is set before fn runs, so after a throw the next call returns undefined. Say which behaviour you want. For async work store the promise, so parallel calls share one request.

(Senior signal) Write memoize. What goes wrong with the cache key?

To memoize is to remember a function's results by its arguments. It is safe only for pure functions (same input, same output, no side effects).

JavaScript
function memoize(fn, resolver = (...args) => JSON.stringify(args)) {  const cache = new Map();  function memoized(...args) {    const key = resolver(...args);    if (cache.has(key)) return cache.get(key);    const value = fn.apply(this, args);    cache.set(key, value);    return value;  }  memoized.cache = cache;  return memoized;}// One object argument: a WeakMap lets the cache entry die with the object.function memoizeByObject(fn) {  const cache = new WeakMap();  return (obj) => {    if (!cache.has(obj)) cache.set(obj, fn(obj));    return cache.get(obj);  };}
  • JSON.stringify(args) is a weak key: [undefined] and [null] both give "[null]", so f(undefined) returns the cached f(null) (tested). Object key order changes the key; a circular object throws. A custom resolver fixes these.
  • A Map grows forever: add a size limit (see LRU). A WeakMap holds keys weakly, so an entry disappears when nothing else uses the object; it accepts only object keys.
  • Async trap: a rejected promise is cached too.
  • Cost: O(1) plus building the key.
(Senior signal) Write curry with arity and placeholders.

Currying turns f(a, b, c) into f(a)(b)(c): it collects arguments until it has enough, then calls the function. A placeholder marks a gap to fill later.

JavaScript
const _ = Symbol('placeholder');function curry(fn, arity = fn.length) {  const collect = (held) => (...next) => {    const args = [...held];    const rest = [...next];    for (let i = 0; i < args.length && rest.length; i++) {      if (args[i] === _) args[i] = rest.shift(); // fill gaps first    }    args.push(...rest);    const ready = args.length >= arity && args.slice(0, arity).every((a) => a !== _);    return ready ? fn(...args) : collect(args);  };  return collect([]);}

They check: fn.length counts declared parameters but ignores rest parameters and defaults, so curry((...n) => ...) needs an explicit arity. Any split works: c(1)(2, 3), c(1, 2)(3), and c(_, 2)(1)(3) fills the gap first. Mistake: mutating held arguments (a partly applied function must be reusable). Difference in one line: currying is a chain of one-step calls; partial application fixes some arguments now and takes the rest in one call.

(Must know) Write compose, pipe and partial.
JavaScript
const pipe = (...fns) => (...args) =>  fns.length === 0 ? args[0] : fns.slice(1).reduce((value, f) => f(value), fns[0](...args));const compose = (...fns) => pipe(...[...fns].reverse());
JavaScript
function partial(fn, ...preset) {  return function (...later) {    return fn.apply(this, [...preset, ...later]);  };}

pipe(f, g)(x) is g(f(x)) (left to right); compose(f, g)(x) is f(g(x)) (right to left, like maths). They check: the first function may take many arguments; an empty list returns the input; no mutation of fns. Async: fns.reduce((p, f) => p.then(f), Promise.resolve(input)).

(Senior signal) Write deepClone. How is it different from structuredClone?

A deep clone copies an object and everything inside it. A shallow copy ({...obj}) shares the inner objects.

JavaScript
function deepClone(value, seen = new WeakMap()) {  if (value === null || typeof value !== 'object') return value;  if (seen.has(value)) return seen.get(value);  if (value instanceof Date) return new Date(value.getTime());  let copy;  if (value instanceof Map) {    copy = new Map();    seen.set(value, copy);    value.forEach((v, k) => copy.set(deepClone(k, seen), deepClone(v, seen)));    return copy;  }  if (value instanceof Set) {    copy = new Set();    seen.set(value, copy);    value.forEach((v) => copy.add(deepClone(v, seen)));    return copy;  }  copy = Array.isArray(value) ? new Array(value.length) : Object.create(Object.getPrototypeOf(value));  seen.set(value, copy);  for (const key of Reflect.ownKeys(value)) {    if (Object.prototype.propertyIsEnumerable.call(value, key)) copy[key] = deepClone(value[key], seen);  }  return copy;}

They check: cycles (a WeakMap from original to copy, registered before recursing), shared references staying shared, and Date, Map, Set (RegExp is the same idea). All rows below were run in Node 22.

JSON.parse(JSON.stringify(x))structuredClone(x)code above
Cyclesthrowsworksworks
Datebecomes a stringstays Datestays Date
Map, Setbecome {}keptkept
Functionsdroppedthrows DataCloneErrorshared
Class prototypelostlostkept

In real code I use structuredClone first (Node 17+, Chrome 98, Firefox 94, Safari 15.4); I write my own only to keep class instances or functions. Cost: O(n). Mistakes: recursing on a cycle; typeof null === 'object'.

(Senior signal) Write deepEqual.
JavaScript
function deepEqual(a, b, seen = new WeakMap()) {  if (a === b || (a !== a && b !== b)) return true;  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;  if (seen.get(a) === b) return true;  seen.set(a, b);  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());  if (a instanceof Map) {    if (a.size !== b.size) return false;    for (const [k, v] of a) if (!b.has(k) || !deepEqual(v, b.get(k), seen)) return false;    return true;  }  if (a instanceof Set) {    if (a.size !== b.size) return false;    for (const v of a) if (!b.has(v)) return false;    return true;  }  if (Array.isArray(a) && a.length !== b.length) return false;  const keys = Object.keys(a);  if (keys.length !== Object.keys(b).length) return false;  return keys.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k], seen));}

They check: NaN equals NaN; {a: undefined} is not {}; an array is not an object with the same keys (the prototype check); cycles; Date by time; Map and Set by content. Cost: O(n). Limit: objects inside a Set compare by identity. Mistake: comparing JSON.stringify output depends on key order.

(Must know) Write flatten for arrays (with depth) and for objects.
JavaScript
function flatten(arr, depth = 1) {  const out = [];  arr.forEach((item) => {    if (Array.isArray(item) && depth > 0) {      for (const x of flatten(item, depth - 1)) out.push(x);    } else out.push(item);  });  return out;}const isContainer = (v) =>  Array.isArray(v) || (v !== null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype);function flattenObject(obj, depth = Infinity, prefix = '', out = {}) {  for (const [k, v] of Object.entries(obj)) {    const key = prefix ? `${prefix}.${k}` : k;    if (depth > 0 && isContainer(v) && Object.keys(v).length > 0) flattenObject(v, depth - 1, key, out);    else out[key] = v;  }  return out;}
  • [1, [2, [3]]] with depth 1 gives [1, 2, [3]]; Infinity flattens fully. forEach skips holes, so it matches the real Array.prototype.flat (compared in the tests).
  • Objects: { a: { b: 1, c: [1, 2] } } becomes { 'a.b': 1, 'a.c.0': 1, 'a.c.1': 2 }. Only plain objects and arrays are opened, so a Date stays whole.
  • Cost: O(total items). Mistake: push(...hugeArray) can overflow the stack. Say first: in real work it is arr.flat(Infinity).
(Must know) Write groupBy, chunk, zip, unique and intersection.
JavaScript
function groupBy(items, key) {  const pick = typeof key === 'function' ? key : (item) => item[key];  const groups = Object.create(null);  items.forEach((item, i) => {    const k = pick(item, i);    (groups[k] ??= []).push(item);  });  return groups;}
JavaScript
function chunk(arr, size) {  if (!Number.isInteger(size) || size < 1) throw new RangeError('size must be a positive integer');  const out = [];  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));  return out;}const zip = (...arrays) =>  Array.from({ length: Math.max(0, ...arrays.map((a) => a.length)) }, (_, i) => arrays.map((a) => a[i]));function unique(arr, keyFn = (x) => x) {  const seen = new Set();  return arr.filter((x) => {    const k = keyFn(x);    if (seen.has(k)) return false;    seen.add(k);    return true;  });}function intersection(a, b) {  const inB = new Set(b);  return unique(a).filter((x) => inB.has(x));}
  • groupBy uses Object.create(null), so "constructor" is just a key. Native Object.groupBy (ES2024, Node 21+) does the same; Map.groupBy allows object keys.
  • chunk rejects 0 (it would loop forever) and non-integers.
  • unique uses a Set (O(n)); filter with indexOf is O(n²).
  • intersection builds a Set of one side: O(n + m), not O(n × m). Native Set.prototype.intersection exists (ES2025).
  • zip pads to the longest array; say which you chose.
(Must know) Write get(obj, 'a.b[0].c', fallback) and set. What is the security trap?
JavaScript
const toPath = (path) =>  Array.isArray(path) ? path : String(path).replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);function get(obj, path, fallback) {  let cur = obj;  for (const key of toPath(path)) {    if (cur == null) return fallback;    cur = cur[key];  }  return cur === undefined ? fallback : cur;}const UNSAFE = new Set(['__proto__', 'constructor', 'prototype']);function set(obj, path, value) {  const keys = toPath(path);  let cur = obj;  keys.forEach((key, i) => {    if (UNSAFE.has(key)) throw new Error(`Unsafe path segment: ${key}`);    if (i === keys.length - 1) {      cur[key] = value;    } else {      if (cur[key] === null || typeof cur[key] !== 'object') {        cur[key] = /^\d+$/.test(keys[i + 1]) ? [] : {};      }      cur = cur[key];    }  });  return obj;}

They check: a missing step returns the fallback; a falsy value (0, '') is still returned; set creates arrays for numeric next keys. The trap is prototype pollution: set({}, '__proto__.admin', true) would change Object.prototype for the whole app. The code blocks __proto__, constructor and prototype (tested).

(Must know) Write polyfills for map, filter and reduce. What about holes and thisArg?
JavaScript
function check(self, cb, name) {  if (self == null) throw new TypeError(`Array.prototype.${name} called on null or undefined`);  if (typeof cb !== 'function') throw new TypeError(`${String(cb)} is not a function`);}function myMap(cb, thisArg) {  check(this, cb, 'map');  const O = Object(this), len = O.length >>> 0, out = new Array(len);  for (let i = 0; i < len; i++) {    if (i in O) out[i] = cb.call(thisArg, O[i], i, O);  }  return out;}function myFilter(cb, thisArg) {  check(this, cb, 'filter');  const O = Object(this), len = O.length >>> 0, out = [];  for (let i = 0; i < len; i++) {    if (i in O && cb.call(thisArg, O[i], i, O)) out.push(O[i]);  }  return out;}function myReduce(cb, ...init) {  check(this, cb, 'reduce');  const O = Object(this), len = O.length >>> 0;  let i = 0, acc;  if (init.length > 0) acc = init[0];  else {    while (i < len && !(i in O)) i++;    if (i >= len) throw new TypeError('Reduce of empty array with no initial value');    acc = O[i++];  }  for (; i < len; i++) if (i in O) acc = cb(acc, O[i], i, O);  return acc;}

They check: thisArg goes through call; the callback gets (value, index, array); holes are skipped with i in O and map keeps them; the length is read once; reduce on an empty array with no initial value throws TypeError. Trap: testing acc === undefined for "no initial value" is wrong, because reduce(fn, undefined) is a real initial value; counting the rest arguments is right. Cost: O(n).

(Senior signal) Write polyfills for call, apply and bind.
JavaScript
function myCall(ctx, ...args) {  ctx = ctx == null ? globalThis : Object(ctx);  const key = Symbol('fn');  Object.defineProperty(ctx, key, { value: this, configurable: true });  try {    return ctx[key](...args);  } finally {    delete ctx[key];  }}function myApply(ctx, args) {  return args == null ? myCall.call(this, ctx) : myCall.call(this, ctx, ...Array.from(args));}function myBind(ctx, ...preset) {  if (typeof this !== 'function') throw new TypeError('Bind must be called on a function');  const target = this;  function bound(...later) {    return target.apply(new.target ? this : ctx, [...preset, ...later]);  }  if (target.prototype) bound.prototype = Object.create(target.prototype);  return bound;}
  • call puts the function on the object as a temporary method, so this is that object. A Symbol key cannot clash with a real property. Limit: it fails on a frozen object (tested); real code uses Reflect.apply.
  • apply is call with an array(-like); apply(ctx) with no array must work.
  • bind returns a new function with fixed this and leading arguments. Trap: new on a bound function ignores the bound this; new.target detects it. Binding twice keeps the first this.
(Senior signal) Implement the new operator and instanceof.
JavaScript
function myNew(Ctor, ...args) {  if (typeof Ctor !== 'function' || !Ctor.prototype) throw new TypeError(`${String(Ctor)} is not a constructor`);  const obj = Object.create(Ctor.prototype); // 1. new object linked to the prototype  const result = Ctor.apply(obj, args); //      2. run the constructor with this = obj  const isObject = result !== null && (typeof result === 'object' || typeof result === 'function');  return isObject ? result : obj; //            3. an object return value wins}function myInstanceOf(value, Ctor) {  if (typeof Ctor !== 'function') throw new TypeError("Right-hand side of 'instanceof' is not callable");  if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false;  const target = Ctor.prototype;  for (let p = Object.getPrototypeOf(value); p !== null; p = Object.getPrototypeOf(p)) {    if (p === target) return true;  }  return false;}

new F(args): make an object linked to F.prototype; call F with this set to it; if F returns an object, use that; otherwise use the new object. instanceof walks the prototype chain looking for F.prototype. They check: a returned 5 is ignored; primitives are never instanceof. Limit: myNew cannot run a class; use Reflect.construct.

(Senior signal) Write a basic Promise with then chaining.

A promise is an object for a value that arrives later: pending, then fulfilled (with a value) or rejected (with a reason). Once settled, it never changes. A microtask is a small job that runs right after the current code, before any timer.

JavaScript
class MyPromise {  #state = 'pending';  #value;  #handlers = [];  constructor(executor) {    let locked = false; // resolve/reject work only the first time    const settle = (state, value) => {      this.#state = state;      this.#value = value;      this.#handlers.forEach((h) => this.#run(h));      this.#handlers = [];    };    const adopt = (x) => { // the "resolution procedure"      if (x === this) return settle('rejected', new TypeError('Chaining cycle detected'));      if (x !== null && (typeof x === 'object' || typeof x === 'function')) {        let then;        try { then = x.then; } catch (e) { return settle('rejected', e); }        if (typeof then === 'function') {          let called = false;          try {            then.call(x,              (v) => { if (!called) { called = true; adopt(v); } },              (e) => { if (!called) { called = true; settle('rejected', e); } });          } catch (e) { if (!called) { called = true; settle('rejected', e); } }          return;        }      }      settle('fulfilled', x);    };    const resolve = (x) => { if (!locked) { locked = true; adopt(x); } };    const reject = (e) => { if (!locked) { locked = true; settle('rejected', e); } };    try { executor(resolve, reject); } catch (e) { reject(e); }  }  #run({ onFulfilled, onRejected, resolve, reject }) {    queueMicrotask(() => { // callbacks are always async      const ok = this.#state === 'fulfilled';      const cb = ok ? onFulfilled : onRejected;      if (typeof cb !== 'function') return (ok ? resolve : reject)(this.#value); // pass through      try { resolve(cb(this.#value)); } catch (e) { reject(e); }    });  }  then(onFulfilled, onRejected) {    return new MyPromise((resolve, reject) => {      const handler = { onFulfilled, onRejected, resolve, reject };      if (this.#state === 'pending') this.#handlers.push(handler);      else this.#run(handler);    });  }  catch(onRejected) { return this.then(undefined, onRejected); }  static resolve(v) { return v instanceof MyPromise ? v : new MyPromise((res) => res(v)); }  static reject(e) { return new MyPromise((_, rej) => rej(e)); }}

They check: (1) resolve and reject count only the first time; (2) then returns a new promise; (3) callbacks run in a microtask, even if already settled; (4) a returned value resolves the next promise, a thrown error rejects it; (5) a returned promise or "thenable" (an object with a then method) is followed; (6) a missing handler passes the value on. Its log order matches the real Promise, and the Promises/A+ suite passes (872 tests). Left out: unhandled-rejection reporting.

(Must know) Write Promise.all, allSettled, race and any.
JavaScript
const promiseAll = (iterable) =>  new Promise((resolve, reject) => {    const items = [...iterable];    const results = new Array(items.length);    let left = items.length;    if (left === 0) return resolve(results);    items.forEach((item, i) => {      Promise.resolve(item).then((v) => {        results[i] = v;        if (--left === 0) resolve(results);      }, reject);    });  });const promiseAllSettled = (iterable) =>  promiseAll([...iterable].map((item) =>    Promise.resolve(item).then(      (value) => ({ status: 'fulfilled', value }),      (reason) => ({ status: 'rejected', reason }))));const promiseRace = (iterable) =>  new Promise((resolve, reject) => {    for (const item of iterable) Promise.resolve(item).then(resolve, reject);  });const promiseAny = (iterable) =>  new Promise((resolve, reject) => {    const items = [...iterable];    const errors = new Array(items.length);    let left = items.length;    const fail = () => reject(new AggregateError(errors, 'All promises were rejected'));    if (left === 0) return fail();    items.forEach((item, i) => {      Promise.resolve(item).then(resolve, (e) => {        errors[i] = e;        if (--left === 0) fail();      });    });  });
MethodFulfils whenRejects whenResult
allall fulfilfirst rejectionvalues in input order
allSettled (ES2020)all settlenever{status, value / reason} list
racefirst settlesfirst settles badlythat one result
any (ES2021)first fulfilsall reject (AggregateError)that one value

They check: results stored by index, not in finish order; plain values accepted; an empty list resolves for all and allSettled, stays pending for race, rejects for any. Trap: Promise.all does not cancel the others when one rejects. Cost: O(n).

(Senior signal) Run async tasks with a concurrency limit (promise pool).

Task: 100 requests, at most 5 at a time. They check: you pass functions that start work (a promise has already started), keep result order, and stop on failure.

JavaScript
async function promisePool(tasks, limit) {  if (!Number.isInteger(limit) || limit < 1) throw new RangeError('limit must be >= 1');  const results = new Array(tasks.length);  let next = 0, failed = false;  async function worker() {    while (!failed && next < tasks.length) {      const i = next++;      try {        results[i] = await tasks[i]();      } catch (err) {        failed = true;        throw err;      }    }  }  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));  return results;}

Each worker takes the next task when it finishes. next++ has no await before it, so two workers never take the same task. Cost: O(n) time, at most limit in flight. Mistake: chunk plus Promise.all per batch: one slow task blocks the batch.

(Must know) Write sleep, a timeout wrapper and retry with backoff.
JavaScript
const sleep = (ms, signal) =>  new Promise((resolve, reject) => {    if (signal?.aborted) return reject(signal.reason);    const onAbort = () => { clearTimeout(timer); reject(signal.reason); };    const timer = setTimeout(() => {      signal?.removeEventListener('abort', onAbort);      resolve();    }, ms);    signal?.addEventListener('abort', onAbort, { once: true });  });function withTimeout(promise, ms, message = `Timed out after ${ms}ms`) {  let timer;  const timeout = new Promise((_, reject) => {    timer = setTimeout(() => reject(new Error(message)), ms);  });  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));}
JavaScript
async function retry(fn, {  retries = 3, baseMs = 100, factor = 2, maxMs = 5000,  jitter = true, shouldRetry = () => true, signal, wait = sleep,} = {}) {  for (let attempt = 0; ; attempt++) {    try {      return await fn(attempt);    } catch (err) {      if (attempt >= retries || !shouldRetry(err)) throw err;      const cap = Math.min(maxMs, baseMs * factor ** attempt);      await wait(jitter ? Math.random() * cap : cap, signal);    }  }}
  • withTimeout clears its timer in finally. Trap: it does not stop the work. For fetch, pass AbortSignal.timeout(ms) (Node 16.14+, all current browsers).
  • retry waits base × 2^attempt, capped, with full jitter (a random wait from 0 to the cap), so clients do not retry together.
  • They check: which errors you retry (network and 5xx, not 400), a maximum number of tries, Retry-After on 429. Retry only idempotent work (safe to repeat).
(Must know) Write an EventEmitter with on, once, off and emit.
JavaScript
class Emitter {  #events = new Map();  on(name, fn) {    if (!this.#events.has(name)) this.#events.set(name, []);    this.#events.get(name).push(fn);    return this;  }  once(name, fn) {    const wrapper = (...args) => {      this.off(name, wrapper);      fn.apply(this, args);    };    wrapper.listener = fn;    return this.on(name, wrapper);  }  off(name, fn) {    const list = this.#events.get(name);    if (!list) return this;    const i = list.findLastIndex((l) => l === fn || l.listener === fn);    if (i !== -1) list.splice(i, 1);    if (list.length === 0) this.#events.delete(name);    return this;  }  emit(name, ...args) {    const list = this.#events.get(name);    if (!list) return false;    for (const fn of [...list]) fn.apply(this, args);    return true;  }}

They check: once removes itself before calling, and off(name, fn) still finds the wrapper through .listener; emit loops over a copy, so listeners that add or remove listeners are safe. Like Node, a function can be added twice and off removes one. Node's emit('error') with no listener throws.

(Senior signal) Write pub/sub and a tiny observable store. How do they differ from an emitter?

Difference in one line: an emitter belongs to one object; pub/sub is a shared broker where both sides only know a topic name.

JavaScript
class PubSub {  #topics = new Map();  subscribe(topic, fn) {    if (!this.#topics.has(topic)) this.#topics.set(topic, new Set());    this.#topics.get(topic).add(fn);    return () => this.#topics.get(topic)?.delete(fn);  }  publish(topic, data) {    for (const fn of [...(this.#topics.get(topic) ?? [])]) {      try { fn(data); } catch (e) { console.error(e); }    }  }}
JavaScript
function createStore(initial) {  let state = initial;  const listeners = new Set();  return {    getState: () => state,    setState(patch) {      const next = { ...state, ...patch };      if (Object.keys(next).every((k) => Object.is(next[k], state[k]))) return;      const prev = state;      state = next;      [...listeners].forEach((l) => l(state, prev));    },    subscribe(listener, selector = (s) => s) {      const wrapped = (s, prev) => {        if (!Object.is(selector(s), selector(prev))) listener(selector(s));      };      listeners.add(wrapped);      return () => listeners.delete(wrapped);    },  };}
  • subscribe returns an unsubscribe function; one failing subscriber must not stop the others.
  • The store is immutable: setState makes a new object, and does nothing if no value changed. A selector runs a listener only when its slice changes. React's useSyncExternalStore needs exactly this: subscribe plus a stable getState.
(Senior signal) Build an LRU cache with O(1) get and set.

An LRU (least recently used) cache holds at most max items; when full, it drops the item used longest ago.

JavaScript
class LRUCache {  #max;  #map = new Map(); // a Map remembers insertion order  constructor(max) {    if (!Number.isInteger(max) || max < 1) throw new RangeError('max must be a positive integer');    this.#max = max;  }  get(key) {    if (!this.#map.has(key)) return undefined;    const value = this.#map.get(key);    this.#map.delete(key); // move to the "newest" end    this.#map.set(key, value);    return value;  }  set(key, value) {    this.#map.delete(key);    this.#map.set(key, value);    if (this.#map.size > this.#max) {      this.#map.delete(this.#map.keys().next().value); // first key = least recently used    }    return this;  }  get size() { return this.#map.size; }}

A Map remembers insertion order, and delete and set are O(1). So "delete, then set" moves a key to the newest end, and the first key is the oldest. They check: get counts as a use; updating a key does not grow the cache; undefined values work. Follow-up: "without Map ordering" is a hash map plus a doubly linked list (DSA).

(Senior signal) Write a rate limiter (token bucket) in JavaScript.

A token bucket holds up to capacity tokens and gains refillPerSecond each second. Each request takes one; with none left, it is refused. It allows short bursts but limits the long-term rate.

JavaScript
class TokenBucket {  #capacity; #rate; #now; #tokens; #last;  constructor({ capacity, refillPerSecond, now = Date.now }) {    this.#capacity = capacity;    this.#rate = refillPerSecond;    this.#now = now;    this.#tokens = capacity; // start full: allows a burst    this.#last = now();  }  #refill() { // lazy: no timers, just maths when someone asks    const t = this.#now();    this.#tokens = Math.min(this.#capacity, this.#tokens + ((t - this.#last) / 1000) * this.#rate);    this.#last = t;  }  tryRemove(n = 1) {    this.#refill();    if (this.#tokens < n) return false;    this.#tokens -= n;    return true;  }  retryAfterMs(n = 1) {    this.#refill();    return this.#tokens >= n ? 0 : Math.ceil(((n - this.#tokens) / this.#rate) * 1000);  }}

They check: lazy refill (maths when asked, no timer per user), a cap at capacity, a clock you can fake. retryAfterMs feeds a Retry-After header. Cost: O(1) per user. Say aloud: this is per process; with four servers use a shared store such as Redis (Node.js and Express, Lesson 06).

(Must know) Implement a linked list, a queue and a stack.

A linked list is a chain of nodes, each holding a value and a pointer to the next. A stack is last-in-first-out; a queue is first-in-first-out.

JavaScript
class Node {  constructor(value, next = null) { this.value = value; this.next = next; }}class LinkedList {  head = null;  tail = null;  size = 0;  append(value) {    const node = new Node(value);    if (this.tail) this.tail.next = node; else this.head = node;    this.tail = node;    this.size++;    return this;  }  remove(value) {    let prev = null;    for (let cur = this.head; cur; prev = cur, cur = cur.next) {      if (cur.value !== value) continue;      if (prev) prev.next = cur.next; else this.head = cur.next;      if (cur === this.tail) this.tail = prev;      this.size--;      return true;    }    return false;  }  reverse() {    let prev = null, cur = this.head;    this.tail = this.head;    while (cur) [cur.next, prev, cur] = [prev, cur, cur.next];    this.head = prev;    return this;  }  *[Symbol.iterator]() {    for (let cur = this.head; cur; cur = cur.next) yield cur.value;  }}class Queue {  #list = new LinkedList();  enqueue(x) { this.#list.append(x); return this; }  dequeue() {    const node = this.#list.head;    if (!node) return undefined;    this.#list.head = node.next;    if (!node.next) this.#list.tail = null;    this.#list.size--;    return node.value;  }  get size() { return this.#list.size; }}
  • Stack: an array with push and pop, both O(1). Queue: array.shift() can be O(n) on large arrays (V8 optimises many cases, so measure first); the linked version is O(1) with head and tail.
  • List: append O(1), remove O(n), reverse O(n) time, O(1) space. They check: empty list, one item, removing the head or tail (update tail!). More: DSA, Linked Lists and Stacks.
(Must know) Warm-ups: binary search, two-sum, anagram, palindrome.
JavaScript
function binarySearch(sorted, target) {  let lo = 0, hi = sorted.length - 1;  while (lo <= hi) {    const mid = lo + Math.floor((hi - lo) / 2);    if (sorted[mid] === target) return mid;    if (sorted[mid] < target) lo = mid + 1; else hi = mid - 1;  }  return -1;}function twoSum(nums, target) {  const seen = new Map();  for (let i = 0; i < nums.length; i++) {    const j = seen.get(target - nums[i]);    if (j !== undefined) return [j, i];    seen.set(nums[i], i);  }  return null;}
  • Binary search: O(log n), needs sorted input; lo <= hi with mid ± 1 avoids endless loops (DSA 28). Two-sum: one pass with a Map of value to index, O(n); check the complement before adding the current item (DSA 26).
  • Anagram: count letters in a Map (O(n)); spread [...s] so emoji count as one character. Palindrome: drop non-letters and digits, lowercase, two pointers (O(n)); ask about case and punctuation.
(Trap) What does JSON.stringify do with undefined, functions, NaN, Date, Map, BigInt and cycles? And JSON.parse?

All results were run in Node 22.

InputJSON.stringify result
{a: undefined, b() {}, d: 1}{"d":1} (dropped in objects)
[undefined, () => 1, NaN, -0][null,null,null,0]
{d: new Date(0), m: new Map([[1,2]])}{"d":"1970-01-01T00:00:00.000Z","m":{}}
{n: 1n} or a cyclethrows TypeError
{b:1, 2:1, a:1, 1:1}{"1":1,"2":1,"b":1,"a":1} (integer keys first)
undefinedundefined, not a string

toJSON(), a replacer (function or allow-list array) and an indent argument change the output. JSON.parse is strict: no trailing comma, no single quotes; '' and undefined throw SyntaxError. Numbers above 2^53 lose digits ('12345678901234567890' gives 12345678901234567000); a "__proto__" key becomes a normal own key (safe); a reviver can turn date strings back into Date. So JSON.parse(JSON.stringify(x)) is a poor deep clone.

(Senior signal) Implement JSON.stringify.
JavaScript
const ESCAPES = { '"': '\\"', '\\': '\\\\', '\b': '\\b', '\f': '\\f', '\n': '\\n', '\r': '\\r', '\t': '\\t' };const quote = (s) =>  '"' + s.replace(/["\\\u0000-\u001f]/g, (c) => ESCAPES[c] ?? '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')) + '"';function stringify(value, stack = []) {  if (value !== null && typeof value === 'object' && typeof value.toJSON === 'function') value = value.toJSON();  switch (typeof value) {    case 'string': return quote(value);    case 'number': return Number.isFinite(value) ? String(value) : 'null';    case 'boolean': return String(value);    case 'bigint': throw new TypeError('Do not know how to serialize a BigInt');    case 'undefined': case 'function': case 'symbol': return undefined;  }  if (value === null) return 'null';  if (stack.includes(value)) throw new TypeError('Converting circular structure to JSON');  stack.push(value);  let out;  if (Array.isArray(value)) {    out = '[' + value.map((v) => stringify(v, stack) ?? 'null').join(',') + ']';  } else {    const parts = [];    for (const k of Object.keys(value)) {      const s = stringify(value[k], stack);      if (s !== undefined) parts.push(quote(k) + ':' + s);    }    out = '{' + parts.join(',') + '}';  }  stack.pop();  return out;}

They check: toJSON first; undefined, functions and symbols vanish in objects but become null in arrays; non-finite numbers become null; string escaping; a cycle check with a stack (add before, remove after), so a value used twice is fine. I compared it with the real one on 25 inputs (a differential test). Left out, say so: replacer, indent, lone surrogates. Cost: O(n).

(Senior signal) Build a small template engine: Hi {{ user.name }}.
JavaScript
const escapeHtml = (s) =>  String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);// own properties only: {{constructor}} must not reach Object.prototypeconst lookup = (data, path) =>  path.replace(/\[(\w+)\]/g, '.$1').split('.').reduce(    (cur, key) => (cur != null && Object.hasOwn(Object(cur), key) ? cur[key] : undefined), data);function render(template, data) {  return template.replace(    /\{\{\{\s*([\w.[\]]+)\s*\}\}\}|\{\{\s*([\w.[\]]+)\s*\}\}/g,    (_, raw, escaped) => {      const value = lookup(data, raw ?? escaped);      if (value == null) return '';      return raw ? String(value) : escapeHtml(value);    });}

They check: nested paths; missing values print nothing (never undefined); 0 and false still print; HTML escaping by default, {{{raw}}} as opt-out (Mustache style). Trap I hit while testing: a lookup that follows the prototype chain printed function Object() { [native code] } for {{constructor}}, so only own properties are read. Never use eval or new Function on template text. {{#if}} and {{#each}} need a small parser, not one regex.

(Must know) Write event delegation.

Event delegation means attaching one listener to a parent and relying on bubbling (an event travels up from the clicked element through its parents) to handle clicks on many children, including children added later.

JavaScript
function delegate(root, type, selector, handler) {  const listener = (event) => {    const el = event.target instanceof Element ? event.target.closest(selector) : null;    if (el && root.contains(el)) handler.call(el, event, el);  };  root.addEventListener(type, listener);  return () => root.removeEventListener(type, listener);}

They check: event.target may be a child of the item (a <span> inside <li>), so use closest; check the match is inside root; return a clean-up function. Tested in Chromium (nested click, later-added item, match outside the root). Trap: focus and blur do not bubble; use focusin and focusout. event.target is where the event started; event.currentTarget holds the listener.

(Senior signal) Infinite scroll with IntersectionObserver.

An IntersectionObserver is a browser API that tells you when an element enters or leaves the visible area, with no scroll handler (Chrome 51, Firefox 55, Safari 12.1).

JavaScript
function infiniteScroll({ sentinel, loadPage, render, rootMargin = '200px' }) {  let page = 0, loading = false, done = false;  const io = new IntersectionObserver(async ([entry]) => {    if (!entry.isIntersecting || loading || done) return;    loading = true;    try {      const items = await loadPage(page);      if (items.length === 0) { done = true; io.disconnect(); return; }      render(items);      page++;    } catch (err) {      console.error(err); // allow a retry on the next intersection    } finally {      loading = false;    }    if (!done) { io.unobserve(sentinel); io.observe(sentinel); } // re-check: it may still be visible  }, { rootMargin });  io.observe(sentinel);  return () => io.disconnect();}

Put an empty sentinel after the list and load more when it becomes visible. They check: loading and done flags, retry after an error, rootMargin to load early, disconnect on clean-up. The senior trap: the observer fires when visibility changes. If a new page is short and the sentinel is still on screen, nothing fires again and loading stalls. In Chromium the naive version fired once; unobserve then observe asks for a fresh check, and loading continued until the sentinel left the screen. Follow-up: 10,000 items? Virtualise (render only visible rows).

(Must know) Write a tiny render function without a virtual DOM.
JavaScript
function el(tag, props, ...children) {  const node = document.createElement(tag);  for (const [key, value] of Object.entries(props ?? {})) {    if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value);    else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);    else if (value === true) node.setAttribute(key, '');    else if (value !== false && value != null) node.setAttribute(key, value);  }  for (const child of children.flat(Infinity)) {    if (child == null || child === false) continue;    node.append(child instanceof Node ? child : document.createTextNode(String(child)));  }  return node;}const render = (tree, container) => container.replaceChildren(tree);

el('li', { onClick }, 'Item ', 3) builds a real node. They check: nested child arrays, null and false skipped, and text through createTextNode, so <img onerror=...> shows as text and never runs (tested). Never put user data in innerHTML. Limit: replacing everything loses focus, scroll and typed text, which is why React diffs and uses keys. replaceChildren: Chrome 86, Firefox 78, Safari 14.

(Senior signal) Build an autocomplete that cancels old requests.
JavaScript
function autocomplete({ input, list, search, wait = 200 }) {  let controller;  const run = debounce(async (query) => {    const { signal } = (controller = new AbortController());    if (!query.trim()) return list.replaceChildren();    try {      const items = await search(query, signal);      if (signal.aborted) return;      list.replaceChildren(...items.map((text) => el('li', null, text)));    } catch (err) {      if (err.name !== 'AbortError') list.replaceChildren(el('li', { class: 'error' }, 'Search failed'));    }  }, wait);  input.addEventListener('input', () => {    controller?.abort();    run(input.value);  });}

They check: debounce typing; abort the previous request on each keystroke; ignore a stale answer even if the fetch ignores the signal; do not show AbortError as a failure. Chromium test of the worst case: a slow request for ab was in flight when abc was typed. The log was start:ab, abort:ab, start:abc, resolve:abc, and only abc results showed. Also say: accessibility (role="combobox", arrow keys), a small cache of queries, and with fetch pass { signal }.

(Trap) What do these print?

All outputs were run in Node 22.

JavaScript
console.log(['1', '2', '3'].map(parseInt));            // [ 1, NaN, NaN ]console.log([10, 9, 1].sort());                        // [ 1, 10, 9 ]console.log(0.1 + 0.2 === 0.3);                        // false (0.30000000000000004)console.log([NaN].includes(NaN), [NaN].indexOf(NaN)); // true -1[1, 2, 3].forEach(async (n) => { await null; log.push(n); }); // log is still [] on the next line
  • map passes (value, index, array), and parseInt(value, radix) takes the index as radix. Use .map(Number).
  • The default sort compares text; pass (a, b) => a - b.
  • 0.1 cannot be stored exactly: use a tolerance, or integers (cents).
  • includes treats NaN as equal; indexOf uses ===.
  • forEach ignores the returned promise. Use await Promise.all(arr.map(async ...)).

Key takeaways

  • Debounce waits for silence; throttle runs at most once per period. Keep this, arguments, cancel and a trailing call.
  • Cache keys, clones and equality are where answers fail: JSON.stringify merges null and undefined, cycles loop forever, structuredClone rejects functions.
  • Polyfills: holes (i in O), thisArg, explicit undefined initial value, new.target for bind.
  • Promises: first settle wins, then returns a new promise, all stores by index and does not cancel, any needs AggregateError.
  • Concurrency: pass task functions, run limit workers. Retry only idempotent work, with capped backoff and jitter. A timeout does not cancel work; an AbortSignal does.
  • DOM: delegate with closest, re-observe the sentinel, build text with createTextNode, abort and ignore stale responses.
  • Test first, say the complexity, name what you left out.

What's next

Practise the algorithm-heavy half of the round (trees, graphs, dynamic programming) in the DSA course. For the rules behind these answers, revisit Closures and Async JS and the Event Loop.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series