40 min read

Chapter 11 — The Polyfill Set

Every function they ask you to rebuild, the reference answer, and the edge case they are waiting for

Prerequisites: Chapter 03 — Promises in Real Code (the full MyPromise), Chapter 07 — Controlling Concurrency (debounce, throttle, pools). this, prototypes and closures are covered in FE-10. Practice: the gym, exercise 10-polyfill-kata.


How to Read This Chapter

This is a reference chapter. Each entry has four parts: the spec in a few lines, a reference implementation, the edge cases interviewers probe, and a one-line test you can say or type to prove it works. Every implementation here was run under node --test in Node 22 against the built-in where one exists (myFlat against Array.prototype.flat on a few hundred random nested arrays, myBind under new against native bind, the emitter against node:events, and so on).

In the interview, do not start typing. Say the spec back first, including the edge cases you plan to handle, then write it. Most lost points come from a case the candidate never mentioned, not from a syntax slip. When you finish, run your own one-line test out loud.

TermMeaning
HoleA missing index in a sparse array: [1, , 3] has no key 1. 1 in arr is false. Most array methods skip holes
Array-likeAn object with a length and numeric keys (arguments, a NodeList). Array methods work on them via .call
new.targetInside a function, the constructor new was applied to, or undefined for a plain call
ThenableAny object with a then method. Promise.resolve(x) adopts its state
SameValueZeroEquality used by Map, Set, includes: like === but NaN equals NaN
Single-flightConcurrent callers for the same key share one in-flight call instead of each starting one
DriftA repeating timer slipping later on each tick because each delay is measured from "now"
JitterA random part added to a retry delay so many clients do not retry at the same moment

Part 1: Functions and this

Function.prototype.call and apply

Spec. fn.call(thisArg, ...args) runs fn once with this set to thisArg. apply takes the arguments as an array or array-like. The trick: a function called as obj.method() gets this === obj, so attach fn to the object for one call.

js
Function.prototype.myCall = function (thisArg, ...args) {
  if (typeof this !== 'function') throw new TypeError('myCall must be called on a function');
  // Sloppy-mode rule: null/undefined become globalThis, primitives get boxed.
  const ctx = thisArg == null ? globalThis : Object(thisArg);
  const key = Symbol('fn'); // a Symbol cannot clash with the object's own keys
  ctx[key] = this;
  try {
    return ctx[key](...args); // called as a method, so `this` is ctx
  } finally {
    delete ctx[key];
  }
};

Function.prototype.myApply = function (thisArg, argsArray) {
  if (typeof this !== 'function') throw new TypeError('myApply must be called on a function');
  if (argsArray != null && typeof argsArray !== 'object') {
    throw new TypeError('CreateListFromArrayLike called on non-object');
  }
  const args = argsArray == null ? [] : Array.from(argsArray); // array-likes allowed
  return this.myCall(thisArg, ...args);
};

Edge cases they probe:

  • Why a Symbol key? A string key like 'fn' could overwrite a real property on the object.
  • delete in finally: the temporary key must go even when fn throws.
  • null / undefined become globalThis and primitives are boxed. That is sloppy-mode behaviour. A strict-mode function called with native call(null) sees this === null; this polyfill cannot reproduce that without Reflect.apply, which defeats the exercise. Say so.
  • A frozen object cannot take the temporary key, so myCall throws on it. Native call does not.
  • apply(ctx, 5) throws TypeError; apply(ctx) and apply(ctx, null) mean no arguments.

Test: Math.max.myApply(null, [1, 5, 2]) === 5

Function.prototype.bind, including new

Spec. Return a new function that calls the target with a fixed this and some arguments prepended. If the bound function is called with new, the fixed this is ignored and the target is constructed with the combined arguments.

js
Function.prototype.myBind = function (thisArg, ...preset) {
  const target = this;
  if (typeof target !== 'function') throw new TypeError('Bind must be called on a function');
  function bound(...later) {
    const args = [...preset, ...later];
    if (new.target) {
      // Called with `new`: thisArg is ignored and the target is constructed.
      // If new.target is `bound` itself, construct as if `new target(...)`.
      return Reflect.construct(target, args, new.target === bound ? target : new.target);
    }
    return target.apply(thisArg, args);
  }
  // Lets `obj instanceof bound` work. Native bound functions get this from the target.
  if (target.prototype) bound.prototype = target.prototype;
  Object.defineProperty(bound, 'name', { value: `bound ${target.name}` });
  Object.defineProperty(bound, 'length', { value: Math.max(0, target.length - preset.length) });
  return bound;
};

Edge cases they probe:

  • new on the bound function. new.target is set only for new calls. Reflect.construct also handles class targets and class Sub extends Bound, which the old ES5 answer cannot.
  • The ES5 answer is const isNew = this instanceof bound; target.apply(isNew ? this : thisArg, args) with bound.prototype = Object.create(target.prototype). Know it, and know its two flaws: it cannot construct a class, and it misfires if the bound function is called as a method of an object that happens to inherit from it.
  • Binding twice does not rebind: f.bind(a).bind(b) still uses a, because the outer bound function calls the inner one, which ignores its this.
  • length is the target's length minus the preset count, never negative; name is "bound f".
  • Arrow functions ignore thisArg entirely, bound or not.

Test: function P(x, y) { this.x = x; this.y = y }; const p = new (P.myBind({}, 1))(2); p.x === 1 && p instanceof P


Part 2: Arrays

All of these read length once, skip holes with i in O, and pass (value, index, array) to the callback. Those three details are what the interviewer checks.

map and filter

Spec. map returns an array of the same length with each present element transformed; holes stay holes. filter returns a dense array of the elements whose callback returned truthy.

js
Array.prototype.myMap = function (callback, thisArg) {
  if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
  const O = Object(this);
  const len = O.length >>> 0; // read once: elements pushed during the loop are not visited
  const out = new Array(len);
  for (let i = 0; i < len; i++) {
    if (i in O) out[i] = callback.call(thisArg, O[i], i, O); // holes stay holes
  }
  return out;
};

