Ch. 1 · JavaScript

JavaScript Single-Flight Requests

How to share one in-flight promise between concurrent callers so a burst of identical requests makes one network call, with errors handled.

~7 min readintermediateupdated Oct 19, 2026

Open a dashboard that has a header, a sidebar and a profile card, and all three components may ask for the current user as they mount. Without coordination that’s three identical GET /api/me requests within a few milliseconds. Multiply by a page with twenty widgets that each need the same config, or a server where hundreds of requests miss the same cache key at once, and duplicate work becomes a real cost.

Single-flight is the pattern that fixes it: while a request for a key is in progress, anyone else asking for the same key waits on that request instead of starting their own. The name comes from Go’s golang.org/x/sync/singleflight package, but the idea is natural in JavaScript because a promise is already a shareable handle to a result that hasn’t arrived yet.

This guide builds the helper, shows why caching results isn’t enough, walks through the failure cases interviewers ask about, and finishes with the token-refresh version you’ll find in most API clients. Every output below is from running the code in Node 22 with a fake 100 ms request.

Why a result cache doesn’t stop duplicate requests

The first idea most people have is a cache of results:

const resultCache = new Map();

async function naiveGetUser(id) {
  if (resultCache.has(id)) return resultCache.get(id);
  const user = await fetchUser(id);
  resultCache.set(id, user);
  return user;
}

await Promise.all([naiveGetUser('42'), naiveGetUser('42'), naiveGetUser('42')]);
// network calls: 3
JavaScript

All three calls ran the cache check before any of them finished, so all three saw an empty cache and started their own request. The cache only fills in when the first response arrives, 100 ms later, which is far too late for callers that arrived in the same tick.

That’s the gap single-flight closes. The fix is to store something in the map immediately, before any waiting happens. A promise is exactly that something.

Sharing the in-flight promise

Here’s the helper. It’s generic: a key, and a function that starts the work:

const inFlight = new Map();

function singleFlight(key, fn) {
  const existing = inFlight.get(key);
  if (existing) return existing;

  const p = fn().finally(() => inFlight.delete(key));
  inFlight.set(key, p);
  return p;
}

const [a, b, c] = await Promise.all(
  [1, 2, 3].map(() => singleFlight('user:42', () => fetchUser('42')))
);
// network calls: 1, same object: true, in flight now: 0
JavaScript

The first caller finds no entry, starts the request and stores its promise synchronously, before returning. The second and third callers run a moment later, find that promise, and return it. All three await the same promise, so they all receive the same resolved object, and only one request went out.

The .finally removes the entry when the request settles. That’s what makes this single-flight rather than a cache: the deduplication only lasts while the request is in progress. A call that arrives after the response has landed starts a fresh request (calling it again afterwards took the network count from 1 to 2). If you also want to reuse results for a while, put a separate cache with an expiry in front of this; keeping the two concerns apart makes each easier to reason about.

Why the function argument matters

singleFlight takes () => fetchUser('42'), not fetchUser('42'). If the caller passed a promise, the request would already have started before the helper could check the map, and nothing would be deduplicated. Accepting a function lets the helper decide whether to start the work at all. It’s the same reason timeout helpers that support cancellation take a function.

Choosing the key

The key has to include everything that changes the response. 'user:42' is fine for a simple lookup, but for a search endpoint it needs the query, the page, and any filters, and on a server it may need the tenant or the user’s permissions. Getting the key too coarse is the dangerous mistake: two different requests share one response, and a user can see someone else’s data. Getting it too fine just reduces the savings.

Failures, synchronous throws and shared objects

Three edge cases separate a production-ready helper from the five-line version.

Errors are shared, but not remembered

When the shared request fails, every waiting caller gets the same rejection:

const results = await Promise.allSettled(
  [1, 2].map(() => singleFlight('user:bad', () => fetchUser('bad')))
);
// [ 'rejected: 500 for bad', 'rejected: 500 for bad' ]  calls 1  in flight 0
JavaScript

That’s usually right. Ten callers asking at once shouldn’t trigger ten requests just because the first one failed. But because .finally clears the entry on rejection too, the failure isn’t cached: the next call after the error makes a new request. If you stored failed promises in a long-lived cache, one blip would break that key until the page reloaded, which is a bug people do ship.

Each caller still needs its own error handling. They share one promise, but each await or .catch is separate, and a caller that doesn’t handle the rejection will trigger an unhandled rejection warning like any other.

