Event emitters, debounce and throttle
Two things get asked here and they are asked in different rounds. Publish/subscribe is a design question - what an emitter buys you, what it costs, and why the same shape turns up in Node, in the DOM and in every state library. Debounce and throttle are a typing question, and the trap is that most people can define one and then implement the other by accident. Say the definitions in terms of the LAST call versus the FIRST call and you will never mix them up again.
Publish/subscribe, and what it actually costs
An emitter is a map from event name to a list of functions, plus a method that calls them all. That is the entire implementation, and being able to say so is worth more than any amount of terminology: on pushes onto the list, emit iterates it, off splices out of it.
What it buys is that the publisher does not know its subscribers. A button that emits saved does not import the toast, the analytics call and the cache invalidator, so any of them can be added or removed without touching it. What it costs is exactly the same property in reverse: nothing statically links the emit to the handlers, so control flow is invisible, 'find all callers' stops working, and every listener you forget to remove is a leak that also keeps the whole closure alive.
You meet the same shape three times over: Node's EventEmitter, the DOM's addEventListener, and the subscribe callback in every store - Redux, Zustand, a React useSyncExternalStore. When an interviewer asks you to implement one, they are usually really asking whether you understand the store you use every day.
Debounce and throttle, said so you cannot mix them up
Debounce: wait until the calls STOP. Every new call resets the clock, so the function runs once, N milliseconds after the last call. If the calls never stop, it never runs.
Throttle: run at most once per N milliseconds, no matter how many calls arrive. The clock is not reset by new calls; it runs, then ignores everything until the window is up.
Choose by asking what you want when a burst never ends. During a two-minute continuous scroll a debounced handler fires zero times and a throttled one fires every 100ms - so scroll, mousemove and resize-while-dragging are throttle, and search-as-you-type, autosave and 'validate this field' are debounce. Resize is the one that gets argued about: throttle it if you are repositioning something while it moves, debounce it if you are doing an expensive relayout only when the user has stopped.
Questions
Answer each one yourself before you open it.
01What is the publish/subscribe pattern?
Publishers emit named events without knowing who is listening, and subscribers register handlers for a name. The emitter in the middle holds a map from event name to a list of functions and calls them when the event fires.
The value is the decoupling: the code that publishes has no import of the code that reacts, so you add a fifth listener without editing the publisher. The cost is that the coupling still exists, it is just invisible to your tools - nothing takes you from the emit to the handler.
Follow-up When would you NOT use it?
When there is exactly one consumer and it always runs. Then it is a function call wearing a costume, and you have traded a readable stack trace for nothing. Emitters earn their keep at one-to-many, or where the consumer set genuinely varies.
02Where do you already use an event emitter without calling it that?
addEventListenerin the DOM,EventEmitterin Node - streams, servers, processes are all emitters - and thesubscribefunction in every state library, including whatuseSyncExternalStoreexpects React to be given.03Is
emitsynchronous or asynchronous?Synchronous, in both Node and a hand-rolled emitter.
emitis aforloop that calls each listener right there, so it returns only after every handler has run.Two consequences. A slow listener blocks the emitter and everything after it, so one handler doing a heavy sync parse stalls the whole chain. And a throwing listener propagates out of
emitinto the publisher's stack, which is usually not what the publisher expected - the common fix is a try/catch inside the loop so one bad subscriber cannot take the rest down.04How do you remove a listener you added with an inline arrow function?
You cannot.
offandremoveEventListenermatch by reference, and a fresh arrow is a different function every time. Keep a named reference, or use thesignaloption onaddEventListenerand abort it.This is the number one source of listener leaks in React: an inline handler added in an effect, and a cleanup that calls
offwith a new arrow, so the removal silently does nothing and every remount adds another listener. The symptom is a handler that eventually fires eight times.05How does
oncework?It registers a wrapper that removes itself before calling the real handler, so the listener list is clean even if the handler throws.
Removing first, not after, is the detail worth showing. If you call the handler first and it throws, the removal line never runs and the 'once' listener stays registered forever. Also keep the original function on the wrapper - otherwise
off(name, original)cannot find the wrapper to remove.06What happens if a listener is added or removed while an event is being emitted?
It depends on whether the implementation iterates a copy. Node copies the listener array before iterating, so a listener added during an emit does not run until the next one, and one removed during an emit still runs if the loop has not reached it yet.
Iterate the live array and you get skipped listeners: splice out index 0 while the loop is at index 0 and index 1 shifts down into a slot the loop has already passed. Copying with a spread before the loop is one character of defence and it is what an interviewer is looking for.
07What is special about the
errorevent in Node?If an
errorevent is emitted and nothing is listening, Node throws it as an uncaught exception and by default the process dies.It is deliberate - a silently swallowed I/O error is worse than a crash. It means every long-lived emitter you create needs an error listener, even if it only logs.
08Define debounce.
Delay the call until the calls stop: every new call resets the timer, so it runs once, N milliseconds after the LAST one.
Follow-up And throttle?
Run at most once every N milliseconds, ignoring the calls in between. The timer is not reset by new calls - it is tied to the last time the function actually ran.
09Search-as-you-type, window resize, scroll position, save button. Which gets which?
Search-as-you-type: debounce, around 300ms - you only want the query the user stopped on. Scroll: throttle, so it keeps firing while they scroll. Resize: throttle if you are moving something as it drags, debounce if the work is an expensive relayout you only need at the end. Save button: neither - you want a disabled button and an in-flight guard, not a timer.
That last one is the judgement half of the question. Debouncing a save means the user's click did nothing for 300ms with no feedback, and a double-click still ends up as one save only by accident. Disable the control while the request is in flight; that is honest about what is happening.
10Someone says debounce and throttle are basically the same. What do you say?
Give them a burst that never ends. In two minutes of continuous scrolling a debounced handler fires zero times and a throttled one fires roughly 1200 times at 100ms. They are opposites in exactly the case that matters.
The confusion comes from the common case: for a short burst followed by silence, both end up calling the function about once, so they look interchangeable in a demo. The difference only shows up under sustained input, which is the case you shipped to production.
11What are the leading and trailing edges?
Trailing runs the function after the wait, using the last arguments - the default for debounce. Leading runs it immediately on the first call and then suppresses the rest of the burst.
Leading is right when the first event is the meaningful one and the repeats are noise: a double-clicked button, a rapid-fire keyboard shortcut. Trailing is right when the last value is the meaningful one: a search box, a slider. Enabling both is a real option, and the trap in it is the single trailing call after an isolated single call - a naive both-edges implementation fires twice for one event.
12Why must a debounce be cancellable?
Because the pending timer outlives the thing that scheduled it. If a component unmounts with a debounced save still pending, the callback fires against a dead component - a state update on an unmounted tree, or a request nobody will read.
So the returned function gets a
cancel(drop the pending call) and usually aflush(run it now). In React, the cleanup function of the effect callscancel. Being asked to 'add cancel' is the standard second half of the debounce question and it is where most people stall.13You debounce a handler inside a React component and it never debounces. Why?
It is being recreated on every render, so each keystroke gets a brand new timer with a brand new closure and nothing is ever cancelled. The debounced function has to be stable - created once with
useMemo/useRef, not inline in the body.The same shape breaks throttle and memoize in components for the same reason: the cache or the timer lives in the closure, and a new closure every render means an empty cache every render. And once you make it stable you have to cancel it on unmount, which is the previous question.
Follow-up What is the modern alternative for a search box?
Debounce the VALUE, not the callback: a
useDebouncedValue(query, 300)hook that holds a state value updated by a timer, then drive the fetch off that in an effect. It composes with abort-on-change far more cleanly than a debounced handler does.14What is the difference between throttling in the client and rate limiting on a server?
Throttle is a client-side politeness - it drops your own extra calls. Rate limiting is enforcement - the server counts requests per key over a window and rejects the excess with a 429 and a
Retry-After.The reason the distinction is worth making is that client throttling is not a control: anyone can open a console and call your endpoint in a loop. Throttle to keep your own UI smooth; rate limit because you do not trust the client.
Follow-up How would you implement one?
Token bucket is the usual answer: a counter per key that refills at a fixed rate up to a cap, decremented per request, reject at zero. It allows a short burst and then a steady rate, which is what people actually want. A fixed window counter is simpler and lets through double the limit across a window boundary.
15What is memoisation, and when is it wrong?
Caching a function's return value against its arguments so a repeat call skips the work. It is wrong whenever the function is not pure, when the arguments do not serialise into a stable key, or when the cache grows without bound.
The unbounded growth is the one that bites in production: a memoised function keyed on user ids in a long-lived process is a memory leak with a nice name. Bound it - an LRU, a
Mapwith a size cap, or aWeakMapwhen the key is an object whose lifetime you want to follow.Follow-up How do you build the cache key?
JSON.stringify(args)is the honest default and you should name its limits out loud: key order matters,undefineddisappears, functions and symbols vanish, and it will not survive a cyclic object. For a single object argument, aWeakMapkeyed on the reference is better - no serialisation, and it does not pin the object in memory.16Is
useMemoin React memoisation in this sense?Not really. It has a cache of exactly one entry and it is allowed to throw that away whenever React likes, so it is a render-time optimisation, not a guarantee. Real memoisation keeps every result until you evict it.
17How do you memoise an async function?
Cache the PROMISE, not the resolved value. Store the promise on the first call so concurrent callers share the one in-flight request, and delete the entry on rejection so a failure is not cached forever.
Caching the value instead means five components mounting at once all miss the cache and fire five identical requests, because none of them has resolved yet. Caching the promise deduplicates them into one - this is exactly what a data-fetching library's request deduplication is.
18What is
onceoutside of an emitter?A wrapper that runs a function at most one time and returns the first result on every subsequent call. Initialisation, a one-time consent prompt, lazily creating a singleton.
19How do you debounce something that must not lose the last value?
That is what trailing edge already gives you - the timer fires with the arguments from the most recent call, not the first. If the burst can be interrupted by an unmount or a navigation, add
flushand call it on the way out.20Your throttled scroll handler feels laggy. What do you change?
For anything visual, throttle to the frame instead of to a millisecond count:
requestAnimationFrame, one call per paint. A 100ms throttle is six frames of staleness, which reads as lag no matter how cheap the handler is.Also check you are not doing layout reads and writes in the same handler - a
getBoundingClientRectafter a style write forces a synchronous reflow, and no amount of throttling makes that cheap.21Do you still need to throttle scroll handlers at all?
Often no.
IntersectionObservercovers 'is this element visible',position: stickycovers the sticky header, and CSS scroll-driven animations cover progress bars - all of them off the main thread. Reach for a scroll listener when nothing declarative fits.Worth volunteering, because it answers the question behind the question. Also mention
{ passive: true }on any scroll or touch listener you do add: without it the browser has to wait for your handler to see whether it callspreventDefault.22How would you test a debounce?
Fake timers. Call the debounced function three times, advance the clock by less than the wait and assert zero calls, advance past it and assert exactly one call with the last arguments.
Naming fake timers is the point - a test that actually sleeps 300ms is slow and flaky, and it will pass on a fast machine and fail in CI.
jest.useFakeTimers()orvi.useFakeTimers(), thenadvanceTimersByTime.23What is the difference between an emitter and a promise?
A promise settles once and remembers its value, so a handler attached afterwards still fires. An emitter fires many times and remembers nothing - subscribe after the event and you missed it.
That 'missed it' asymmetry is the real design difference and it is where bugs come from: a listener attached one tick too late gets nothing, silently. Emitters are for streams of events; promises for one result.
24What is the difference between an emitter and an observable?
An observable adds composition and lifecycle: operators like map and filter, a completion signal, an error channel, and a subscription object you unsubscribe from. An emitter has none of that - it is a list of functions and a loop.
25Where do the leaks come from in an emitter-based system?
Listeners that outlive the thing that added them. The handler closes over the component, the component unmounts, but the emitter still holds the function - so the component and everything it captured stays alive.
Node's 'possible EventEmitter memory leak detected' warning at eleven listeners exists exactly for this. The fix is to make removal impossible to forget: return an unsubscribe function from your
on, or take anAbortSignal, rather than trusting every caller to pass the same reference back tooff.26Why does
onreturning an unsubscribe function beatoff?Because it removes the chance of getting it wrong. The caller never has to hold the same function reference, never has to match the event name a second time, and the cleanup is one call that cannot be attached to the wrong listener.
It is also exactly what React's effect cleanup wants - subscribe in the effect, return what
ongave you, done. This is worth offering unprompted when you implement one; it shows you have used the pattern rather than only read about it.27Debounce, throttle, and rAF-throttle. One sentence on choosing.
Debounce when only the end of the burst matters. Throttle when you need regular updates during the burst. rAF-throttle when what you are updating is pixels, because more than once per frame is work nobody can see.
28How would you implement a simple pub/sub in twenty lines with no class?
A
Mapfrom name to aSetof handlers.onadds to the set and returns a closure that deletes it;emititerates a copy of the set. ASetgets you deduplication and O(1) removal for free.
Predict the output
The output is hidden. Predict it before you open it.
on, once, and two emits
const EventEmitter = require('events');
const bus = new EventEmitter();
bus.on('tick', (n) => console.log('on', n));
bus.once('tick', (n) => console.log('once', n));
bus.emit('tick', 1);
bus.emit('tick', 2);Show the output
on 1
once 1
on 2Listeners run in registration order, so on before once on the first emit. The once wrapper removed itself before running, so it is gone by the second emit and only the permanent listener fires.
Removing the wrong function
const EventEmitter = require('events');
const bus = new EventEmitter();
bus.on('x', () => console.log('hi'));
bus.off('x', () => console.log('hi'));
bus.emit('x');
console.log('listeners:', bus.listenerCount('x'));Show the output
hi
listeners: 1off matches by reference and those are two different function objects that merely look identical, so the removal is a silent no-op. Exactly the same thing happens with removeEventListener and an inline arrow, which is the most common listener leak in React cleanup code.
emit is synchronous
const EventEmitter = require('events');
const bus = new EventEmitter();
bus.on('go', () => console.log('handler'));
console.log('before');
bus.emit('go');
console.log('after');Show the output
before
handler
afteremit is a for loop over the listener array - it calls each handler on the current stack and returns when they are all done. Nothing is queued, so a slow listener blocks the publisher and a throwing listener throws out of emit.
Listener order and prepend
const EventEmitter = require('events');
const bus = new EventEmitter();
bus.on('e', () => console.log('one'));
bus.prependListener('e', () => console.log('zero'));
bus.emit('e');Show the output
zero
oneListeners run in array order and prependListener puts one at the front. Ordering is an implementation detail you should not depend on in application code - if two handlers must run in a set order they are one handler with two steps.
Debounce keeps only the last
function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
const search = debounce((v) => console.log('search', v), 100);
search('r');
search('re');
search('rea');
search('react');Show the output
search reactEach call clears the pending timer and starts a new one, so only the last set of arguments survives and the function runs once, 100ms after the final call. Four keystrokes, one request - which is the entire point of a search box debounce.
Throttle keeps only the first per window
function throttle(fn, ms) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last < ms) return;
last = now;
fn(...args);
};
}
const onScroll = throttle((y) => console.log('y', y), 100);
onScroll(0);
onScroll(10);
onScroll(20);
setTimeout(() => onScroll(300), 150);Show the output
y 0
y 300The first call fires immediately, the next two land inside the 100ms window and are dropped outright, and the one at 150ms opens a new window. Note what this loses: the value 20 is gone forever. A leading-only throttle never delivers the final position of a gesture, which is why real implementations add a trailing call.
Leading-edge debounce
function debounce(fn, ms, { leading = false } = {}) {
let t = null;
return (...args) => {
const callNow = leading && t === null;
clearTimeout(t);
t = setTimeout(() => {
t = null;
if (!leading) fn(...args);
}, ms);
if (callNow) fn(...args);
};
}
const fire = debounce((v) => console.log('fire', v), 50, { leading: true });
fire(1);
fire(2);
fire(3);Show the output
fire 1It runs on the first call of a burst and then swallows the rest until 50ms of silence resets it. This is the right shape for a submit button or a keyboard shortcut, where the first press is the intent and the repeats are noise - the opposite of a search box.
Memoise with a serialised key
function memoize(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);
};
}
let calls = 0;
const add = memoize((a, b) => {
calls++;
return a + b;
});
console.log(add(1, 2), add(1, 2), calls);Show the output
3 3 1Both calls return 3 and the underlying function ran once. Note cache.has rather than a truthiness check on cache.get - a memoised function that legitimately returns 0, '' or undefined would recompute forever with the naive version.
A cache key that is not the whole key
function memoize(fn) {
const cache = new Map();
return (a, b) => {
if (!cache.has(a)) cache.set(a, fn(a, b));
return cache.get(a);
};
}
const add = memoize((a, b) => a + b);
console.log(add(1, 2));
console.log(add(1, 5));Show the output
3
3Keying on the first argument only makes the second one invisible, so the second call is a cache hit that returns a wrong answer. The bug never throws and never logs; it just quietly returns stale results, which is how memoisation bugs usually reach production.
Debounce and `this`
function debounce(fn, ms) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), ms);
};
}
const panel = {
name: 'panel',
save: debounce(function () {
console.log('saving', this.name);
}, 10),
};
panel.save();Show the output
saving panelThe wrapper is a function, so calling it as panel.save() gives it this === panel; the arrow inside the timer inherits that this and apply forwards it. Make the wrapper an arrow, or call fn(...args) directly, and this is undefined by the time the timer fires - so the same code throws on a method.
once, outside an emitter
function once(fn) {
let done = false;
let value;
return (...args) => {
if (!done) {
done = true;
value = fn(...args);
}
return value;
};
}
let n = 0;
const init = once(() => ++n);
console.log(init(), init(), n);Show the output
1 1 1The second call short-circuits and returns the cached result, so the counter is only incremented once. Set the flag BEFORE calling fn, not after - otherwise a reentrant call from inside fn runs it a second time.
Write it from memory
Complete, runnable code. Practice typing it from memory before your interview.
EventEmitter
The whole thing: on, off, once, emit. Forty lines, and the round usually ends with 'now make it not blow up if a listener throws'.
class EventEmitter {
constructor() {
this.listeners = new Map(); // name -> Set of handlers
}
on(name, handler) {
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
this.listeners.get(name).add(handler);
return () => this.off(name, handler);
}
off(name, handler) {
const set = this.listeners.get(name);
if (!set) return this;
set.delete(handler);
if (set.size === 0) this.listeners.delete(name);
return this;
}
once(name, handler) {
const wrapper = (...args) => {
this.off(name, wrapper);
handler(...args);
};
wrapper.original = handler;
return this.on(name, wrapper);
}
emit(name, ...args) {
const set = this.listeners.get(name);
if (!set || set.size === 0) return false;
for (const handler of [...set]) {
try {
handler(...args);
} catch (err) {
console.error('listener for ' + name + ' threw', err);
}
}
return true;
}
listenerCount(name) {
const set = this.listeners.get(name);
return set ? set.size : 0;
}
}onreturns an unsubscribe function. Offer this unprompted - it is what makes cleanup impossible to get wrong, and it is exactly what a React effect wants to return.[...set]copies before iterating, so a handler that unsubscribes itself or adds another during the emit cannot corrupt the loop.onceremoves the wrapper BEFORE calling the handler, so a throwing handler still gets unregistered.wrapper.originalis there sooff(name, handler)could be extended to find it - say that you know it is missing rather than pretending it is not.- A
Setgives deduplication and O(1) delete. If they ask why not an array: removal is O(n) and the same function registered twice would fire twice.
debounce, with cancel and flush
The most-asked utility in front-end interviews. The second half is always 'now add cancel'.
function debounce(fn, wait = 300) {
let timer = null;
let lastArgs = null;
let lastThis = null;
function debounced(...args) {
lastArgs = args;
lastThis = this;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(lastThis, lastArgs);
}, wait);
}
debounced.cancel = function () {
clearTimeout(timer);
timer = null;
lastArgs = null;
lastThis = null;
};
debounced.flush = function () {
if (timer === null) return;
clearTimeout(timer);
timer = null;
fn.apply(lastThis, lastArgs);
};
debounced.pending = () => timer !== null;
return debounced;
}
// const search = debounce((q) => fetchResults(q), 300);
// input.addEventListener('input', (e) => search(e.target.value));
// on unmount: search.cancel();- A
functiondeclaration rather than an arrow, sothissurvives when the debounced function is used as a method. cancelis the half people forget, and it is the half that matters in React: a pending timer that fires after unmount updates a component that no longer exists.flushis what you call on a form submit - run the pending save now instead of losing it.- In a component, create it once (
useMemowith an empty dep array, or a ref) and cancel it in the effect cleanup. Created in the render body it never debounces anything.
throttle, leading and trailing
Ask which edges they want before you write it. The naive leading-only version loses the final call, and that is usually a bug.
function throttle(fn, wait = 100) {
let lastRun = 0;
let timer = null;
let lastArgs = null;
function throttled(...args) {
const now = Date.now();
const remaining = wait - (now - lastRun);
lastArgs = args;
if (remaining <= 0) {
if (timer) {
clearTimeout(timer);
timer = null;
}
lastRun = now;
fn(...args);
} else if (timer === null) {
timer = setTimeout(() => {
lastRun = Date.now();
timer = null;
fn(...lastArgs);
}, remaining);
}
}
throttled.cancel = function () {
clearTimeout(timer);
timer = null;
lastRun = 0;
};
return throttled;
}- Leading AND trailing: it fires immediately, then guarantees one more call at the end of the window with the newest arguments. That trailing call is what stops a scroll handler from missing the final position.
remainingis computed from the last actual run, not reset by incoming calls - that is the one line that makes this a throttle and not a debounce.- For anything visual, say you would use
requestAnimationFrameinstead: one call per paint, aligned to the frame, and free pausing in background tabs.
memoize with a custom key
Almost always followed by 'what happens when the argument is an object' and 'how does it not grow forever'.
function memoize(fn, keyFn) {
const cache = new Map();
function memoized(...args) {
const key = keyFn ? keyFn(...args) : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
}
memoized.clear = () => cache.clear();
memoized.size = () => cache.size;
return memoized;
}
// Async, deduplicating in-flight calls:
function memoizeAsync(fn, keyFn) {
const cache = new Map();
return function (...args) {
const key = keyFn ? keyFn(...args) : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const promise = Promise.resolve(fn.apply(this, args)).catch((err) => {
cache.delete(key);
throw err;
});
cache.set(key, promise);
return promise;
};
}cache.hasbeforecache.get, so a cachedundefinedor0is still a hit.- The async version caches the PROMISE, so five callers during one in-flight request share it instead of firing five requests. That is request deduplication, and it is what every data-fetching library does.
cache.delete(key)on rejection - otherwise one network blip is cached as a permanent failure.- Say the bound out loud: this
Mapgrows forever. In a long-lived process, cap it (an LRU) or key it on objects with aWeakMapso entries can be collected.
A debounced value hook
The React-shaped version of the question, and the one you actually want in a search box. No debounced callback, no stale closure.
import { useEffect, useState } from 'react';
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// function Search() {
// const [query, setQuery] = useState('');
// const debouncedQuery = useDebouncedValue(query, 300);
//
// useEffect(() => {
// if (!debouncedQuery) return;
// const controller = new AbortController();
// fetchResults(debouncedQuery, controller.signal).then(setResults);
// return () => controller.abort();
// }, [debouncedQuery]);
// }- The cleanup clearing the timer IS the debounce - a new keystroke changes
value, which reruns the effect, which cancels the previous timer. There is no separate debounce utility. - It also solves unmount for free: the same cleanup runs on unmount, so there is no pending timer to leak.
- Pairing it with
AbortControllerin the fetching effect fixes the other half of the race - the last request to be sent is not necessarily the last to arrive.
once
A five-line answer that shows you understand closures. Usually a warm-up before the emitter.
function once(fn) {
let called = false;
let result;
return function (...args) {
if (called) return result;
called = true;
result = fn.apply(this, args);
fn = null; // let the original be collected
return result;
};
}
const init = once(() => {
console.log('initialising');
return { ready: true };
});- Set
called = truebefore invokingfn, so a reentrant call cannot run it twice. - Nulling
fnafterwards releases whatever it closed over - a small thing, and exactly the kind of detail that gets noticed. - The same shape with a
Mapinstead of a boolean is memoize; say that connection out loud if both come up.