Array.prototype.myFilter = function (callback, thisArg) {
  if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
  const O = Object(this);
  const len = O.length >>> 0;
  const out = [];
  for (let i = 0; i < len; i++) {
    if (!(i in O)) continue; // holes are skipped
    const value = O[i]; // read before the callback, which may mutate the array
    if (callback.call(thisArg, value, i, O)) out.push(value);
  }
  return out;
};

Edge cases they probe:

  • [1, , 3].map(f) keeps the hole and never calls f for index 1. new Array(len) plus i in O does that; out.push would not.
  • Elements pushed during the loop are not visited, because len was read first.
  • thisArg is used for the callback (and ignored by arrow callbacks).
  • A non-function callback throws TypeError before any iteration.
  • Works on array-likes: Array.prototype.myMap.call({ length: 2, 0: 'a', 1: 'b' }, f).

Test: [1, , 3].myMap(x => x * 2) has length 3 and !(1 in result)

reduce, without an initial value

Spec. Fold the array left to right. With no initial value, the first present element is the seed and iteration starts after it. An empty array (or all holes) with no initial value throws TypeError.

js
Array.prototype.myReduce = function (callback, ...rest) {
  if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
  const O = Object(this);
  const len = O.length >>> 0;
  let i = 0;
  let acc;
  if (rest.length > 0) {
    acc = rest[0]; // an explicit `undefined` initial value still counts
  } else {
    while (i < len && !(i in O)) i++; // the first *present* element is the seed
    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 = callback(acc, O[i], i, O);
  }
  return acc;
};

Edge cases they probe:

  • "No initial value" means the argument was not passed, not that it is undefined. [].reduce(f, undefined) returns undefined; [].reduce(f) throws. Hence ...rest and rest.length, not init === undefined.
  • [, 2, , 4].reduce(add) seeds with 2 (index 1), not with a hole.
  • The callback gets (acc, value, index, array); the seed's index is never passed.

Test: [, 2, , 4].myReduce((a, b) => a + b) === 6

flat(depth) and flatMap

Spec. flat(depth = 1) returns a new array with nested arrays spread in, up to depth levels. Holes are dropped at every level. flatMap(f) is map(f) then flat(1), in one pass.

js
Array.prototype.myFlat = function (depth = 1) {
  const d = Math.trunc(Number(depth)) || 0; // NaN and null become 0; Infinity stays
  const out = [];
  const walk = (arr, level) => {
    for (let i = 0; i < arr.length; i++) {
      if (!(i in arr)) continue; // holes are removed at every level
      const v = arr[i];
      if (Array.isArray(v) && level > 0) walk(v, level - 1);
      else out.push(v);
    }
  };
  walk(Object(this), d);
  return out;
};

Array.prototype.myFlatMap = function (callback, thisArg) {
  if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
  const O = Object(this);
  const len = O.length >>> 0;
  const out = [];
  for (let i = 0; i < len; i++) {
    if (!(i in O)) continue;
    const v = callback.call(thisArg, O[i], i, O);
    if (Array.isArray(v)) {
      for (let j = 0; j < v.length; j++) if (j in v) out.push(v[j]); // one level only
    } else {
      out.push(v);
    }
  }
  return out;
};

Edge cases they probe:

  • depth coercion: undefined → 1; 0, negative, NaN, null → no flattening (holes are still removed); Infinity flattens fully; '2' → 2; 1.7 → 1.
  • Only real arrays are spread (Array.isArray), not array-likes or strings.
  • Recursion depth. For a 100 000-deep array, use an explicit stack: push [array, index, level] frames and loop. Mention it; write the recursive one unless asked.
  • flatMap flattens exactly one level: [1].flatMap(x => [[x]]) is [[1]].

Test: [1, [2, [3, [4]]]].myFlat(Infinity) deep-equals [1, 2, 3, 4]


Part 3: Objects and Prototypes

Object.create, instanceof, new

Spec. Object.create(proto) makes an empty object whose prototype is proto. a instanceof C walks a's prototype chain looking for C.prototype. new C(...args) creates an object linked to C.prototype, runs C with it as this, and returns it unless C returned an object.

js
function objectCreate(proto, properties) {
  if (proto !== null && typeof proto !== 'object' && typeof proto !== 'function') {
    throw new TypeError('Object prototype may only be an Object or null');
  }
  let obj;
  if (proto === null) {
    obj = {};
    Object.setPrototypeOf(obj, null); // the old ES5 shim could not do this
  } else {
    function F() {}
    F.prototype = proto;
    obj = new F();
  }
  if (properties !== undefined) Object.defineProperties(obj, properties);
  return obj;
}

function myInstanceOf(obj, Ctor) {
  if (typeof Ctor !== 'function') throw new TypeError("Right-hand side of 'instanceof' is not callable");
  if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) return false;
  const target = Ctor.prototype;
  if (target === null || (typeof target !== 'object' && typeof target !== 'function')) {
    throw new TypeError("Function has non-object prototype in instanceof check");
  }
  let p = Object.getPrototypeOf(obj);
  while (p !== null) {
    if (p === target) return true;
    p = Object.getPrototypeOf(p);
  }
  return false;
}

function myNew(Ctor, ...args) {
  if (typeof Ctor !== 'function') throw new TypeError(`${Ctor} is not a constructor`);
  const proto = Ctor.prototype;
  // 1. A fresh object linked to Ctor.prototype (Object.prototype if that is not an object).
  const obj = Object.create(proto !== null && typeof proto === 'object' ? proto : Object.prototype);
  // 2. Run the constructor with `this` = the new object.
  const result = Ctor.apply(obj, args);
  // 3. An object returned by the constructor wins; a primitive return is ignored.
  return result !== null && (typeof result === 'object' || typeof result === 'function') ? result : obj;
}

