Promisify Callbacks
intermediateConvert error-first callback APIs into promise-returning functions, like Node's util.promisify.
Memoize async API calls in JavaScript by caching the promise, not the result — in-flight deduplication, error eviction, TTL expiry, and stale-while-revalidate.
Wrap an async function (say
fetch) so identical calls hit the network once: concurrent duplicates share one in-flight request, later calls get the cached result, and failures don't poison the cache.
The whole question turns on one decision most candidates get wrong:
Cache the promise, not the resolved data.
If you await the response and cache the data afterward, the cache is empty during the entire first flight — so five components requesting /api/user on mount fire five network requests, and your "cache" deduplicates nothing. Cache the promise synchronously, before the request resolves, and calls 2–5 receive the same pending promise as call 1. In-flight deduplication is the actual production feature (it's what React Query, SWR, and Apollo all do), and it's the line interviewers are listening for.
function memoizeAsync(fn, { ttl = 60_000, getKey = JSON.stringify } = {}) {
const cache = new Map(); // key → { promise, expiresAt }
return function (...args) {
const key = getKey(args);
const entry = cache.get(key);
if (entry && Date.now() < entry.expiresAt) {
return entry.promise; // pending OR settled — both deduplicate
}
const promise = Promise.resolve()
.then(() => fn.apply(this, args)) // sync throws become rejections
.catch((err) => {
cache.delete(key); // DON'T cache failures — retry next call
throw err; // rethrow: still a rejection for callers
});
// Cached synchronously — before the network responds. This line
// IS the deduplication.
cache.set(key, { promise, expiresAt: Date.now() + ttl });
return promise;
};
}const fetchJson = (url) => fetch(url).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});
const cachedFetch = memoizeAsync(fetchJson, { ttl: 30_000 });
// Five components mount at once:
const [a, b, c, d, e] = await Promise.all([
cachedFetch("/api/user"), cachedFetch("/api/user"),
cachedFetch("/api/user"), cachedFetch("/api/user"),
cachedFetch("/api/user"),
]);
// → ONE network request. a…e are the same object.t=0ms call 1: miss → start fetch → cache.set(key, pendingPromise) → return it
t=5ms call 2: HIT (promise still pending) → same promise returned
t=120ms fetch resolves → BOTH callers' .then run with one response
t=30s+ TTL expired → next call refetchesContrast the broken data-cache version: at t=5ms the cache is still empty (the set happens ~t=120ms), so call 2 starts a second request. Same code, one line moved, completely different system behavior.
.catch that evicts and rethrows. This is the #1 follow-up; have the answer ready before it's asked.JSON.stringify(args) works for URL+options but: key order isn't canonical ({a,b} vs {b,a} miss each other), undefined/functions vanish silently (why), and big args make big keys. Exposing getKey lets callers supply (args) => args[0] (URL-only) — and saying "key derivation is the caller's domain knowledge" is the senior framing.Response object? A fetch Response body is a one-shot stream — the second consumer's .json() throws body already consumed. Cache the parsed data promise (as above) or response.clone(). A great detail to volunteer.Map in a long-lived SPA grows forever. The real fix is an LRU (cap + evict oldest); mention it, sketch it only if asked. (Compare the single-slot extreme: memoizeLast.)cache.set handles it.POST /api/user, the cached GET /api/user is stale; expose invalidate(key) / tag-based invalidation. "There are only two hard things…" — but showing the API for invalidation beats quoting the joke.this forwarding — fn.apply(this, args) so memoized methods still work.await — no deduplication (the headline bug).console.log-driven cache observability in library code — return values and let callers instrument.args.join(",") — collides objects into "[object Object]".Map preserves insertion order: delete+re-set on read moves an entry to "newest"; evict map.keys().next().value when over cap.BroadcastChannel or a Service Worker as the shared cache authority — the systems-design escalation.Convert error-first callback APIs into promise-returning functions, like Node's util.promisify.
First settled task wins: build the primitive behind timeouts and fastest-source fetching.
Fire all tasks at once and gather results — plus what parallel really means on a single thread.
Run async tasks one after another and understand why reduce-with-promises works.