A synchronous throw escapes the helper

If fn throws before returning a promise (a bad argument, a URL builder that fails), fn().finally(...) never runs. The error propagates out of singleFlight synchronously, which surprises callers using .catch() on the result:

singleFlight('x', () => { throw new Error('sync boom'); });
// throws synchronously: sync boom
JavaScript

The sturdier version runs the task inside a promise chain, so a synchronous throw becomes a rejection like any other failure:

function singleFlight(key, fn) {
  const existing = inFlight.get(key);
  if (existing) return existing;

  const p = Promise.resolve().then(fn).finally(() => {
    if (inFlight.get(key) === p) inFlight.delete(key);
  });
  inFlight.set(key, p);
  return p;
}
JavaScript

The identity check in finally is a small safeguard. If something else replaced the entry for this key in the meantime (an explicit invalidation that started a newer request, for example), an old promise settling late won’t delete the newer entry.

Promise.resolve().then(fn) starts the work one microtask later rather than immediately. That makes no practical difference for network calls, and the trade is worth it.

Everyone gets the same object

Shared promises resolve to one shared value:

const [u1, u2] = await Promise.all([
  singleFlight('u', () => fetchUser('7')),
  singleFlight('u', () => fetchUser('7')),
]);
u1.name = 'changed by caller 1';
console.log(u2.name);  // changed by caller 1
JavaScript

Before single-flight, each caller had a private copy and could modify it. After, a component that edits its “copy” changes what every other caller sees. Treat the result as read-only, freeze it in development to catch offenders, or hand each caller a structuredClone of the value if the data is small and mutation is expected.

Cancellation and the shared request

If one caller gives up (the user navigated away from one widget), should the shared request be cancelled? Not by that caller alone: other callers may still be waiting. Passing one caller’s AbortSignal into the shared fetch would let any of them kill the request for everyone. A reference-counted approach works: the helper owns its own AbortController, tracks how many callers are still interested, and aborts only when that count reaches zero. Each caller can still stop waiting on its own by racing the shared promise against its own signal. Libraries such as TanStack Query implement this sort of bookkeeping, which is one reason to use one when requirements grow.

The token refresh case

The most common real-world use is refreshing an access token. When a token expires, every in-flight API call gets a 401 at roughly the same moment, and each one tries to refresh. With refresh-token rotation, the first refresh invalidates the old refresh token, so the second, third and fourth attempts fail and the user is logged out.

A single shared promise fixes it:

let refreshing = null;

function getFreshToken() {
  refreshing ??= refresh().finally(() => { refreshing = null; });
  return refreshing;
}

const tokens = await Promise.all(Array.from({ length: 5 }, getFreshToken));
// [ 'new-1', 'new-1', 'new-1', 'new-1', 'new-1' ]  refreshes: 1
JavaScript

??= assigns only when refreshing is null, so the first 401 starts the refresh and the others join it. When the refresh settles, the variable is cleared so the next expiry can refresh again. Each failed API call awaits the token and then retries once with it.

Practice question: the product page stampede

Question. An Express server renders product pages. Each render calls getPrice(sku), which checks a Redis cache and, on a miss, calls a slow pricing service (about 800 ms). Prices expire from Redis every 60 seconds. Every minute, the pricing service’s load spikes and some requests time out. Explain the spike and how single-flight helps. What are its limits here?

Answer. When a popular SKU’s cache entry expires, every request that arrives during the 800 ms before the first response is written back also misses, and each calls the pricing service. At 200 requests per second for one SKU, that’s around 160 identical calls in one burst, every minute. This is a cache stampede. Wrapping the miss path in singleFlight('price:' + sku, () => fetchAndCache(sku)) means one request per SKU goes to the pricing service and the rest wait on it. The limits: the map lives in one Node process, so with eight processes behind a load balancer you still get up to eight calls per SKU; cross-process deduplication needs a Redis lock or a refresh-ahead job. Errors are shared, so if the one call times out, all waiting requests fail together, which argues for serving a slightly stale price on error. And the key must include anything that changes the price, such as currency or customer tier. A strong answer names the stampede, quantifies it, and is clear that single-flight is per process.

More on this topic

More in JavaScript

read ✓JavaScript · hard

JavaScript Timeouts with Promise.race

Why Promise.race times out the caller but not the work, how to clear the timer and abort the request, and when AbortSignal.timeout is enough.

~7 min readread →
esc