Edge cases they probe:

  • Object.create(null): the ES5 trick (F.prototype = proto; new F()) silently gives you Object.prototype when proto is null, because new falls back when F.prototype is not an object. That is why the null branch exists.
  • instanceof with a primitive on the left returns false (no throw); with a non-callable on the right it throws; with an arrow function on the right (no prototype) it throws.
  • Native instanceof checks C[Symbol.hasInstance] first. That is how class Even { static [Symbol.hasInstance](n) { return n % 2 === 0 } } makes 2 instanceof Even true. Mention it; the walk above is what they want.
  • instanceof fails across realms: an array from an iframe is not instanceof Array in the parent. That is why Array.isArray exists.
  • myNew with a constructor that returns a primitive ignores it; returning an object (or a function) replaces this.
  • myNew cannot construct a class (apply on a class throws "cannot be invoked without 'new'") and does not reject arrow functions the way native new does. Real code uses Reflect.construct.

Test: function A() {}; myInstanceOf(myNew(A), A) && !myInstanceOf(1, Number) && Object.getPrototypeOf(objectCreate(null)) === null

Deep clone

Spec. Return a copy that shares no mutable object with the input. Keep cycles and shared references as cycles and shared references. Copy Date, RegExp, Map, Set; keep prototypes.

js
function deepClone(value, seen = new WeakMap()) {
  if (value === null || typeof value !== 'object') return value; // primitives, and functions by reference
  if (seen.has(value)) return seen.get(value); // cycles and shared references

  if (value instanceof Date) return new Date(value.getTime());
  if (value instanceof RegExp) {
    const r = new RegExp(value.source, value.flags);
    r.lastIndex = value.lastIndex;
    return r;
  }
  if (value instanceof Map) {
    const m = new Map();
    seen.set(value, m); // register before recursing, or a cycle recurses forever
    value.forEach((v, k) => m.set(deepClone(k, seen), deepClone(v, seen)));
    return m;
  }
  if (value instanceof Set) {
    const s = new Set();
    seen.set(value, s);
    value.forEach((v) => s.add(deepClone(v, seen)));
    return s;
  }

  const out = Array.isArray(value) ? new Array(value.length) : Object.create(Object.getPrototypeOf(value));
  seen.set(value, out);
  for (const key of Reflect.ownKeys(value)) { // string and symbol keys
    if (Object.getOwnPropertyDescriptor(value, key).enumerable) {
      out[key] = deepClone(value[key], seen); // getters are read, not copied
    }
  }
  return out;
}

Edge cases they probe:

  • Cycles. The WeakMap maps original → copy. It must be filled before recursing into children, or a.self = a recurses forever.
  • Shared references: { a: x, b: x } should give a copy where copy.a === copy.b. The same map does this.
  • JSON.parse(JSON.stringify(x)) loses Date (becomes a string), Map/Set (become {}), undefined and functions (dropped), NaN (becomes null), and throws on cycles.
  • Not handled here: typed arrays, ArrayBuffer, Error, DOM nodes, private class fields (#x cannot be copied by any outside code).

Compared with structuredClone (built into Node 17+ and browsers):

deepClone abovestructuredClone
Cycles, Date, RegExp, Map, SetYesYes
Class instancesKeeps the prototypeDrops it: you get a plain object
FunctionsCopied by referenceThrows DataCloneError
Symbol keys, gettersCopies symbol keys; reads gettersDrops symbol keys; reads getters
Typed arrays, Error, BlobNoYes

In production code, prefer structuredClone for plain data. Write your own only when you need prototypes kept or functions passed through.

Test: const a = { d: new Date(0) }; a.self = a; const b = deepClone(a); b.self === b && b.d !== a.d

Deep equal

Spec. Two values are deeply equal if they are the same primitive, or objects with the same prototype and deeply equal own enumerable properties. Handle arrays, Date, RegExp, Map, Set and cycles.

js
function deepEqual(a, b, seen = new Map()) {
  if (Object.is(a, b)) return true; // NaN equals NaN; +0 and -0 differ
  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; // already comparing this pair further up: a cycle
  seen.set(a, b);

  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
  if (a instanceof RegExp) return a.source === b.source && a.flags === b.flags;
  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; // members compared by identity
    return true;
  }
  if (Array.isArray(a) && a.length !== b.length) return false;

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
  for (const k of keysA) {
    if (!Object.hasOwn(b, k) || !deepEqual(a[k], b[k], seen)) return false;
  }
  return true;
}

Edge cases they probe:

  • NaN vs NaN (equal by Object.is), 0 vs -0 (not equal by Object.is; equal by ===). State which rule you chose.
  • { a: undefined } vs {}: different key counts, so not equal. [1, , 3] vs [1, undefined, 3]: also not equal.
  • [] vs {}: different prototypes.
  • Cycles: the seen map records pairs being compared; meeting the same pair again means "equal so far".
  • Set members and Map keys are compared by identity here. A deep comparison of object members is O(n²). Say so instead of writing it.
  • One deliberate difference from Node: two Invalid Dates are equal here; util.isDeepStrictEqual says they are not. Every other case in the test suite matches Node.

Test: deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) && !deepEqual([1, 2], [2, 1])


Part 4: Function Wrappers

once

Spec. Return a function that runs fn the first time and returns that first result on every later call.

js
function once(fn) {
  let called = false;
  let result;
  return function (...args) {
    if (!called) {
      called = true; // set first: a re-entrant call or a throw does not run fn again
      result = fn.apply(this, args);
      fn = undefined; // let the closure release fn
    }
    return result;
  };
}

Edge cases they probe:

  • Set the flag before calling fn. If fn re-enters the wrapper (a handler that triggers itself), it must not run twice.
  • If fn throws, does the next call retry? Here: no, it returns undefined. Some teams want a retry. Ask which.
  • Forward this and arguments; release fn so a large closure can be collected.

Test: let n = 0; const f = once(() => ++n); f(); f(); n === 1

memoize, and async memoize

Spec. Cache results by a key derived from the arguments. For async functions, cache the promise so concurrent callers share one call, and do not keep a failed promise.

