There's a set of JavaScript implementations that turn up again and again — in interviews, in utility libraries, and in the moment you realise you need a debounce and don't want another dependency. Writing them yourself is the fastest way to find out whether you understand this, closures, and the event loop, or merely recognise them.
This is that set, plus the language behaviour underneath it. Each implementation starts simple and then addresses what the simple version gets wrong.
Delays execution until the triggering has stopped for delay milliseconds, resetting the timer on every call. Search-as-you-type, autosave, resize handlers.
1function debounce(fn, delay) {
2 let timer;
3 return function (...args) {
4 clearTimeout(timer);
5 timer = setTimeout(() => fn.apply(this, args), delay);
6 };
7}
Two details carry the implementation. fn.apply(this, args) preserves the caller's context and arguments — and the returned function must be a regular function, not an arrow, so that this binds to whoever called it. An arrow would capture the surrounding scope instead.
A leading option fires on the first call and then suppresses the rest, which is what you want for a submit button that should feel instant but must not double-fire:
1function debounce(fn, delay, immediate = false) {
2 let timer;
3 return function (...args) {
4 const callNow = immediate && !timer;
5 clearTimeout(timer);
6 timer = setTimeout(() => {
7 timer = null;
8 if (!immediate) fn.apply(this, args);
9 }, delay);
10 if (callNow) fn.apply(this, args);
11 };
12}
For the full treatment — cancel, flush, and why this silently does nothing in React — see the debounce polyfill.
Runs fn at most once per interval, no matter how often it's triggered. Scroll, mousemove, drag — anything firing rapidly where you want a steady cadence rather than silence.
1function throttle(fn, interval) {
2 let lastTime = 0;
3 return function (...args) {
4 const now = Date.now();
5 if (now - lastTime >= interval) {
6 lastTime = now;
7 fn.apply(this, args);
8 }
9 };
10}
The distinction that matters: debounce waits for quiet, throttle enforces a rate. Debounce fires once after activity stops; throttle fires regularly during it. Debouncing a scroll handler means nothing happens until scrolling ends, which is rarely the intent.
This version drops the final call, since it only fires on the leading edge. A timer-based variant guarantees a trailing call so the last event isn't lost — usually the one you care about. See the throttle polyfill for both edges.
Promise.all resolves with an array of every result once all promises fulfil, and rejects immediately if any one rejects.
1function promiseAll(promises) {
2 return new Promise((resolve, reject) => {
3 const results = [];
4 let completed = 0;
5 if (promises.length === 0) return resolve(results);
6
7 promises.forEach((p, i) => {
8 Promise.resolve(p)
9 .then((value) => {
10 results[i] = value; // by index — order is input order, not completion order
11 completed++;
12 if (completed === promises.length) resolve(results);
13 })
14 .catch(reject); // first rejection wins
15 });
16 });
17}
Three things are load-bearing. Results are stored by index, so order follows the input regardless of which resolves first — a push-based version returns them in completion order, which is a subtle and unpleasant bug. Promise.resolve(p) wraps raw values, so [1, fetch(...)] works. And the empty-array case must be handled before the loop, or the promise never settles.
Promise.allSettled never rejects. It waits for everything and reports each outcome:
1function promiseAllSettled(promises) {
2 return Promise.all(
3 promises.map((p) =>
4 Promise.resolve(p)
5 .then((value) => ({ status: 'fulfilled', value }))
6 .catch((reason) => ({ status: 'rejected', reason }))
7 )
8 );
9}
Promise.race settles — resolving or rejecting — as soon as the first promise does:
1function promiseRace(promises) {
2 return new Promise((resolve, reject) => {
3 promises.forEach((p) => Promise.resolve(p).then(resolve, reject));
4 });
5}
It's this short because promises are single-assignment: once resolved, later calls are ignored.
Promise.any is the sibling — it resolves on the first fulfilment, ignores rejections, and rejects only if every promise fails, with an AggregateError.
One thing none of them do: cancel anything. A rejected Promise.all stops you waiting, but every other request continues to completion. Full detail in Promise.all and the other combinators.
These test whether you understand this, callback signatures, and the accumulator pattern.
1Array.prototype.myMap = function (callback, thisArg) {
2 const result = [];
3 for (let i = 0; i < this.length; i++) {
4 if (i in this) result[i] = callback.call(thisArg, this[i], i, this);
5 }
6 return result;
7};
8
9Array.prototype.myFilter = function (callback, thisArg) {
10 const result = [];
11 for (let i = 0; i < this.length; i++) {
12 if (i in this && callback.call(thisArg, this[i], i, this)) {
13 result.push(this[i]);
14 }
15 }
16 return result;
17};
18
19Array.prototype.myReduce = function (callback, initialValue) {
20 let acc = initialValue;
21 let startIndex = 0;
22 if (arguments.length < 2) {
23 if (this.length === 0) {
24 throw new TypeError('Reduce of empty array with no initial value');
25 }
26 acc = this[0];
27 startIndex = 1;
28 }
29 for (let i = startIndex; i < this.length; i++) {
30 acc = callback(acc, this[i], i, this);
31 }
32 return acc;
33};
The i in this check is what skips holes in sparse arrays, matching native behaviour — [1, , 3] has an absence at index 1, not an undefined.
reduce is the one with real branching. Without an initial value, the accumulator seeds from the first element and iteration starts at index 1 — and an empty array with no seed throws. That last case reaches production regularly, because a filtered list that happens to be empty is enough to trigger it. Detecting it via arguments.length matters too: a default parameter can't distinguish "not passed" from an explicitly passed undefined.
Deeper on each: map, filter, reduce.
All three control this. call and apply invoke immediately; bind returns a new function.
1Function.prototype.myCall = function (context, ...args) {
2 context = context || globalThis;
3 const fnKey = Symbol('fn');
4 context[fnKey] = this;
5 const result = context[fnKey](...args);
6 delete context[fnKey];
7 return result;
8};
9
10Function.prototype.myApply = function (context, args = []) {
11 context = context || globalThis;
12 const fnKey = Symbol('fn');
13 context[fnKey] = this;
14 const result = context[fnKey](...args);
15 delete context[fnKey];
16 return result;
17};
18
19Function.prototype.myBind = function (context, ...boundArgs) {
20 const fn = this;
21 return function (...callArgs) {
22 return fn.apply(context, [...boundArgs, ...callArgs]);
23 };
24};
The mechanism is the same for all three: there's no way to invoke a function with an arbitrary this except through the mechanism that sets this in the first place — a method call. So you temporarily attach the function to the context object, call it as a method, and clean up.
A Symbol key is used rather than a string so you cannot overwrite a property the caller already owns. In production you'd also wrap the invocation in try/finally, so a throwing function doesn't leave the temporary property attached forever.
bind supports partial application — arguments given at bind time are prepended to those given at call time. The version above handles that but not construction: new BoundFn() should ignore the bound this in favour of the new object. The bind polyfill covers that case.
1function deepClone(value, seen = new WeakMap()) {
2 if (value === null || typeof value !== 'object') return value;
3
4 if (seen.has(value)) return seen.get(value);
5
6 if (value instanceof Date) return new Date(value);
7 if (value instanceof RegExp) return new RegExp(value.source, value.flags);
8
9 const clone = Array.isArray(value) ? [] : {};
10 seen.set(value, clone); // register BEFORE recursing — this is what breaks cycles
11
12 for (const key of Reflect.ownKeys(value)) {
13 clone[key] = deepClone(value[key], seen);
14 }
15 return clone;
16}
Two things separate this from the naive version. The WeakMap handles circular references — and the ordering matters enormously: the clone is registered before recursing into children, so a cycle back to the parent finds the in-progress clone instead of recursing forever. And Date and RegExp need explicit handling, because they have no own enumerable properties and would otherwise clone to empty objects.
structuredClone() is now built into every modern runtime and handles all of this, including Map, Set and ArrayBuffer. It throws on functions, which is the main reason to still reach for a manual implementation. The old JSON.parse(JSON.stringify(x)) trick silently destroys undefined, functions and Date objects, and throws on cycles.
1function deepEqual(a, b) {
2 if (a === b) return true;
3 if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
4 return false;
5 }
6 const keysA = Object.keys(a);
7 const keysB = Object.keys(b);
8 if (keysA.length !== keysB.length) return false;
9
10 return keysA.every((key) =>
11 Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key])
12 );
13}
The a === b shortcut handles both identical references and matching primitives in one line. The length check before comparing keys catches the case where b has extra properties.
Turns f(a, b, c) into f(a)(b)(c), collecting arguments until there are enough to invoke.
1function curry(fn) {
2 return function curried(...args) {
3 if (args.length >= fn.length) {
4 return fn.apply(this, args);
5 }
6 return (...next) => curried.apply(this, [...args, ...next]);
7 };
8}
9
10const sum = (a, b, c) => a + b + c;
11const cSum = curry(sum);
12cSum(1)(2)(3); // 6
13cSum(1, 2)(3); // 6
14cSum(1)(2, 3); // 6
The whole thing hinges on fn.length — the number of declared parameters. Which is also its weakness: default and rest parameters corrupt that count, so (a, b, c = 1) => {} reports a length of 2 and the curried version fires one argument early. Any real utility needs an explicit arity option. See the curry polyfill.
1function memoize(fn) {
2 const cache = new Map();
3 return function (...args) {
4 const key = JSON.stringify(args);
5 if (cache.has(key)) return cache.get(key);
6 const result = fn.apply(this, args);
7 cache.set(key, result);
8 return result;
9 };
10}
JSON.stringify as a cache key is fine for primitive arguments and breaks for functions and circular structures. For a single object argument a WeakMap is better — it keys on identity and lets entries be garbage-collected when the argument goes out of scope, which a Map would prevent.
The other caveat is unbounded growth: this cache never evicts. Long-lived memoisation over varied inputs is a memory leak with good intentions.
1function flatten(arr, depth = Infinity) {
2 return arr.reduce((acc, item) => {
3 if (Array.isArray(item) && depth > 0) {
4 acc.push(...flatten(item, depth - 1));
5 } else {
6 acc.push(item);
7 }
8 return acc;
9 }, []);
10}
11// flatten([1, [2, [3, [4]]]]) -> [1, 2, 3, 4]
12// flatten([1, [2, [3]]], 1) -> [1, 2, [3]]
Array.isArray rather than instanceof Array, so it works on arrays from another realm — an iframe or a worker. A non-recursive version using an explicit stack avoids stack overflow on pathologically deep input; more in flatten.
1function groupBy(arr, keyFn) {
2 return arr.reduce((groups, item) => {
3 const key = typeof keyFn === 'function' ? keyFn(item) : item[keyFn];
4 (groups[key] ||= []).push(item);
5 return groups;
6 }, {});
7}
8// groupBy([6.1, 4.2, 6.3], Math.floor) -> { 4: [4.2], 6: [6.1, 6.3] }
Accepting either a function or a property name covers both common uses. Object.groupBy is now standard, so this is increasingly a demonstration rather than a necessity.
1class EventEmitter {
2 constructor() {
3 this.events = {};
4 }
5
6 on(event, handler) {
7 (this.events[event] ||= []).push(handler);
8 return () => this.off(event, handler); // return an unsubscribe function
9 }
10
11 off(event, handler) {
12 if (!this.events[event]) return;
13 this.events[event] = this.events[event].filter((h) => h !== handler);
14 }
15
16 emit(event, ...args) {
17 (this.events[event] || []).forEach((h) => h(...args));
18 }
19
20 once(event, handler) {
21 const wrapper = (...args) => {
22 handler(...args);
23 this.off(event, wrapper);
24 };
25 this.on(event, wrapper);
26 }
27}
Returning an unsubscribe function from on removes an entire category of bug. off compares by identity, so an inline arrow can never be removed — you'd need to have kept the exact reference. Handing back a closure that already holds it sidesteps the problem.
once wraps the handler so it removes itself after firing. The consequence, worth knowing, is that off(event, originalHandler) can't find it — the registry holds the wrapper. Production implementations store a back-reference for exactly this. See the event emitter for that, plus why a throwing listener shouldn't break the chain.
1function retry(fn, retries = 3, delay = 500) {
2 return new Promise((resolve, reject) => {
3 function attempt(remaining, wait) {
4 fn()
5 .then(resolve)
6 .catch((err) => {
7 if (remaining === 0) return reject(err);
8 setTimeout(() => attempt(remaining - 1, wait * 2), wait);
9 });
10 }
11 attempt(retries, delay);
12 });
13}
14
15retry(() => fetch('/api/flaky').then((r) => r.json()), 3, 500)
16 .then(console.log)
17 .catch((err) => console.error('all retries failed', err));
Backoff exists because retrying immediately makes things worse — a struggling server gets hit again instantly by every client that just failed. Doubling the wait gives it room to recover.
In production you also add jitter, randomising the delay, so that clients which failed simultaneously don't retry simultaneously. Without it, exponential backoff still produces synchronised waves of traffic. More in fetch retry.
A function retains access to the scope it was created in, even after that scope has returned. The variables stay alive because something still references them.
1function counter() {
2 let count = 0;
3 return () => ++count;
4}
5const inc = counter();
6inc(); // 1
7inc(); // 2 — the same `count` persists
The classic trap is var in a loop:
1for (var i = 0; i < 3; i++) {
2 setTimeout(() => console.log(i), 0); // 3, 3, 3
3}
4
5for (let i = 0; i < 3; i++) {
6 setTimeout(() => console.log(i), 0); // 0, 1, 2
7}
var is function-scoped, so all three callbacks close over one shared binding — which is 3 by the time any of them run. let is block-scoped and creates a fresh binding each iteration, so each closure captures its own.
this is determined by how a function is called, not where it's defined. Four rules, in priority order:
new — this is the newly constructed object.
- Explicit —
call, apply or bind set it directly.
- Implicit — called as
obj.method(), so this is obj.
- Default — a standalone call, so
this is undefined in strict mode, or the global object otherwise.
Arrow functions sit outside all of it. They have no this of their own and capture it lexically from where they were defined:
1const obj = {
2 name: 'Example',
3 regular() { return this.name; }, // 'Example' — implicit binding
4 arrow: () => this.name, // undefined — captured from the outer scope
5};
That's exactly why arrows are right for callbacks, where you want to preserve the surrounding this, and wrong for object methods, which need their own.
Objects link to other objects through [[Prototype]]. A property lookup that misses walks the chain until it finds the property or reaches null. class syntax is sugar over this mechanism, not a replacement for it.
1function Animal(name) { this.name = name; }
2Animal.prototype.speak = function () { return `${this.name} makes a sound`; };
3
4function Dog(name) { Animal.call(this, name); } // inherit instance properties
5Dog.prototype = Object.create(Animal.prototype); // inherit methods
6Dog.prototype.constructor = Dog;
7
8new Dog('Rex').speak(); // "Rex makes a sound" — found up the chain
hasOwnProperty is true only for the object's own properties; the in operator also searches the chain. That difference is the whole reason Object.create(null) is the right structure for a dictionary keyed by untrusted input.
var declarations are hoisted and initialised to undefined. Referencing one before its line gives you undefined rather than an error.
- Function declarations are hoisted completely, and are callable before they appear.
let and const are hoisted too, but not initialised. The span from the top of the block to the declaration is the temporal dead zone, and touching the variable there throws.
1console.log(a); // undefined — hoisted, not yet assigned
2var a = 1;
3
4console.log(b); // ReferenceError — b is in the TDZ
5let b = 2;
The common misconception is that let and const aren't hoisted. They are — the difference is that access before initialisation is an error rather than undefined.
1// Nested callbacks
2getUser(id, (user) => {
3 getOrders(user, (orders) => {
4 getDetails(orders[0], (details) => console.log(details));
5 });
6});
7
8// Flattened with promises
9getUser(id)
10 .then(getOrders)
11 .then((orders) => getDetails(orders[0]))
12 .then(console.log)
13 .catch(handleError);
14
15// async/await — reads top to bottom
16async function load(id) {
17 try {
18 const user = await getUser(id);
19 const orders = await getOrders(user);
20 return await getDetails(orders[0]);
21 } catch (err) {
22 handleError(err);
23 }
24}
Each step trades nesting for linearity. The async/await version also gets ordinary try/catch, which means async errors are handled the same way as synchronous ones.
The most common performance mistake in async JavaScript:
1// Sequential — total time is the sum of all
2async function slow(ids) {
3 const out = [];
4 for (const id of ids) out.push(await fetchItem(id));
5 return out;
6}
7
8// Parallel — total time is the slowest one
9async function fast(ids) {
10 return Promise.all(ids.map((id) => fetchItem(id)));
11}
await inside a loop serialises everything. For 50 items at 100ms each, that's five seconds instead of one hundred milliseconds.
The rule: chain await only when a later call genuinely needs an earlier result. Independent calls should all start together.
This works because promises begin executing the moment they're created, not when they're awaited. Promise.all isn't parallelising anything — the .map already started every request, and all simply waits for them. More in parallel vs sequential.
Full parallelism stops being free once the list is long — 1,000 simultaneous requests will hit rate limits and exhaust connections. You want parallelism with a ceiling:
1async function asyncPool(limit, items, iteratorFn) {
2 const results = [];
3 const executing = new Set();
4
5 for (const item of items) {
6 const p = Promise.resolve().then(() => iteratorFn(item));
7 results.push(p);
8 executing.add(p);
9 p.finally(() => executing.delete(p));
10
11 if (executing.size >= limit) {
12 await Promise.race(executing); // wait for any one to free a slot
13 }
14 }
15 return Promise.all(results);
16}
17
18// Fetch 100 URLs, at most 5 in flight
19asyncPool(5, urls, (url) => fetch(url).then((r) => r.json()));
Each task adds itself to the executing set and removes itself on completion. Once the set reaches limit, Promise.race blocks the loop until any one task finishes, freeing exactly one slot. The loop then continues, holding the number in flight steady.
Implementations — debounce and throttle, the promise combinators, the array polyfills, call/apply/bind, deepClone and deepEqual, curry, memoize, flatten, groupBy, an event emitter, retry with backoff, and a concurrency pool.
Language — closures and why var in a loop misbehaves; the four this rules and why arrows ignore them; the prototype chain; hoisting and the temporal dead zone.
Async — await in a loop is sequential and usually wrong; promises start on creation, not on await; and unbounded parallelism needs a ceiling.
The connecting thread is that almost every one of these is really a question about closures, this, or the event loop wearing a different hat.