skip to content

In a JavaScript module, several parts of an app call `loadUser(id)` at nearly the same moment and each call fires its own request. How do you dedupe them by caching the in-flight promise, and what must the cache do when the operation rejects?

level: seniorimportance: should knowfreq 40%

answer

  1. one promise, many consumers
  2. store it before it settles
  3. keyed by request identity
  4. clear the entry on settle
  5. never memoise a rejection

basics

~20 s

Cache the promise, not the value: on the first call store fetchUser(id) in a Map keyed by id and hand that same promise to every later caller. Attach .finally(() => cache.delete(id)) so a rejection is never cached permanently.

solid answer

~50 s

A promise is multicast — any number of consumers can attach handlers to one promise and all receive the same outcome — so deduping means caching the promise itself the instant the work starts, before it settles. I keep a `Map` keyed by request identity: if there is no entry, call the loader, store the promise, return it; if there is one, return it unchanged. Concurrent callers then share a single request. Cleanup matters as much as the cache: `.finally(() => inFlight.delete(id))` removes the entry once it settles, so a failure is not memoised forever — otherwise one transient blip poisons that key for the life of the process and every future caller replays the stored error. This is single-flight deduplication, not result caching; if you also want to reuse a successful value for a while, that is a separate cache with its own TTL layered on top. Note that every caller receives the same resolved object reference, so a mutation by one is seen by all.

code

javascript · 18 lines
javascript
const inFlight = new Map();

function dedupe(key, work) {
  if (!inFlight.has(key)) {
    inFlight.set(key, work().finally(() => inFlight.delete(key)));
  }
  return inFlight.get(key);
}

let requests = 0;
const fetchUser = (id) =>
  new Promise((resolve) => setTimeout(() => resolve({ id, n: ++requests }), 50));

Promise.all([
  dedupe('u1', () => fetchUser('u1')),
  dedupe('u1', () => fetchUser('u1')),
  dedupe('u1', () => fetchUser('u1')),
]).then((users) => console.log(requests, users[0] === users[2])); // 1 true

go deeper

for a junior

Know that one promise can be handed to many callers and they all get the same result, so storing the promise as soon as the work starts is what stops duplicate requests. Remember the entry has to be removed once it settles.

for a middle

Explain the Map-keyed implementation and the cleanup: .finally deletes the entry so the dedupe window is exactly the in-flight period, and a rejection is never memoised. Be able to say why the key must include every parameter that changes the result.

for a senior

Show the operational judgment: a permanently cached rejection looks like a broken service with no traffic, retries belong inside the shared flight, and callers share one object reference. Separate single-flight dedupe from result caching with a TTL rather than conflating them.

for a principal

Own where this layer lives at all — in-process dedupe helps one instance, while a fleet still stampedes a cold key, so decide when the answer is a shared cache, request coalescing at the edge, or a different data-loading design entirely.