js
function memoize(fn, keyOf = (...args) => JSON.stringify(args)) {
  const cache = new Map();
  return function (...args) {
    const key = keyOf(...args);
    if (cache.has(key)) return cache.get(key); // has(), not get(): undefined is a valid result
    const value = fn.apply(this, args);
    cache.set(key, value);
    return value;
  };
}

function memoizeAsync(fn, { keyOf = (...args) => JSON.stringify(args), ttlMs = Infinity } = {}) {
  const cache = new Map(); // key -> { promise, expires }
  return function (...args) {
    const key = keyOf(...args);
    const hit = cache.get(key);
    if (hit && hit.expires > Date.now()) return hit.promise;

    // Cache the promise, not the value: concurrent callers share one call.
    // new Promise(...) turns a synchronous throw from fn into a rejection.
    const promise = new Promise((resolve) => resolve(fn.apply(this, args)));
    cache.set(key, { promise, expires: Date.now() + ttlMs });
    promise.catch(() => {
      // Do not cache failures. Check identity: a newer call may have replaced the entry.
      if (cache.get(key)?.promise === promise) cache.delete(key);
    });
    return promise;
  };
}

Edge cases they probe:

  • Key strategy. JSON.stringify(args) treats {a:1, b:2} and {b:2, a:1} as different keys, drops functions, turns undefined into null inside arrays, throws on cycles and BigInt. For object arguments, pass a keyOf (such as user => user.id) or key a WeakMap by identity.
  • cache.has(key), not cache.get(key) !== undefined: undefined can be a valid result.
  • Caching the promise, not the value. If you await first and then store, ten concurrent calls start ten requests (a stampede). Storing the promise makes them share one.
  • Rejected promises. Cache a rejection and every later call fails forever (the gym, exercise 06-cache-layer). The .catch removes it.
  • The identity check. Without cache.get(key)?.promise === promise, an old rejected call can delete a newer, healthy entry. The timeline below shows it.
  • Unbounded growth: a Map that is only ever added to is a memory leak in a long-running server. Add a TTL, an LRU limit, or both.

Step through the case the identity check exists for:

#
Call A
Call B
cache['user:7']
1
fn() starts, pA stored
pA
1 / 8

Test: let calls = 0; const f = memoizeAsync(async (x) => (calls++, x)); await Promise.all([f(1), f(1)]); calls === 1

curry, variadic curry, compose / pipe

Spec. curry(f) returns a function that collects arguments across calls until it has f.length of them, then calls f. A variadic curry collects until it is called with no arguments. pipe(f, g, h)(x) is h(g(f(x))); compose runs right to left.

js
function curry(fn, arity = fn.length) {
  return function curried(...args) {
    if (args.length >= arity) return fn.apply(this, args);
    return (...more) => curried.apply(this, [...args, ...more]);
  };
}

function curryVariadic(fn) {
  return function next(...args) {
    return (...more) => (more.length === 0 ? fn(...args) : next(...args, ...more));
  };
}

const pipe = (...fns) => (...args) =>
  fns.length === 0 ? args[0] : fns.slice(1).reduce((acc, f) => f(acc), fns[0](...args));

const compose = (...fns) => pipe(...fns.slice().reverse());

Edge cases they probe:

  • fn.length does not count rest parameters or parameters after the first default. curry((a, b = 1) => …) has arity 1. Hence the arity parameter.
  • Partials must be reusable: const add1 = add(1); add1(2)(3); add1(10)(10) both work, because each call builds a new argument array instead of mutating a shared one. A version that pushes into one closed-over array fails this.
  • The classic sum(1)(2)(3) with no terminator needs either a final () (above) or a valueOf / toString override, which only works when the result is coerced. Say which you are writing.
  • pipe() with no functions is the identity. The first function in a pipe may take several arguments; the rest take one.
  • Async pipe: fns.reduce((p, f) => p.then(f), Promise.resolve(x)).

Test: pipe(x => x + 1, x => x * 2)(3) === 8 && compose(x => x + 1, x => x * 2)(3) === 7


Part 5: Events and Callbacks

EventEmitter

Spec. on(name, fn) adds a listener, off(name, fn) removes one, once(name, fn) adds one that removes itself before its first call, emit(name, ...args) calls the listeners in order, synchronously, and returns whether there were any. emit('error') with no listener throws.

js
class EventEmitter {
  #events = new Map(); // name -> array of listeners

  on(name, listener) {
    if (typeof listener !== 'function') throw new TypeError('listener must be a function');
    if (!this.#events.has(name)) this.#events.set(name, []);
    this.#events.get(name).push(listener);
    return this;
  }

  off(name, listener) {
    const list = this.#events.get(name);
    if (!list) return this;
    // Match a once() wrapper by its original listener. Remove the most recent match.
    const i = list.findLastIndex((l) => l === listener || l.listener === listener);
    if (i !== -1) list.splice(i, 1);
    if (list.length === 0) this.#events.delete(name);
    return this;
  }

  once(name, listener) {
    const wrapper = (...args) => {
      this.off(name, wrapper); // remove before calling, so a re-emit inside does not re-run it
      listener.apply(this, args);
    };
    wrapper.listener = listener;
    return this.on(name, wrapper);
  }

  emit(name, ...args) {
    const list = this.#events.get(name);
    if (!list || list.length === 0) {
      if (name === 'error') {
        const err = args[0];
        throw err instanceof Error ? err : new Error(`Unhandled error. (${err})`);
      }
      return false;
    }
    // Iterate a copy: on/off during this emit affect the next emit, not this one.
    for (const listener of [...list]) listener.apply(this, args);
    return true;
  }

  listenerCount(name) {
    return this.#events.get(name)?.length ?? 0;
  }
}

Edge cases they probe:

