#JavaScript#Closures

JS Coding: Implementations & Concepts

The JavaScript utilities worth writing from scratch — debounce, throttle, the Promise combinators, array polyfills, call/apply/bind, deep clone, curry, memoize, an event emitter, retry with backoff, and a concurrency pool — plus the closures, this-binding and event-loop behaviour underneath them.

By Pratik Rai

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.


Part One — Implementations

debounce

Delays execution until the triggering has stopped for delay milliseconds, resetting the timer on every call. Search-as-you-type, autosave, resize handlers.

JSfile.js
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:

JSfile.js
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.

throttle

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.

JSfile.js
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 combinators

Promise.all resolves with an array of every result once all promises fulfil, and rejects immediately if any one rejects.

JSfile.js
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:

JSfile.js
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:

JSfile.js
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.


Array method polyfills

These test whether you understand this, callback signatures, and the accumulator pattern.

JSfile.js
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.


call, apply, and bind

All three control this. call and apply invoke immediately; bind returns a new function.

JSfile.js
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.


Deep clone

JSfile.js
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.

Deep equality

JSfile.js
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.


curry

Turns f(a, b, c) into f(a)(b)(c), collecting arguments until there are enough to invoke.

JSfile.js
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.

memoize

JSfile.js
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.

flatten

JSfile.js
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.

groupBy

JSfile.js
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.


Event emitter

JSfile.js
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.

Retry with exponential backoff

JSfile.js
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.


Part Two — The Language Underneath

Closures

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.

JSfile.js
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:

JSfile.js
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.

How this is resolved

this is determined by how a function is called, not where it's defined. Four rules, in priority order:

  1. newthis is the newly constructed object.
  2. Explicitcall, apply or bind set it directly.
  3. Implicit — called as obj.method(), so this is obj.
  4. 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:

JSfile.js
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.

Prototypal inheritance

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.

JSfile.js
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.

Hoisting and the temporal dead zone

  • 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.
JSfile.js
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.


Part Three — Async Patterns

From callbacks to async/await

JSfile.js
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.

Sequential versus parallel

The most common performance mistake in async JavaScript:

JSfile.js
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.

Capping concurrency

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:

JSfile.js
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.


The Shape of It

Implementationsdebounce 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.

Asyncawait 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.

Goal: Have every common JS coding interview implementation cold — from debounce and throttle through Promise combinators, polyfills, deep clone, curry, and async concurrency — plus the closures, this-binding, hoisting, and prototype concepts behind output-prediction questions.

Related Articles

Continue learning with these related challenges

View All
Blogs

Frontend Roadmap, Tier 1: Beginner Deep Notes

The mental models and edge cases that separate someone who has used HTML, CSS, JS, forms, responsive design, and HTTP from someone who actually understands them. Part 1 of the Frontend Roadmap series.

HTML · CSS · JavaScript · HTTPPratik Rai ·

JavaScript

Fetch And Retry

Implement a retry function that handles transient failures by retrying async operations with optional delays.

JavaScript · Promises · Async/AwaitPratik Rai ·

Blogs

Performance Optimisation from First Principles

Understanding frontend performance optimization from the ground up - learn how browsers work and optimize your code accordingly.

Performance · Frontend · JavaScript · BrowserPratik Rai ·