## Why caching the promise works at all A promise is a broadcast channel with one message. Any number of consumers may call `.then` on it, before or after it settles, and each gets the same fulfilment value or the same rejection reason. That is what makes deduplication possible: you do not need the result to exist yet, you only need something to hand out. So you store the promise the moment the work starts. ```js const inFlight = new Map(); function loadUser(id) { if (!inFlight.has(id)) { inFlight.set(id, fetchUser(id).finally(() => inFlight.delete(id))); } return inFlight.get(id); } ``` Three callers in the same tick now share one `fetchUser` call. This is often called *single-flight*: at most one execution per key is in progress at a time. ## The window the cache actually covers Because the entry is removed on settlement, the dedupe window is exactly the duration of the operation — no longer. A caller arriving one tick after the request finishes starts a fresh one. That is the correct default for a dedupe helper: it removes *duplicate concurrent work* without making any claim about how long a result stays valid. Reusing results is a different concern with different failure modes (staleness, invalidation, memory), and mixing the two into one Map is how you end up serving a user record from twenty minutes ago because nobody remembered the cache had no expiry. ## Why the rejection path is the whole question Drop the `.finally` and the pattern quietly becomes a landmine: ```js if (!inFlight.has(id)) inFlight.set(id, fetchUser(id)); // never cleared return inFlight.get(id); ``` If that first call fails for any reason — a blip, a restart on the other side — the rejected promise stays in the Map. Its state is frozen, so every future call for that id replays the same error instantly, forever, with no request ever sent. From the outside the service looks permanently broken for exactly those ids, recovers only on redeploy, and shows no traffic to explain it. Clearing on settlement is what keeps the failure transient. If you also want to keep *successes* around, be explicit about the asymmetry: delete on rejection always, and move fulfilled entries into a separate TTL cache. ```js const entry = fetchUser(id) .then((user) => { results.set(id, { user, at: Date.now() }); return user; }) .finally(() => inFlight.delete(id)); ``` ## Choosing the key The key must capture everything that changes the result: the id, plus any filter, locale, or auth scope. A single `let inFlight = null` only works for a genuinely parameterless loader such as a bootstrap config. Building keys with `JSON.stringify(params)` is common and mostly fine, but remember it is order-sensitive — `{a:1,b:2}` and `{b:2,a:1}` stringify differently and would occupy two entries — so normalise the fields you include, or build the key by hand. ## Sharp edges worth naming in an interview - **Shared object identity.** Every caller resolves to the *same* object. If one of them mutates the returned user, the others see it. Return a frozen or per-caller copy when consumers are allowed to mutate. - **One caller's fate is everyone's.** A shared failure fails every waiting caller together, which is usually what you want, but it means a retry wrapper must sit *inside* the deduped function so the retries are shared too. Putting retry outside means each caller retries independently and you have re-created the stampede you were preventing. - **Every consumer needs its own handler.** Sharing the promise does not share error handling: a caller that takes the promise and never attaches a `catch` still produces its own unhandled rejection. - **Cancellation does not compose naturally.** One caller walking away must not abort the shared operation for the others, so a dedupe layer either ignores per-caller cancellation or tracks a reference count and only aborts when the last consumer leaves. - **Bound the map.** Keys accumulate while requests are in flight; with a hot key space and slow upstream, an unbounded Map of pending entries is real memory. Clearing on settle handles the normal case, but a helper used across thousands of ids should still be reviewed for that. The test that distinguishes a strong answer: cache the promise (not the value), key it correctly, and clear it on settlement so failure never becomes permanent.

  • What breaks if the map entry is never removed after the promise settles?
    A single failure becomes permanent for that key. The rejected promise stays in the map, its state is frozen, and every future caller replays that same error instantly with no request ever sent — the code looks broken for exactly those ids until the process restarts. Clearing on settlement in a `.finally` keeps a transient failure transient.
  • How is this different from caching the resolved result?
    Deduping covers only the window while the operation is in flight and makes no claim about validity afterwards; a result cache deliberately serves a past value and therefore needs a TTL and an invalidation story. Keeping them as two layers lets you dedupe everything safely while caching results only where staleness is acceptable.
  • If the deduped operation should also be retried, where does the retry belong?
    Inside the deduped function, so the retry attempts are part of the single shared flight and every waiting caller benefits from them. Putting the retry outside means each caller retries independently against the same failing dependency, recreating exactly the stampede the dedupe was there to prevent.
  • What surprises callers about the value they get back from a deduped loader?
    They all receive the same object reference, not copies. If one consumer mutates the returned user, every other consumer sees the mutation, and a later arrival that hits the same in-flight entry inherits it too. Freeze the result, or hand out a structured copy per caller, whenever consumers are allowed to mutate what they receive.

It is a queue at a ticket window: the first person's request is the one placed, and everyone behind gets a copy of the same answer instead of asking again.

saying these in an interview costs you the question

  • Caches only the resolved value, so concurrent callers still each fire
  • Leaves the rejected promise in the map forever
  • Uses one shared variable for a loader that takes parameters
  • Assumes each caller gets its own copy of the resolved object
  • Puts the retry outside the dedupe so every caller retries separately

context