  • Removing a listener during emit. If emit loops over the live array and listener A calls off(B), the splice shifts the array and the loop skips whichever listener came after A. Iterating a copy fixes it and matches Node: a listener removed during an emit still runs in that emit, and one added during an emit first runs on the next. The gym, exercise 07-event-bus, has this bug.
  • off for a once listener. The array holds the wrapper, not your function, so off(name, fn) must also match wrapper.listener.
  • once removes before calling. If the listener emits the same event again, it must not run a second time.
  • The error event. No listener → throw. In Node, an unhandled 'error' on a socket or stream crashes the process; that is by design.
  • A listener that throws stops the remaining listeners and the throw reaches the emit caller. Node behaves the same. If the interviewer wants isolation, wrap each call in try/catch and report errors after the loop.
  • Leaks. Every on without an off keeps the listener and its closure alive. Node warns past 10 listeners per event (setMaxListeners). In React, on in useEffect without returning off adds one more on every mount.
  • emit is synchronous. It is not a queue: there is no retry, no persistence, and a slow listener blocks the emitter.

Test: const log = []; const e = new EventEmitter(); const b = () => log.push('b'); e.on('x', () => e.off('x', b)).on('x', b); e.emit('x'); e.emit('x'); leaves log as ['b'].

promisify

Spec. Turn a Node-style function whose last argument is (err, value) => … into one that returns a promise.

js
function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      // A sync throw inside the executor becomes a rejection.
      // A callback called twice is harmless: a promise settles once.
      fn.call(this, ...args, (err, value) => {
        if (err) reject(err);
        else resolve(value);
      });
    });
  };
}

Edge cases they probe:

  • this is forwarded: promisify(obj.method) called as obj.pm() still sees obj. A version using an arrow function for the returned wrapper loses it.
  • A callback called twice (a common bug in legacy modules) is harmless: the promise settles once and ignores the rest. A synchronous throw becomes a rejection because it happens inside the executor.
  • Multiple success values (cb(null, a, b)): Node's util.promisify keeps only the first. Functions such as dns.lookup set util.promisify.custom to return an object instead. Mention the symbol.
  • if (err): a falsy error such as 0 is treated as success. That matches Node.
  • Do not promisify a function that takes its callback in the first position (setTimeout(cb, ms)); Node's timers/promises exists for that.

Test: await promisify((a, b, cb) => cb(null, a + b))(1, 2) === 3


Part 6: Promise Combinators

These four are the most-asked polyfills after debounce. Chapter 03 builds a full MyPromise; here the combinators are written against the built-in Promise.

Resolves whenRejects whenEmpty input
allevery input fulfils (values in input order)the first input rejectsresolves []
allSettledevery input settlesneverresolves []
racethe first input settles, either way(same)pending forever
anythe first input fulfilsevery input rejects (AggregateError)rejects AggregateError
js
function promiseAll(iterable) {
  return new Promise((resolve, reject) => {
    const results = [];
    let pending = 0;
    let index = 0;
    for (const item of iterable) {
      const i = index++; // capture the position: results keep input order
      pending++;
      Promise.resolve(item).then((value) => {
        results[i] = value;
        if (--pending === 0) resolve(results);
      }, reject); // first rejection wins; later ones are ignored
    }
    if (pending === 0) resolve(results); // empty input
  });
}

function promiseAllSettled(iterable) {
  return new Promise((resolve) => {
    const results = [];
    let pending = 0;
    let index = 0;
    const settle = (i, outcome) => {
      results[i] = outcome;
      if (--pending === 0) resolve(results);
    };
    for (const item of iterable) {
      const i = index++;
      pending++;
      Promise.resolve(item).then(
        (value) => settle(i, { status: 'fulfilled', value }),
        (reason) => settle(i, { status: 'rejected', reason }),
      );
    }
    if (pending === 0) resolve(results);
  });
}

function promiseRace(iterable) {
  return new Promise((resolve, reject) => {
    for (const item of iterable) Promise.resolve(item).then(resolve, reject);
    // Empty input: never settles. That is the spec.
  });
}

function promiseAny(iterable) {
  return new Promise((resolve, reject) => {
    const errors = [];
    let pending = 0;
    let index = 0;
    for (const item of iterable) {
      const i = index++;
      pending++;
      Promise.resolve(item).then(resolve, (reason) => {
        errors[i] = reason;
        if (--pending === 0) reject(new AggregateError(errors, 'All promises were rejected'));
      });
    }
    if (pending === 0) reject(new AggregateError([], 'All promises were rejected'));
  });
}

Edge cases they probe:

  • Order. results.push(value) gives completion order. Capture i per item and write results[i].
  • Counting. Do not check results.length === n: assigning results[4] first makes the length 5 while four slots are empty. Count down a pending counter.
  • Empty input. Without the final if (pending === 0), all([]) never resolves.
  • Non-promise items. Promise.resolve(item) wraps plain values and adopts thenables. Calling item.then directly crashes on 3.
  • Any iterable, not just arrays: a Set, a generator. Use for…of, not .length and an index. A non-iterable makes for…of throw inside the executor, which becomes the rejection, as in native.
  • Fail-fast is not cancel. When all rejects, the other inputs keep running and their results are dropped. To stop them, pass an AbortSignal into the work (Chapter 06).
  • any keeps errors in input order on err.errors.

Test: await promiseAll([sleep(20).then(() => 'a'), 'b']) deep-equals ['a', 'b']


Part 7: Timing

setInterval via setTimeout

Spec. Call fn every ms until stopped. Two properties native setInterval lacks: an async fn never overlaps with itself, and ticks stay on the start + n × ms grid instead of drifting.

The simple answer is recursive setTimeout:

js
function every(fn, ms) {
  let timer;
  const tick = async () => { await fn(); timer = setTimeout(tick, ms); }; // next one only after this one ends
  timer = setTimeout(tick, ms);
  return () => clearTimeout(timer);
}

It fixes overlap, but has three flaws. It drifts: each tick lands ms after the previous one finished. One rejection skips the reschedule, so the loop stops silently, and the unhandled rejection crashes a Node process by default. And stop() called while fn is running does nothing: the timer it clears has already fired, and the line after the await schedules a new one. The full version:

