Polyfills and JavaScript Utility Implementation Questions
Rebuilding standard JavaScript behavior and common utility libraries from scratch, the machine-coding interview genre where a candidate re-creates a familiar API to prove they understand its mechanics. Covers a minimal Promise built from first principles (chaining, asynchronous handlers, single settlement, thenable adoption, error flow) and Promise.allSettled-style combinators; polyfills for browser APIs such as IntersectionObserver, and a keyed virtual DOM diff; utilities such as debounce (leading and trailing, cancel, flush), throttle, and React hooks that debounce a value or a callback with stable identity and no stale closures; deep clone (cycles, shared references, Dates, RegExps), shallow and deep equality (typed arrays, Maps and Sets, NaN and -0), nested object helpers (safe path get, flatten and unflatten, deep merge), curry with placeholders and partial application, and memoize with cache keys and WeakMap-based caches; and small infrastructure such as event emitters (on, off, once, emit, context binding, listener leaks). Emphasizes edge cases, correct this binding, argument handling, spec fidelity, and complexity. Using these built-ins as a language feature, the event loop and async/await semantics, DOM traversal and event propagation, functional programming as a paradigm, and generic algorithm puzzles are covered elsewhere.
Implement memoize for a pure function that takes primitive arguments, using a closure for the cache. Then extend it so it can cache calls that take objects without keeping those objects alive forever, and discuss the downsides of a cache that never evicts.
Sample Answer
Memoize wraps a function so that a repeated call with the same arguments returns a stored result instead of recomputing it. It is only correct for a pure function: one whose result depends only on its arguments and which has no side effects, so replaying the stored result is indistinguishable from calling it again. The cache lives in a closure (a function plus the variables it captured). Two decisions matter: how the arguments become a cache key, and how entries leave the cache. For object arguments, the key must be held weakly so the cache does not keep those objects alive. Some memory vocabulary used below: an object is reachable while some running code can still get to it through variables or other objects; garbage collection is the engine automatically freeing objects that are no longer reachable (an object that can be freed is collectable); a strong reference keeps its target reachable, while a weakly held one does not, so the target can be collected even though the weak holder still mentions it.
Version 1: primitive arguments
The key must separate every pair of argument lists a pure function could treat differently. The tempting JSON.stringify(args) does not: NaN, Infinity and null all serialize to null, undefined becomes null inside an array, and a BigInt throws, per the MDN description of JSON.stringify. The key below stores each argument's type name and its string form, then serializes that array of strings, so two different primitive argument lists cannot share a key. Objects and Symbols have an identity that no string carries (two Symbol('id') values print the same text), so version 1 throws a TypeError for them and points to version 2. It also keeps -0 and 0 apart, since a pure function like 1 / x returns different results for them.
Version 1 keeps entries in a Map, which remembers insertion order, so removing the first key evicts the least recently used entry. That gives a bounded cache with maxSize.
Version 2: objects as arguments, held weakly
A string key cannot represent an object, and a plain Map keyed by the object would keep it alive forever. Version 2 builds a small tree with one node per argument position. A primitive argument selects the next node through a Map; an object or function argument selects it through a WeakMap. The result sits in the last node. (This is the same idea as a trie, a tree where each level is one step of the lookup key, here one argument.) For mw(o1, 'x', 2) the lookup path is the arity 3, then o1, then 'x', then 2: the root's prim Map holds 3 pointing to node 1; node 1's weak WeakMap holds o1 pointing to node 2; node 2's prim Map holds 'x' pointing to node 3; node 3's prim Map holds 2 pointing to node 4, and node 4 stores the result. Calling mw(o1, 'x', '2') reuses nodes 1 to 3 and adds a sibling under node 3, so only the last argument differs. The leading arity step is not needed for correctness, since every node stores its own result and calls with different argument counts end at different nodes; it only keeps them in separate subtrees. MDN documents the property that matters: a WeakMap does not keep its key objects alive, and once a key is collected its value becomes collectable too, unless something else references it. So when the caller drops the last reference to the object, the whole branch under it, including the cached result, can go.
import assert from 'node:assert/strict';
// ---- Version 1: primitive arguments, cache in a closure, optional size bound ----
// The key must tell 1 from "1", NaN from null, undefined from null, and 0 from -0.
// Objects and Symbols have identity that no string can carry (two Symbol('id') print the same),
// so this version refuses them instead of letting two different arguments share a key.
const isObject = (v) => (typeof v === 'object' && v !== null) || typeof v === 'function';
const part = (a) => {
if (isObject(a) || typeof a === 'symbol') throw new TypeError('use memoizeWeak for objects and symbols');
return `${typeof a}:${Object.is(a, -0) ? '-0' : String(a)}`;
};
const keyOf = (args) => JSON.stringify(args.map(part)); // JSON.stringify of an array of strings is unambiguous
function memoize(fn, { maxSize = Infinity } = {}) {
const cache = new Map(); // Map remembers insertion order, which gives us LRU for free
const memoized = function (...args) {
const key = keyOf(args);
if (cache.has(key)) {
const value = cache.get(key);
cache.delete(key); // re-insert: this entry becomes the most recently used
cache.set(key, value);
return value;
}
const value = fn.apply(this, args); // if fn throws, nothing is cached
cache.set(key, value);
if (cache.size > maxSize) cache.delete(cache.keys().next().value); // evict the least recently used
return value;
};
memoized.size = () => cache.size;
memoized.clear = () => cache.clear();
return memoized;
}
// The tempting shortcut, to show why the key function above is more careful.
const naiveMemoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args));
return cache.get(key);
};
};
// ---- Version 2: object arguments held weakly ----
// One node per argument position: primitives go in a Map, objects in a WeakMap.
const NEG_ZERO = Symbol('-0'); // stand-in key: Map treats -0 and 0 as the same key (SameValueZero), a pure function may not
const newNode = () => ({ prim: null, weak: null, done: false, value: undefined });
function memoizeWeak(fn) {
let root = newNode();
const memoized = function (...args) {
let node = root;
for (const arg of [args.length, ...args]) { // arity first: calls with different argument counts get separate subtrees
const k = Object.is(arg, -0) ? NEG_ZERO : arg;
const table = isObject(k) ? (node.weak ??= new WeakMap()) : (node.prim ??= new Map());
let next = table.get(k);
if (!next) table.set(k, (next = newNode()));
node = next;
}
if (!node.done) {
node.value = fn.apply(this, args);
node.done = true;
}
return node.value;
};
memoized.clear = () => { root = newNode(); }; // WeakMap has no clear(); drop the whole tree
return memoized;
}
// A cache that holds objects strongly, for comparison.
const strongObjectMemoize = (fn) => {
const cache = new Map();
return (obj) => (cache.has(obj) ? cache.get(obj) : (cache.set(obj, fn(obj)), cache.get(obj)));
};
// ---------------- checks ----------------
const out = [];
let calls = 0;
const probe = (...a) => (calls++, a.map(String).join('|'));
// Key correctness
const naive = naiveMemoize((x) => `computed for ${String(x)}`);
naive(null);
out.push(`naive JSON key : f(NaN) returns "${naive(NaN)}" (wrong, it reused the entry for null)`);
assert.equal(naive(NaN), 'computed for null');
const m = memoize((x) => (calls++, typeof x + ':' + String(x)));
calls = 0;
const results = [m(1), m('1'), m(NaN), m(null), m(undefined), m(0), m(-0), m(1n)];
assert.equal(calls, 8, 'all eight inputs are distinct keys');
assert.equal(new Set(results).size >= 7, true);
m(1); m(NaN); m(-0);
assert.equal(calls, 8, 'repeat calls hit the cache');
out.push(`memoize : 8 distinct inputs computed ${calls} times, 3 repeats computed 0 more times`);
// Bounded vs unbounded
calls = 0;
const square = (n) => (calls++, n * n);
const unbounded = memoize(square);
const bounded = memoize(square, { maxSize: 3 });
for (let i = 0; i < 10000; i++) { unbounded(i); bounded(i); }
out.push(`entries after 10000 distinct calls: unbounded ${unbounded.size()}, maxSize 3 -> ${bounded.size()}`);
assert.equal(unbounded.size(), 10000); assert.equal(bounded.size(), 3);
const lru = memoize((n) => (calls++, n), { maxSize: 3 });
calls = 0;
lru(1); lru(2); lru(3); lru(1); lru(4); // 2 is the least recently used and is evicted
const afterFill = calls; lru(1); lru(2);
out.push(`LRU order : after 1,2,3,1,4 -> compute count ${afterFill}; then 1 (hit) and 2 (evicted, recomputed) -> ${calls}`);
assert.equal(afterFill, 4); assert.equal(calls, 5);
// Objects and garbage collection (needs: node --expose-gc)
async function collectedAfterDrop(makeMemo) {
const memo = makeMemo((obj) => ({ derivedFrom: obj, label: `user ${obj.id}` })); // result points back at its key
let user = { id: 7 };
const ref = new WeakRef(user);
memo(user);
assert.equal(memo(user), memo(user), 'same object, same cached result');
user = null;
await new Promise((r) => setTimeout(r, 0)); // WeakRef targets are kept alive until the current job ends
globalThis.gc();
return ref.deref() === undefined;
}
const weakCollected = await collectedAfterDrop(memoizeWeak);
const strongCollected = await collectedAfterDrop(strongObjectMemoize);
out.push(`key object collected after the caller drops it: memoizeWeak ${weakCollected}, Map-based cache ${strongCollected}`);
assert.equal(weakCollected, true); assert.equal(strongCollected, false);
// Guard and identity checks
assert.throws(() => m(Symbol('id')), TypeError);
assert.throws(() => m({}), TypeError);
{
let c = 0;
const f = memoizeWeak(() => ++c);
const s1 = Symbol('id'), s2 = Symbol('id');
f(s1); f(s2); f(s1);
assert.equal(c, 2, 'two Symbols with the same description are different keys');
f(); f(undefined); f(1); f(1, undefined);
assert.equal(c, 6, 'f(), f(undefined), f(1) and f(1, undefined) are four different calls');
}
// Same object -> hit, equal-looking different object -> miss, mixed arguments
calls = 0;
const mw = memoizeWeak(probe);
const o1 = { a: 1 }, o2 = { a: 1 };
mw(o1, 'x', 2); mw(o1, 'x', 2); mw(o2, 'x', 2); mw(o1, 'x', '2'); mw(o1);
out.push(`memoizeWeak : 5 calls, computed ${calls} times (only the exact repeat was a hit)`);
assert.equal(calls, 4);
console.log(out.join('\n'));
Run in a node:22 container with node --expose-gc (which exposes gc() so the test can force a collection), it prints:
naive JSON key : f(NaN) returns "computed for null" (wrong, it reused the entry for null)
memoize : 8 distinct inputs computed 8 times, 3 repeats computed 0 more times
entries after 10000 distinct calls: unbounded 10000, maxSize 3 -> 3
LRU order : after 1,2,3,1,4 -> compute count 4; then 1 (hit) and 2 (evicted, recomputed) -> 5
key object collected after the caller drops it: memoizeWeak true, Map-based cache false
memoizeWeak : 5 calls, computed 4 times (only the exact repeat was a hit)
Repeated runs print the same output. Garbage collection timing is up to the engine, so the collection checks are results for V8 in Node 22, not a language guarantee; MDN warns that a WeakRef target is only reclaimed between turns of the event loop, which is why the test waits one setTimeout before calling gc().
Reading the results
Two lines of the code need a word. node.weak ??= new WeakMap() means "assign a new WeakMap only if node.weak is null or undefined", so each node creates its tables on first use. SameValueZero is the equality that Map uses for keys: like === except that NaN equals NaN, and 0 and -0 are equal, which is why -0 needs the symbol stand-in.
The garbage-collection test reads like this. new WeakRef(user) makes a weak pointer that can be asked later whether its target is still alive. The cache is called once with the object, then user = null removes the test's own strong reference. await new Promise((r) => setTimeout(r, 0)) lets the current job end, because a WeakRef target cannot be collected before that. globalThis.gc() (available only with node --expose-gc) forces a collection, and ref.deref() === undefined is true exactly when the object was collected. The WeakMap-based cache leaves nothing that keeps the object reachable, the Map-based cache does, hence true and false.
- Key correctness. With the naive JSON key,
f(NaN)returned the cached result fornull. The careful key computed all eight different inputs (1,"1",NaN,null,undefined,0,-0,1n) separately, and three repeats cost zero extra computations. - Never evicting. After 10,000 distinct calls the unbounded cache holds 10,000 entries and never shrinks. With
maxSize: 3it holds 3. - LRU order. After calls 1, 2, 3, 1, 4 the cache evicts 2 (the least recently used, because 1 was just reused). Calling 1 again is a hit and calling 2 recomputes, giving 5 computations in total.
- Objects. After the caller drops its only reference, the object behind the
WeakMap-based cache was collected, while theMap-based cache still held it. The cached result in the test points back at its key object, and it was still collected, because aWeakMapvalue that is reachable only from its own key does not keep that key alive. - Symbols and objects. Version 1 refuses them with a
TypeError; version 2 keys them by identity, so twoSymbol('id')values are two cache entries, andf(),f(undefined),f(1)andf(1, undefined)are four different calls. - Equal-looking objects.
{ a: 1 }and another{ a: 1 }are different keys (identity, not structure), so only the exact repeat hit the cache.
Downsides of a cache that never evicts
- Unbounded memory. Every distinct argument list adds an entry. A function called with user input, timestamps or ids has an unbounded key space, so memory grows for the life of the page or process. This is a leak by design.
- Retention of large objects. The cache keeps both keys and results alive. Caching a function of DOM nodes, props objects or large responses pins them after the rest of the program is done with them.
- Stale or wrong answers. If the function depends on anything outside its arguments (time, a setting, a mutable object that is mutated in place), the cache returns old results. Object identity keys also miss mutation: the same object with changed fields still hits.
- No way to reclaim.
WeakMaphas nosize, iteration orclear()(MDN), so the version above clears by dropping the root of the tree.
Choosing a policy
| Situation | Policy |
|---|---|
| Few possible inputs (a handful of enum-like values) | Unbounded is fine |
| Many primitive inputs | maxSize with LRU, or a time-to-live (expire after N ms) |
| Object inputs whose lifetime the caller controls | WeakMap branch, as in version 2 |
| Object plus many primitive inputs | A WeakMap per object holding a small LRU for the primitive part |
| Per-request or per-render results | Create the memoized function per scope, so the cache is discarded with the scope |
Edge cases and complexity
- A lookup walks one node per argument, so it costs O(k) for k arguments after the key work; version 1 builds a key of O(k) length.
thisis not part of the key. That is fine for pure functions and wrong for methods, so memoize only functions of their arguments.- If the function throws, nothing is cached and the next call retries.
- For an async function the cached value is the promise. A rejected promise stays cached, which turns a one-off network error into a permanent failure, unless you delete the entry when the promise rejects.
Implement deepClone in JavaScript that survives self-referencing data and keeps the shared structure intact in the copy. Handle plain objects, arrays, Dates and RegExps. Explain the algorithm and its time and space complexity.
Sample Answer
Direct answer
To clone data that refers to itself, keep a lookup from every original object to the copy you made of it, a memo. Before cloning an object, check the memo: if the object already has a copy, return that copy instead of cloning again. The one rule that makes cycles work is to put the new empty copy into the memo before visiting the object's children, so a child that points back to its parent finds the parent's copy waiting. The same lookup also keeps shared structure intact: if the original has a === b, the clone has a === b, and neither points back into the original. Time is O(n + e) for n distinct objects and e property slots, and space is O(n) plus the call stack, so both are linear in the size of the data (O(n) means the work grows in proportion to n).
Algorithm
- Primitives and functions are returned unchanged.
- If
seen.has(value), returnseen.get(value). - Create the empty copy:
new Date(ms),new RegExp(source, flags)(withlastIndexcopied),new Array(length), or an object with the same prototype (the object it inherits methods from). seen.set(value, copy).- For arrays and objects only, loop over the enumerable own keys (the properties stored directly on the object, not inherited, that are enumerable; the code below also keeps enumerable symbol keys, which
Object.keyswould skip) and fill the copy withdeepClone(child, seen). - Return the copy.
seen is a WeakMap keyed by object identity. A WeakMap is a map whose keys must be objects and are held weakly: the map alone does not keep a key alive, so once nothing else refers to the objects, the entries can be garbage-collected. A Map would also work for a call-scoped memo.
Tracing a cycle
Take a = {}; a.self = a, an object whose self property points back at itself. seen starts empty.
| Step | What happens | seen afterwards |
|---|---|---|
| 1 | deepClone(a): not in seen; make an empty copy a2 and register it | { a -> a2 } |
| 2 | Visit key self: deepClone(a) again. seen.has(a) is true, so return a2 without recursing | { a -> a2 } |
| 3 | a2.self = a2; return a2 | { a -> a2 } |
The result has a2.self === a2: the cycle points at the copy, not the original. brokenClone in the listing registers after the loop, so at step 2 seen is still empty and the call recurses forever.
Implementation
The building blocks in the listing: Reflect.ownKeys(value) lists all own keys, including symbol keys and non-enumerable ones, and propertyIsEnumerable.call(value, key) then keeps only the enumerable ones; Object.create(Object.getPrototypeOf(value)) makes an empty object with the same prototype as the original; the Object.defineProperty call with { value, writable, enumerable, configurable } writes a plain data property and never triggers the __proto__ setter. A private field such as #secret is storage that only the class's own methods can reach by name; it is not a property, so no key-listing function can see it.
import assert from 'node:assert/strict';
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value); // already copied: reuse the copy
let out;
if (value instanceof Date) {
out = new Date(value.getTime());
seen.set(value, out);
return out;
}
if (value instanceof RegExp) {
out = new RegExp(value.source, value.flags);
out.lastIndex = value.lastIndex;
seen.set(value, out);
return out;
}
if (Array.isArray(value)) {
out = new Array(value.length);
} else if (Object.prototype.toString.call(value) === '[object Object]') {
out = Object.create(Object.getPrototypeOf(value));
} else {
throw new TypeError(`deepClone: unsupported ${Object.prototype.toString.call(value)}`);
}
seen.set(value, out); // register BEFORE visiting children so a cycle finds it
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue;
Object.defineProperty(out, key, {
value: deepClone(value[key], seen),
writable: true, enumerable: true, configurable: true,
});
}
return out;
}
// Variant: nested plain objects become Maps, arrays stay arrays.
function objectsToMaps(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
if (Array.isArray(value)) {
const arr = new Array(value.length);
seen.set(value, arr);
for (const i of Object.keys(value)) arr[i] = objectsToMaps(value[i], seen);
return arr;
}
if (Object.prototype.toString.call(value) === '[object Object]') {
const map = new Map();
seen.set(value, map);
for (const key of Object.keys(value)) map.set(key, objectsToMaps(value[key], seen));
return map;
}
return value; // Dates, RegExps and the rest are returned as they are
}
// --- harness ---
const shared = { tag: 'shared' };
const src = { a: shared, b: shared, list: [shared], when: new Date(0), re: /x/g };
src.self = src;
src.list.push(src.list);
const c = deepClone(src);
assert.notEqual(c, src);
assert.equal(c.self, c); // cycle points at the COPY
assert.equal(c.a, c.b); // sharing preserved
assert.equal(c.list[0], c.a);
assert.equal(c.list[1], c.list); // array cycle
assert.notEqual(c.a, shared); // and it is a real copy
assert.notEqual(c.when, src.when);
assert.equal(c.when.getTime(), 0);
const cursor = /a/g; cursor.lastIndex = 2;
assert.equal(deepClone(cursor).lastIndex, 2); // the regex cursor is copied
const sym = Symbol('k');
assert.equal(deepClone({ [sym]: 1 })[sym], 1); // enumerable symbol keys are kept
console.log('cycle:', c.self === c, ' shared:', c.a === c.b, ' fresh:', c.a !== shared);
// the ordering bug: registering after the children loop never terminates
function brokenClone(v, seen = new WeakMap()) {
if (v === null || typeof v !== 'object') return v;
if (seen.has(v)) return seen.get(v);
const out = Array.isArray(v) ? [] : {};
for (const k of Object.keys(v)) out[k] = brokenClone(v[k], seen);
seen.set(v, out); // too late
return out;
}
try { brokenClone(src); } catch (e) { console.log('register-after bug ->', e.constructor.name); }
console.log('length key kept:', deepClone({ length: 3 }).length);
class Temp { constructor() { this.deg = 20; } hot() { return this.deg > 30; } }
const t = deepClone({ t: new Temp() }).t;
console.log('prototype kept:', t instanceof Temp, t.hot());
class Vault { #secret = 42; reveal() { return this.#secret; } }
try { deepClone(new Vault()).reveal(); } catch (e) { console.log('private field ->', e.constructor.name); }
try { deepClone({ m: new Map() }); } catch (e) { console.log(e.message); }
// Maps variant
const input = { z: 1, 2: 'two', a: { inner: [{ k: 1 }, 5] } };
input.a.back = input;
const m = objectsToMaps(input);
console.log(m instanceof Map, [...m.keys()]);
console.log(m.get('a') instanceof Map, Array.isArray(m.get('a').get('inner')), m.get('a').get('inner')[0] instanceof Map);
console.log('cycle kept:', m.get('a').get('back') === m);
console.log('input untouched:', input.a.back === input, input.a.inner[0].k === 1, !(input instanceof Map));
let chain = {}; let tip = chain;
for (let i = 0; i < 1_000_000; i++) { tip.next = {}; tip = tip.next; }
try { deepClone(chain); } catch (e) { console.log('depth 1,000,000 ->', e.constructor.name); }
Run in a node:22 container, it prints:
cycle: true shared: true fresh: true
register-after bug -> RangeError
length key kept: 3
prototype kept: true false
private field -> TypeError
deepClone: unsupported [object Map]
true [ '2', 'z', 'a' ]
true true true
cycle kept: true
input untouched: true true true
depth 1,000,000 -> RangeError
The assert calls throw on failure. They check that c.self === c (the cycle points at the copy, not at the original), that c.a === c.b (sharing preserved), that c.list[1] === c.list (an array containing itself), and that c.a !== shared (the copy is real). Two more checks cover lastIndex and an enumerable symbol key. The length key kept line shows that a plain object with a key named length is still cloned as an object: only Array.isArray selects the array branch, so array-like objects are not turned into arrays.
brokenClone in the same file registers the copy after the children loop. On the same input it raises RangeError, which shows why the order in step 4 matters.
This version keeps the original's prototype (Object.create(Object.getPrototypeOf(value))), so a class instance stays an instance and its methods work (prototype kept: true; hot() runs and returns false because deg is 20). The constructor never runs, though, and private #fields live outside the property list, so they are not copied: calling a method that reads one on the clone raises TypeError (the private field line). Unsupported built-ins (Map, Set, typed arrays) throw.
Complexity
- Time. Each distinct object is cloned once because later visits hit the memo. Work is O(n + e) for n objects and e total property slots. Each slot (an "edge") is one arrow from an object to a value it holds, and each is followed once. Treating a key lookup as constant, that is linear in the size of the data.
- Space. O(n) for the copy and the memo, plus O(d) call stack for the deepest chain d. A chain 1,000,000 objects deep raises
RangeError(the engine's error for exceeding the call stack, its fixed-size record of running functions; last line of the output); an explicit work list avoids that limit.
Converting nested plain objects to Maps
objectsToMaps uses the same memo technique. Plain objects become Maps, arrays stay arrays, and Date/RegExp and other leaves are returned as they are. The input is never written to, because every container is freshly created. The memo gets the new Map before its entries are filled, so input.a.back = input becomes m.get('a').get('back') === m.
Insertion order caveat. A Map preserves the order in which entries are set, and the loop sets them in Object.keys order. For objects that order is not strictly insertion order: integer-like keys come first in ascending order, then string keys in insertion order. The run shows ['2', 'z', 'a'] for an input written as z, 2, a. If the original key order matters, the order of a source that was already an object cannot be recovered, since the engine has already reordered it.
Pitfalls and trade-offs
- Registering after recursion. Shown above: infinite recursion on any cycle.
- Detecting cycles by throwing. An ancestor set that is cleaned up on the way out is simple and correct for rejecting loops, but it gives you no copy. A set of every node ever visited would wrongly reject legitimate shared references. The memo handles cycles and sharing together and is the better default when the goal is a faithful copy.
- Cloning shared references twice is not a crash but a silent change of meaning: updating
copy.ano longer updatescopy.b. - Property descriptors. Properties are re-defined as plain data properties, so getters, setters and non-writable attributes of the original are not reproduced.
Running the code
Save the listing as clone.mjs (the .mjs extension lets Node read import as an ES module) and run it with Node 22, which needs no packages:
node clone.mjs
Implement an EventEmitter class in JavaScript that lets callers subscribe, subscribe for a single call, unsubscribe and publish. Listeners may be registered with an optional context to use as this, and removal must target exactly the right registration. Explain how you store listener metadata and how you would stop leaks when components forget to unsubscribe.
Sample Answer
An event emitter (also called an event bus or publish/subscribe object: code publishes named events and any number of subscribers react) is a map from event names to lists of listener functions. on appends a registration, emit calls the registrations for that name in order, off removes one, and once registers something that removes itself before its first call. The detail that separates a good answer is what a "registration" is: an object holding the function, the optional context, a once flag and a removed flag. Matching on the function alone cannot tell two registrations of the same function apart, and a flag on the registration is what makes removal during an emit safe.
How listener metadata is stored
| Field | Purpose |
|---|---|
fn | The listener function |
context | The object that becomes this when the listener is called (the emitter itself if none is given, as in Node) |
once | If true, the registration is removed immediately before its call, so a re-entrant emit (an emit of the same event called from inside a listener, while the first emit is still running) cannot run it twice |
removed | Set when the registration is removed, so an emit that already copied the list can skip it |
Removal targets exactly one registration in two ways. on and once return an unsubscribe function bound to that registration object, which is exact even when the same function is registered twice. off(event, fn, context) matches both the function and the context with === and removes the most recent match, the same rule Node documents for removeListener. Lists are copied on every add or remove (copy-on-write: never edit an array in place, build a new one), so emit can iterate over the list it started with (its snapshot) without copying again; adds and removes cost O(n) and emit costs O(n) in the number of listeners for that event.
Implementation with checks
import assert from 'node:assert/strict';
import { EventEmitter as NodeEmitter } from 'node:events';
class EventEmitter {
#listeners = new Map(); // the # makes this a private field: only code inside the class can touch it
// event name -> array of registrations, in order
maxListeners = 10; // warn (once per event) when one event gets more than this
// A registration is the unit of identity: { fn, context, once, removed }.
#add(event, fn, context, once) {
if (typeof fn !== 'function') throw new TypeError('listener must be a function');
const reg = { fn, context, once, removed: false };
const list = this.#listeners.get(event) ?? [];
this.#listeners.set(event, [...list, reg]); // new array: an emit in progress keeps its own copy
if (list.length === this.maxListeners) { // this registration is number maxListeners + 1
console.warn(`possible leak: ${list.length + 1} listeners for "${String(event)}"`);
}
return () => this.#remove(event, reg); // unsubscribe handle for exactly this registration
}
on(event, fn, context) { return this.#add(event, fn, context, false); }
once(event, fn, context) { return this.#add(event, fn, context, true); }
#remove(event, reg) {
if (reg.removed) return false; // calling an unsubscribe twice is harmless
reg.removed = true; // an emit that already copied the list checks this flag
const rest = (this.#listeners.get(event) ?? []).filter((r) => r !== reg);
if (rest.length) this.#listeners.set(event, rest);
else this.#listeners.delete(event); // no empty arrays left behind
return true;
}
// Removes ONE registration whose fn AND context both match (the most recent one).
off(event, fn, context) {
const list = this.#listeners.get(event) ?? [];
for (let i = list.length - 1; i >= 0; i--) {
if (list[i].fn === fn && list[i].context === context) return this.#remove(event, list[i]);
}
return false;
}
// Everything one owner registered, for a component's teardown.
offAll(context) {
for (const [event, list] of [...this.#listeners]) {
for (const reg of list) if (reg.context === context) this.#remove(event, reg);
}
}
emit(event, ...args) {
const snapshot = this.#listeners.get(event) ?? [];
for (const reg of snapshot) {
if (reg.removed) continue; // removed by an earlier listener in this very emit
if (reg.once) this.#remove(event, reg); // before the call, so a re-entrant emit cannot run it twice
reg.fn.apply(reg.context === undefined ? this : reg.context, args);
}
return snapshot.length > 0;
}
listenerCount(event) { return (this.#listeners.get(event) ?? []).length; }
eventNames() { return [...this.#listeners.keys()]; }
}
const log = [];
// 1. Order, arguments, return value.
{
const e = new EventEmitter(); const seen = [];
e.on('x', (a, b) => seen.push(`1:${a}${b}`)); e.on('x', (a) => seen.push(`2:${a}`));
assert.equal(e.emit('x', 'p', 'q'), true); assert.equal(e.emit('none'), false);
assert.deepEqual(seen, ['1:pq', '2:p']);
}
// 2. Context becomes `this`; without one, `this` is the emitter.
{
const e = new EventEmitter(); const ctx = { name: 'widget' }; const who = [];
e.on('x', function () { who.push(this === ctx); }, ctx);
e.on('x', function () { who.push(this === e); });
e.emit('x'); assert.deepEqual(who, [true, true]);
}
// 3. once, and removing a once registration by its original function.
{
const e = new EventEmitter(); let n = 0; const f = () => n++;
e.once('x', f); e.emit('x'); e.emit('x'); assert.equal(n, 1);
e.once('x', f); e.off('x', f); e.emit('x'); assert.equal(n, 1);
}
// 4. Exact removal: same function, different contexts; duplicates; unsubscribe handle.
{
const e = new EventEmitter(); const a = { id: 'A' }, b = { id: 'B' }; const hits = [];
const f = function () { hits.push(this.id); };
e.on('x', f, a); e.on('x', f, b);
e.off('x', f, a); e.emit('x'); assert.deepEqual(hits, ['B']);
const un1 = e.on('y', f, a), un2 = e.on('y', f, a); // identical registrations
un1(); assert.equal(e.listenerCount('y'), 1); un1(); assert.equal(e.listenerCount('y'), 1);
un2(); assert.equal(e.listenerCount('y'), 0);
assert.equal(e.off('x', f, undefined), false, 'no registration has an undefined context');
log.push('exact removal ok: off(fn, ctxA) left only B; the handle removes just its own registration');
}
// 5. A listener unsubscribing itself during emit must not make others skip.
{
const e = new EventEmitter(); const order = []; let offA;
offA = e.on('x', () => { order.push('A'); offA(); });
e.on('x', () => order.push('B')); e.on('x', () => order.push('C'));
e.emit('x'); e.emit('x');
log.push(`self-unsubscribe emit1+emit2 -> ${order}`);
assert.deepEqual(order, ['A', 'B', 'C', 'B', 'C']);
}
// 6. Removing a LATER listener, or adding one, during emit.
{
const e = new EventEmitter(); const order = [];
const b = () => order.push('B');
e.on('x', () => { order.push('A'); e.off('x', b); e.on('x', () => order.push('late')); });
e.on('x', b);
e.emit('x');
const n = new NodeEmitter(); const nodeOrder = [];
const nb = () => nodeOrder.push('B');
n.on('x', () => { nodeOrder.push('A'); n.off('x', nb); }); n.on('x', nb);
n.emit('x');
log.push(`removed-in-emit this emitter: ${order} | Node EventEmitter: ${nodeOrder}`);
assert.deepEqual(order, ['A']); assert.deepEqual(nodeOrder, ['A', 'B']);
}
// 6b. off removes ONE registration per call, the most recent match (Node's documented rule).
{
const e = new EventEmitter(); let n = 0; const f = () => n++;
e.on('x', f); e.once('x', f); // the once registration is the most recent
e.off('x', f);
e.emit('x'); e.emit('x');
assert.equal(n, 2, 'the on() registration survived and ran on both emits');
assert.equal(e.listenerCount('x'), 1);
}
// 6c. A listener added during an emit does not run until the next emit.
{
const e = new EventEmitter(); const order = [];
e.on('x', () => { order.push('A'); if (order.length === 1) e.on('x', () => order.push('late')); });
e.emit('x'); assert.deepEqual(order, ['A']);
e.emit('x'); assert.deepEqual(order, ['A', 'A', 'late']);
}
// 7. Leaks: components that never unsubscribe.
{
const warnings = []; const realWarn = console.warn; console.warn = (m) => warnings.push(m);
const bus = new EventEmitter();
const components = Array.from({ length: 12 }, (_, i) => ({ id: i }));
for (const c of components) bus.on('theme', function () {}, c);
console.warn = realWarn;
assert.equal(warnings.length, 1);
log.push(`leak warning ${warnings[0]}`);
for (const c of components) bus.offAll(c); // what a teardown hook calls
log.push(`after teardown listeners=${bus.listenerCount('theme')} eventNames=${JSON.stringify(bus.eventNames())}`);
assert.equal(bus.listenerCount('theme'), 0); assert.deepEqual(bus.eventNames(), []);
}
console.log(log.join('\n'));
Run in a node:22 container, it prints:
exact removal ok: off(fn, ctxA) left only B; the handle removes just its own registration
self-unsubscribe emit1+emit2 -> A,B,C,B,C
removed-in-emit this emitter: A | Node EventEmitter: A,B
leak warning possible leak: 11 listeners for "theme"
after teardown listeners=0 eventNames=[]
The self-unsubscribe pitfall
Take listeners [A, B, C]. A loop over the live array visits index 0 (A); if A removes itself the array becomes [B, C], index 1 is now C and B is skipped. This emitter avoids that because emit iterates a snapshot (the array as it was when the emit began) and removal builds a new array instead of editing that one. Here listener A removes itself on its first call, and the output shows A, B, C on the first emit and B, C on the second: nobody was skipped. Two policies for removals and additions during an emit are in use, and the output shows both:
- This emitter (and DOM
EventTarget): a listener removed by an earlier listener in the same emit is not called, because the loop checks theremovedflag. The DOM specification's dispatch step iterates only over listeners "whose removed is false" and notes that removal still has an effect on the cloned list. Listeners added during the emit do not run until the next emit. - Node's
EventEmitter: all listeners attached when the emit started are called, even if removed meanwhile. Its documentation says removals during an emit do not affect the emit in progress, and the printedA,Bfor Node confirms it.
The DOM policy is the one to choose for UI code: a component that has just unmounted should not receive one more call.
Stopping leaks when components forget to unsubscribe
The emitter holds a strong reference (one that keeps its target from being garbage collected, the automatic reclaiming of memory nobody can reach any more) to every fn, and each fn closes over its component's props, state and DOM nodes, so a forgotten registration keeps the whole component alive for as long as the emitter lives. Layered defences:
- Make correct usage the easy path. Because
onreturns the unsubscribe function, a React effect can return it directly as its cleanup. Thecontextargument plusoffAll(context)lets a component or class remove everything it registered in one teardown call, which the last test uses after twelve registrations. - Detect it. Warn when one event passes a count threshold; the test shows the warning at the 11th registration, the same idea as Node's default of 10 listeners per event. A growing count in development is the signal that some component forgot to clean up.
- Bound lifetimes. Use
oncefor one-shot reactions, and consider accepting anAbortSignal(the browser object used to cancel work) as an option so a whole group of registrations ends when one signal aborts. - Clean up empty entries. The emitter deletes an event's key when its last listener goes (
eventNames()is[]after teardown), so event names do not accumulate either.
Weak references to listeners are not a safe fix: a listener held only weakly can be collected while still intended, since garbage collection timing is not something to design behaviour around.
Edge cases to state out loud
emitreturnstruewhen at least one listener was registered, like Node.- An exception in a listener stops the loop and propagates to the
emitcaller, as in Node. If isolation matters, wrap each call intry/catchand rethrow the first error after the loop. - Registering the same function twice calls it twice;
offremoves one registration per call, the most recent match. The test registersonthenoncefor the same function, callsoffonce, and checks that theonregistration survives and runs on both emits. - A listener added during an emit does not run until the next emit, because adding builds a new array; the test checks that
lateappears only on the second emit. - Symbols and strings both work as event names because the lookup is a
Map.
Running the code
Save the listing as emitter.mjs (the .mjs extension lets Node read import as an ES module) and run it with Node 22, which needs no packages:
node emitter.mjs
That is every published Polyfills and JavaScript Utility Implementation question for Software Engineer so far. Browse the other topics in this category, or practice this one interactively.