InterviewsVector
intermediateCommon5 min read · Updated Jul 18, 2026

Memoize API Calls: Request Deduplication and Caching

Memoize async API calls in JavaScript by caching the promise, not the result — in-flight deduplication, error eviction, TTL expiry, and stale-while-revalidate.


The problem

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.

Implementation

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;
  };
}

Usage

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.

Dry run — the moment that matters

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 refetches

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

The design decisions interviewers probe

  • Error eviction — cache the promise and a rejected promise is cached too: every future call replays the failure until TTL expiry ("negative caching" — almost never what you want by default). Hence the .catch that evicts and rethrows. This is the #1 follow-up; have the answer ready before it's asked.
  • Cache keyJSON.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.
  • Why not cache the 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.
  • TTL, but held simply — expiry checked on read; a periodic sweep is unnecessary for interview scope but worth naming for long-lived maps (expired entries linger until touched → slow leak).
  • Memory bounds — an unbounded 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.)

Edge cases interviewers probe

  • Concurrent duplicates — the core case; the synchronous cache.set handles it.
  • Two different option objects, same request — key canonicalization (sort keys, or narrow the key to what identifies the request).
  • Mutation of returned data — every caller shares one object; one component mutating it corrupts everyone. Options: freeze in dev, structuredClone on read, or immutability discipline. Interviewers use this to bridge into state-management design.
  • Cache invalidation on write — after 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 forwardingfn.apply(this, args) so memoized methods still work.

Common mistakes

  • Caching data after await — no deduplication (the headline bug).
  • Caching rejected promises forever — one blip bricks the feature until TTL.
  • console.log-driven cache observability in library code — return values and let callers instrument.
  • Keys via args.join(",") — collides objects into "[object Object]".
  • Confusing this with generic memoize — same closure skeleton, but async adds in-flight sharing, error eviction, TTL, and staleness: the four things this question is actually about.

Follow-up questions

  • "Add stale-while-revalidate." — serve the cached value immediately and refresh in the background, swapping the cache on success — the UX-latency trade SWR/React Query default to; sketching it shows you've consumed these tools thoughtfully.
  • "Add request cancellation." — hand each fetch an AbortSignal; on deduped calls, abort only when the last interested caller cancels (refcounting) — a genuinely senior wrinkle.
  • "LRU with max 100 entries."Map preserves insertion order: delete+re-set on read moves an entry to "newest"; evict map.keys().next().value when over cap.
  • "How do React Query / SWR relate?" — they are this function plus staleness states, focus-refetch, and cache subscriptions; being able to place your 40 lines inside their architecture is the point of the question.
  • "Dedupe across browser tabs?"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.