js
function setIntervalAsync(fn, ms, { onError = console.error } = {}) {
  const start = performance.now();
  let n = 0;
  let timer;
  let stopped = false;

  function schedule() {
    n++;
    // Aim at start + n*ms, not "now + ms", so lateness does not add up (drift).
    let target = start + n * ms;
    const now = performance.now();
    if (target < now) {
      n = Math.ceil((now - start) / ms); // the callback overran: skip missed ticks, no burst
      target = start + n * ms;
    }
    timer = setTimeout(run, target - now);
  }

  async function run() {
    try {
      await fn(); // the next tick is scheduled only after this one finishes: no overlap
    } catch (err) {
      onError(err);
    } finally {
      if (!stopped) schedule();
    }
  }

  schedule();
  return function stop() {
    stopped = true;
    clearTimeout(timer);
  };
}

Edge cases they probe:

  • Overlap. Native setInterval(async () => …, 1000) starts a new run every second even if the last one is still awaiting a 5-second query. A cron-like sweeper then runs five copies at once.
  • Drift. Measure from the start time, not from "now". Timers fire late, never early, so "now + ms" accumulates the lateness.
  • Overrun. If a run takes longer than ms, skip the missed ticks instead of firing several back to back.
  • Stop while running. The stopped flag is checked after fn settles, so stop() called from inside fn or during the await works.
  • Errors. Decide: keep going and report (here), or stop. Never leave it as an unhandled rejection.
  • performance.now() is monotonic; Date.now() can jump when the system clock is adjusted.

Test: an async fn that takes 25 ms on a 10 ms interval never has two runs active at once.

debounce and throttle

Spec. debounce(fn, wait) calls fn once, wait ms after the last call in a burst, with the latest arguments. throttle(fn, wait) calls fn at most once per wait ms: immediately on the first call (leading edge) and once more at the end of the window if calls arrived during it (trailing edge). Chapter 07 covers when to use which.

js
function debounce(fn, wait) {
  let timer;
  let lastArgs;
  let lastThis;
  function debounced(...args) {
    lastArgs = args;
    lastThis = this;
    clearTimeout(timer); // every call restarts the wait
    timer = setTimeout(() => {
      timer = undefined;
      fn.apply(lastThis, lastArgs); // trailing edge, latest arguments
    }, wait);
  }
  debounced.cancel = () => {
    clearTimeout(timer);
    timer = undefined;
  };
  debounced.flush = () => {
    if (timer === undefined) return;
    clearTimeout(timer);
    timer = undefined;
    fn.apply(lastThis, lastArgs);
  };
  return debounced;
}

function throttle(fn, wait) {
  let last = -Infinity; // time of the last real call
  let timer;
  let pendingArgs;
  let pendingThis;

  function invoke() {
    last = Date.now();
    timer = undefined;
    const args = pendingArgs;
    const ctx = pendingThis;
    pendingArgs = pendingThis = undefined;
    fn.apply(ctx, args);
  }

  function throttled(...args) {
    pendingArgs = args; // the trailing call uses the latest arguments
    pendingThis = this;
    const remaining = wait - (Date.now() - last);
    if (remaining <= 0) {
      clearTimeout(timer);
      invoke(); // leading edge
    } else if (timer === undefined) {
      timer = setTimeout(invoke, remaining); // one trailing call per window
    }
  }
  throttled.cancel = () => {
    clearTimeout(timer);
    timer = undefined;
    pendingArgs = pendingThis = undefined;
    last = -Infinity;
  };
  return throttled;
}

Try both against a burst of keystrokes and scroll events. Drag the wait slider and watch which events actually call the handler:

Loading simulation…

Edge cases they probe:

  • this and the latest arguments. Capture both on every call; an arrow function as the returned wrapper loses this.
  • cancel() (component unmount) and flush() (save the draft before navigating away). An interviewer often asks for them as the follow-up.
  • Leading vs trailing options. Leading-only debounce is a "first click wins" guard for a submit button.
  • Throttle's trailing call must not fire immediately after a leading call. The remaining calculation schedules it at the end of the window.
  • A debounced async search does not cancel the request already in flight. Debounce cuts the number of calls; only an AbortController or a "latest wins" check stops a stale response from landing (Chapter 05).
  • In React, debounce(fn) inside the component body makes a new debounced function on every render, so nothing is ever debounced. Keep it in a useRef or useMemo.

Test: with fake timers, d(1); tick(50); d(2); tick(100) calls fn once, with 2.

asyncPool (concurrency limit)

Spec. Run fn(item) for every item with at most limit running at once. Resolve with the results in input order. Reject on the first failure.

js
async function asyncPool(limit, items, fn) {
  if (!(limit >= 1)) throw new RangeError('limit must be at least 1');
  const results = new Array(items.length);
  let next = 0;
  let failed = false;
  async function worker() {
    while (!failed && next < items.length) {
      const i = next++; // claimed synchronously, so two workers never take the same item
      try {
        results[i] = await fn(items[i], i);
      } catch (err) {
        failed = true; // stop the other workers from starting new items
        throw err;
      }
    }
  }
  const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
  await Promise.all(workers);
  return results;
}

Edge cases they probe:

  • The claim must be synchronous. const i = next++ happens before any await, so two workers never take the same index. Put an await between reading next and incrementing it and you have a check-then-act race inside a single thread.
  • Fail-fast does not stop the workers. Promise.all(workers) rejects on the first error, but the other workers would keep pulling items. The failed flag stops them from starting new ones; the ones already running still finish. True cancellation needs a signal.
  • limit larger than the number of items: start only as many workers as items. limit of 0 would resolve immediately with an empty array of holes, so reject it.
  • An off-by-one (<= instead of <) in the worker loop runs fn(undefined). That is the gym, exercise 04-job-runner.
  • For a lazy source (a generator of 10 million IDs), share one iterator between the workers instead of indexing an array.

See the difference between serial, unbounded and pooled execution against a rate-limited API:

Loading simulation…

Test: five tasks with limit = 2 never have more than two active, and the results come back in input order.

sleep and retry

Spec. sleep(ms) resolves after ms, and rejects early if its AbortSignal fires. retry(fn) calls fn, and on failure waits and tries again, up to retries more times, with exponential backoff and jitter.

js
function sleep(ms, { signal } = {}) {
  return 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 });
  });
}

async function retry(fn, { retries = 3, baseMs = 100, maxMs = 5_000, shouldRetry = () => true, signal } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn(attempt); // `return await`: a rejection must land in this catch
    } catch (err) {
      if (attempt >= retries || !shouldRetry(err) || signal?.aborted) throw err;
      const cap = Math.min(maxMs, baseMs * 2 ** attempt); // exponential backoff
      await sleep(Math.random() * cap, { signal }); // full jitter
    }
  }
}

Edge cases they probe:

  • return await fn() inside try. Without await, the rejected promise is returned, not thrown, and the catch never sees it.
  • retries: 3 means up to 4 attempts. Say which you mean.
  • Do not retry everything. A 400 or a validation error fails the same way every time. shouldRetry should allow timeouts, 429 and 5xx. Honour Retry-After when the server sends it.
  • Full jitter (random() × cap) spreads clients out. A fixed delay makes every client that failed together retry together (a retry storm); the concurrency-pool sim's Retry storm tab shows it.
  • Retrying a non-idempotent call (charging a card, debiting credits, a paid LLM call) can do it twice. Send an idempotency key the server deduplicates on.
  • sleep removes its abort listener when it resolves; otherwise a long-lived signal collects one listener per sleep.
  • Each attempt needs its own timeout. retry around a call that hangs forever waits forever.

Test: a function that fails twice then returns 'ok' resolves to 'ok' with retry(fn, { baseMs: 1 }) after 3 calls.


Part 8: Serialisation and the DOM

A JSON.stringify subset

Spec. Strings, numbers, booleans, null, arrays, plain objects, toJSON. No replacer or space.

js
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, key = '', stack = []) {
  if (value !== null && typeof value === 'object' && typeof value.toJSON === 'function') {
    value = value.toJSON(key); // Date has toJSON
  }
  if (value === null) return 'null';
  switch (typeof value) {
    case 'string': return quote(value);
    case 'number': return Number.isFinite(value) ? String(value) : 'null'; // NaN, Infinity
    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; // omitted in objects, null in arrays
  }
  if (stack.includes(value)) throw new TypeError('Converting circular structure to JSON');
  stack.push(value);
  let out;
  if (Array.isArray(value)) {
    // Array.from, not map: map skips holes and join would print them as empty.
    out = '[' + Array.from(value, (v, i) => stringify(v, String(i), stack) ?? 'null').join(',') + ']';
  } else {
    const parts = [];
    for (const k of Object.keys(value)) {
      const s = stringify(value[k], k, stack);
      if (s !== undefined) parts.push(quote(k) + ':' + s);
    }
    out = '{' + parts.join(',') + '}';
  }
  stack.pop(); // the same object twice in different branches is fine; only a cycle throws
  return out;
}

Edge cases they probe:

  • undefined, functions and symbols are omitted as object values but become null in arrays; stringify(undefined) returns undefined, not a string.
  • NaN and ±Infinity become null; -0 becomes "0"; BigInt throws.
  • Holes in arrays become null. value.map(…) skips holes and join prints them as nothing, giving [1,,3], which is invalid JSON. Array.from visits every index.
  • Cycles throw. Use a stack of ancestors, not a set of everything seen: the same object appearing twice in different branches is legal.
  • Not handled here: boxed primitives (new Number(1) should print 1), lone surrogates (escaped as \udXXX by the native), replacer, space.

Test: stringify({ a: [1, undefined, () => 1], b: undefined, d: new Date(0) }) === JSON.stringify(same)

getElementsByClassName-style traversal

Spec. Return every descendant element of root that has all the given classes, in document order.

js
function getByClassName(root, classNames) {
  const wanted = classNames.split(/\s+/).filter(Boolean);
  const out = [];
  if (wanted.length === 0) return out;
  const walk = (node) => {
    for (const child of node.children) { // element children only, in document order
      if (wanted.every((c) => child.classList.contains(c))) out.push(child);
      walk(child); // the root itself is never included
    }
  };
  walk(root);
  return out;
}

Edge cases they probe:

  • node.children (elements only), not childNodes (includes text and comment nodes).
  • The root itself is not included; that matches the native method.
  • Several classes: 'a b' means both, in any order on the element. Split on whitespace and drop empty strings.
  • Native returns a live HTMLCollection that updates as the DOM changes; this returns a static array.
  • Very deep trees: switch to an explicit stack. Push children in reverse so they pop in document order.

Test: getByClassName(document.body, 'card active').every(el => el.classList.contains('card'))


Spot the Bug

Drill 1 — The admin panel's bulk export

js
function promiseAll(promises) {
  return new Promise((resolve, reject) => {
    const results = [];
    promises.forEach((p) => {
      p.then((value) => {
        results.push(value);
        if (results.length === promises.length) resolve(results);
      }).catch(reject);
    });
  });
}

const [user, invoices, usage] = await promiseAll([
  fetchUser(id), fetchInvoices(id), fetchUsage(id),
]);

What's wrong?

Answer

Four bugs.

  1. Order. push stores values in completion order. If fetchUsage returns first, user holds the usage data. The admin panel shows the wrong columns, only when the network is slow in a particular way.
  2. Empty input never resolves: forEach does nothing and resolve is never called.
  3. Plain values fail: p.then on a non-promise throws TypeError. The throw happens inside the executor, so one plain value rejects the whole call.
  4. Only arrays. promises.length and forEach fail for a Set or a generator.
js
for (const item of iterable) {
  const i = index++;
  pending++;
  Promise.resolve(item).then((v) => { results[i] = v; if (--pending === 0) resolve(results); }, reject);
}
if (pending === 0) resolve(results);

