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.
On this page
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
AbortSignalis an object that tells running work "stop now"; anAbortControllercreates 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 asundefined. - 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
- Repeat the task and ask two questions. "Should the first call run at once? What if the input is
null?" - Write an example and a test first. Three lines of expected output prove it works.
- Think aloud. Say the plan before you type.
- Write the simple version first, then add edge cases one at a time.
- Name things clearly.
lastArgs,pending,resultsbeata,x,tmp. - Say the complexity without being asked.
- Trace your code by hand with the first example before you say "done".
- Name what you left out. If you are stuck, say so, write the slow version, then improve it. Silence is the worst answer.
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.
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.
canceldrops the pending call;flushruns it now.- Cost: O(1). Mistakes: an arrow function as
debounced(losesthis); nocancelon unmount. - Test it with
mock.timers.enable({ apis: ['setTimeout', 'Date'] })fromnode:testandtick(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.
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?
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).
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]", sof(undefined)returns the cachedf(null)(tested). Object key order changes the key; a circular object throws. A customresolverfixes these.- A
Mapgrows forever: add a size limit (see LRU). AWeakMapholds 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.
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.
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());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.
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 | |
|---|---|---|---|
| Cycles | throws | works | works |
Date | becomes a string | stays Date | stays Date |
Map, Set | become {} | kept | kept |
| Functions | dropped | throws DataCloneError | shared |
| Class prototype | lost | lost | kept |
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.
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.
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 depth1gives[1, 2, [3]];Infinityflattens fully.forEachskips holes, so it matches the realArray.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 aDatestays whole. - Cost: O(total items). Mistake:
push(...hugeArray)can overflow the stack. Say first: in real work it isarr.flat(Infinity).
(Must know) Write groupBy, chunk, zip, unique and intersection.
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;}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));}groupByusesObject.create(null), so"constructor"is just a key. NativeObject.groupBy(ES2024, Node 21+) does the same;Map.groupByallows object keys.chunkrejects0(it would loop forever) and non-integers.uniqueuses aSet(O(n));filterwithindexOfis O(n²).intersectionbuilds aSetof one side: O(n + m), not O(n × m). NativeSet.prototype.intersectionexists (ES2025).zippads 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?
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?
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.
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;}callputs the function on the object as a temporary method, sothisis that object. ASymbolkey cannot clash with a real property. Limit: it fails on a frozen object (tested); real code usesReflect.apply.applyiscallwith an array(-like);apply(ctx)with no array must work.bindreturns a new function with fixedthisand leading arguments. Trap:newon a bound function ignores the boundthis;new.targetdetects it. Binding twice keeps the firstthis.
(Senior signal) Implement the new operator and instanceof.
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.
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.
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(); }); }); });| Method | Fulfils when | Rejects when | Result |
|---|---|---|---|
all | all fulfil | first rejection | values in input order |
allSettled (ES2020) | all settle | never | {status, value / reason} list |
race | first settles | first settles badly | that one result |
any (ES2021) | first fulfils | all 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.
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.
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));}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); } }}withTimeoutclears its timer infinally. Trap: it does not stop the work. Forfetch, passAbortSignal.timeout(ms)(Node 16.14+, all current browsers).retrywaitsbase × 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-Afteron 429. Retry only idempotent work (safe to repeat).
(Must know) Write an EventEmitter with on, once, off and emit.
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.
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); } } }}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); }, };}subscribereturns an unsubscribe function; one failing subscriber must not stop the others.- The store is immutable:
setStatemakes a new object, and does nothing if no value changed. A selector runs a listener only when its slice changes. React'suseSyncExternalStoreneeds exactly this:subscribeplus a stablegetState.
(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.
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.
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.
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
pushandpop, 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) withheadandtail. - List:
appendO(1),removeO(n),reverseO(n) time, O(1) space. They check: empty list, one item, removing the head or tail (updatetail!). More: DSA, Linked Lists and Stacks.
(Must know) Warm-ups: binary search, two-sum, anagram, palindrome.
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 <= hiwithmid ± 1avoids endless loops (DSA 28). Two-sum: one pass with aMapof 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.
| Input | JSON.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 cycle | throws TypeError |
{b:1, 2:1, a:1, 1:1} | {"1":1,"2":1,"b":1,"a":1} (integer keys first) |
undefined | undefined, 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.
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 }}.
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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.
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).
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.
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.
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.
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 linemappasses(value, index, array), andparseInt(value, radix)takes the index as radix. Use.map(Number).- The default
sortcompares text; pass(a, b) => a - b. - 0.1 cannot be stored exactly: use a tolerance, or integers (cents).
includestreatsNaNas equal;indexOfuses===.forEachignores the returned promise. Useawait Promise.all(arr.map(async ...)).
Key takeaways
- Debounce waits for silence; throttle runs at most once per period. Keep
this, arguments,canceland a trailing call. - Cache keys, clones and equality are where answers fail:
JSON.stringifymergesnullandundefined, cycles loop forever,structuredClonerejects functions. - Polyfills: holes (
i in O),thisArg, explicitundefinedinitial value,new.targetforbind. - Promises: first settle wins,
thenreturns a new promise,allstores by index and does not cancel,anyneedsAggregateError. - Concurrency: pass task functions, run
limitworkers. Retry only idempotent work, with capped backoff and jitter. A timeout does not cancel work; anAbortSignaldoes. - DOM: delegate with
closest, re-observe the sentinel, build text withcreateTextNode, 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