Say it: "push records completion order, not input order; I capture the index per item, count down a pending counter instead of trusting length, and resolve immediately on empty input."

Drill 2 — The pricing lookup

js
const cache = new Map();

export async function getPlanPrice(planId) {
  if (cache.has(planId)) return cache.get(planId);
  const price = await fetch(`${BILLING_URL}/plans/${planId}`).then((r) => r.json());
  cache.set(planId, price);
  return price;
}

At 09:00 a marketing email goes out and the billing service's request rate jumps 400×. What's wrong?

Answer

No single-flight. The value is stored only after the await. Every request that arrives while the first fetch is in flight sees cache.has(planId) === false and starts its own fetch. It is check-then-act across an await, the same shape as SELECT then UPDATE without a lock. Also: no TTL, so a price change is never seen, and r.ok is never checked, so a 500 error body gets cached as the price.

js
export function getPlanPrice(planId) {
  if (!cache.has(planId)) {
    const p = fetch(`${BILLING_URL}/plans/${planId}`).then((r) => {
      if (!r.ok) throw new Error(`billing ${r.status}`);
      return r.json();
    });
    cache.set(planId, p); // store the promise synchronously, before any await
    p.catch(() => { if (cache.get(planId) === p) cache.delete(planId); });
  }
  return cache.get(planId);
}

Add a TTL as in memoizeAsync above.

Say it: "The cache is filled after the await, so concurrent misses each go to the source. I cache the promise synchronously so they share one request, and I evict it if it rejects."

Drill 3 — The bound handler

js
Function.prototype.bind2 = function (ctx, ...preset) {
  const fn = this;
  return (...args) => fn.apply(ctx, [...preset, ...args]);
};

function Job(name) { this.name = name; this.createdAt = Date.now(); }
const BoundJob = Job.bind2({ tenant: 'acme' });
const job = new BoundJob('nightly-export');

What happens on the last line?

Answer

TypeError: BoundJob is not a constructor. The returned function is an arrow function, and arrow functions cannot be called with new. Even with a regular function, the body ignores new and runs Job with this = { tenant: 'acme' }, so name and createdAt are written onto the shared ctx object and job is an empty object. Every job overwrites the same ctx.

Fix: use a regular function, and check new.target:

js
function bound(...later) {
  const args = [...preset, ...later];
  if (new.target) return Reflect.construct(fn, args, new.target === bound ? fn : new.target);
  return fn.apply(ctx, args);
}
if (fn.prototype) bound.prototype = fn.prototype;
return bound;

Say it: "A bound function called with new must ignore the bound this and construct the target. An arrow function can't be a constructor at all, so I return a regular function and branch on new.target."


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If Promise.all stores results with push
  • If a memoized async function stores the value after await
  • If a rejected promise stays in the cache
  • If emit iterates the live listener array and a listener removes itself
  • If setInterval runs an async job that can take longer than the interval
  • If retry retries a non-idempotent call on timeout
  • If bind returns an arrow function
  • If reduce treats undefined as "no initial value"

Review Checklist

When a codebase has its own versions of these helpers, check:

  1. Hand-rolled Promise.all or pool: is input order kept? Is empty input handled? Does a failure stop new work?
  2. Every memoize or cache helper: is the promise cached (single-flight)? Are rejections evicted? Is there a TTL or size bound? What is the key, and can two different inputs share it?
  3. Every EventEmitter or pub/sub: is there an off for each on? Does emit iterate a copy? Is there an 'error' listener on every stream and socket?
  4. setInterval with an async callback: can runs overlap? What happens on rejection?
  5. retry helpers: backoff with jitter? A cap? Which errors are retried? Is the operation idempotent? Is there a per-attempt timeout?
  6. debounce / throttle in React: created once (useRef / useMemo), cancelled on unmount?
  7. deepClone via JSON.parse(JSON.stringify(x)): are there Dates, Maps or undefineds in that data?
  8. promisify wrappers: do they keep this? Does the wrapped function ever call its callback twice?

If They Ask X, the Trap Is Y

If they askThe trap is
call / applyA string temp key that overwrites a real property; not deleting it when fn throws
bindnew on the bound function; returning an arrow function; partial args order
map / filterHoles; reading length once; thisArg
reduceNo initial value vs undefined initial value; holes before the seed; empty array
flatdepth coercion (Infinity, 0, NaN); holes removed at every level
Object.createnull prototype
instanceofPrimitives on the left; Symbol.hasInstance; cross-realm arrays
newA constructor that returns an object; class targets
Deep cloneCycles (register before recursing); Date, Map, Set; shared references
Deep equalNaN, -0; { a: undefined } vs {}; cycles; arrays vs objects
onceRe-entrant call before the flag is set; what happens after a throw
memoizeKey collisions from JSON.stringify; undefined results; unbounded growth
Async memoizeCaching the value after await (stampede); caching rejections
curryfn.length with defaults and rest; partials sharing one mutable array
EventEmitterRemoving during emit; off for a once listener; unhandled 'error'
promisifyLosing this; multiple callback values
Promise.allpush order; empty input; plain values; non-array iterables
Promise.raceEmpty input never settles; losers keep running
Promise.anyAggregateError; empty input rejects
setInterval via setTimeoutOverlap of async callbacks; drift; stopping mid-run
debounce / throttlethis and latest args; cancel; trailing call timing; new instance per React render
asyncPoolawait between read and increment of the index; fail-fast not stopping workers
retryreturn without await in try; retrying 4xx; no jitter; non-idempotent calls
JSON.stringifyundefined in objects vs arrays; holes; cycles vs repeated references
DOM traversalchildNodes vs children; including the root; multiple classes

What's Next

Chapter 12 — The Gym has the practice repos. Start with exercise 10-polyfill-kata: stubs and tests for Promise.all / allSettled / any / race, debounce, throttle, bind, asyncPool, promisify and retry. Write each one cold, in under ten minutes, before checking it against this chapter.