One Request, Many Callers: Coalescing Duplicate fetch() Calls in JavaScript
Open the network tab of almost any single-page app and you'll find it: the same /api/me request fired three, five, sometimes ten times within a few milliseconds. The header wants the current user, so does the sidebar, th
Open the network tab of almost any single-page app and you'll find it: the same /api/me request fired three, five, sometimes ten times within a few milliseconds. The header wants the current user, so does the sidebar, the avatar, and the settings link. Every component calls fetch() independently, and none of them knows the others asked.
On the server, the same thing happens when a cache entry expires and a hundred concurrent requests all miss it at once and hit the database together. That's the "thundering herd" (or "cache stampede") problem.
The fix for both is request coalescing: while a request for a given key is in flight, every other caller gets the same promise instead of starting a new request. Data libraries like TanStack Query and SWR do this internally, but the core idea fits in about 40 lines, and understanding it helps you spot the edge cases those libraries handle for you.
All code here runs unmodified on Node.js 20+ (which ships fetch globally) and in modern browsers.
The naive version and its bugs
The obvious implementation is a Map from URL to promise:
const inFlight = new Map();
function naiveFetch(url) {
if (!inFlight.has(url)) inFlight.set(url, fetch(url));
return inFlight.get(url);
}
This has three problems:
- It never forgets. Entries stay in the map forever, so this is a memory leak that also serves stale data.
-
A
Responsebody can only be read once. If two callers both callres.json()on the sameResponse, the second one throwsTypeError: Body is unusable: Body has already been read. - Failures are cached. One network blip and that URL is broken until the page reloads.
A correct version
// coalesce.mjs
export function createCoalescingFetch({ ttlMs = 0 } = {}) {
const inFlight = new Map(); // url -> Promise<{status, headers, body}>
return async function coalescedFetch(url, { signal } = {}) {
let shared = inFlight.get(url);
if (!shared) {
shared = (async () => {
const res = await fetch(url);
const body = await res.text(); // read once, share the text
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return { status: res.status, headers: Object.fromEntries(res.headers), body };
})();
inFlight.set(url, shared);
shared.then(
() => {
if (ttlMs > 0) setTimeout(() => inFlight.delete(url), ttlMs).unref?.();
else inFlight.delete(url);
},
() => inFlight.delete(url) // never cache failures
);
}
if (!signal) return shared;
// Let one caller give up without cancelling the request for everyone.
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
shared.then(resolve, reject).finally(() =>
signal.removeEventListener("abort", onAbort)
);
});
};
}
Let's walk through the decisions.
Read the body once, share the result. The shared promise resolves to a plain object containing the body text, not the Response. Every caller can read it as many times as they like. (Callers share the same object, so treat it as read-only, or Object.freeze it if you want that enforced.)
Evict on settle. As soon as the request finishes, the entry is removed, so the next call goes to the network again. Coalescing is about de-duplicating concurrent work, not caching. If you do want a short cache, ttlMs keeps the settled promise around a little longer. The .unref?.() call stops that timer from keeping a Node.js process alive. In browsers, setTimeout returns a number, so the optional call simply does nothing.
Never cache failures. The rejection handler deletes the entry immediately. Everyone who was already waiting gets the same error (which is correct, since they asked at the same moment), but the next caller retries.
Isolate cancellation. This is the subtle one. If you passed a caller's AbortSignal into the shared fetch(), then the first component to unmount would cancel the request for every component. Instead, the shared request runs without a signal, and each caller races the shared promise against its own signal. A caller that aborts gets rejected right away, and everyone else is unaffected.
Proving it works
Claims about concurrency are cheap, so let's test them against a real HTTP server that counts how many requests it receives. The server waits 100 ms before responding, to give the callers time to overlap:
// demo.mjs
import http from "node:http";
import assert from "node:assert/strict";
import { createCoalescingFetch } from "./coalesce.mjs";
let hits = 0;
let failNext = false;
const server = http.createServer((req, res) => {
hits++;
setTimeout(() => {
if (failNext) {
failNext = false;
res.writeHead(503).end("busy");
return;
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ path: req.url, hit: hits }));
}, 100);
});
await new Promise((r) => server.listen(0, r));
const base = `http://127.0.0.1:${server.address().port}`;
Ten concurrent callers, one request:
const get = createCoalescingFetch();
const results = await Promise.all(
Array.from({ length: 10 }, () => get(`${base}/user/1`))
);
assert.equal(hits, 1);
assert.ok(results.every((r) => r === results[0]));
console.log("10 callers ->", hits, "request");
Once settled, the next call is fresh, and different URLs are never merged:
await get(`${base}/user/1`);
assert.equal(hits, 2);
console.log("after settle ->", hits, "requests");
await Promise.all([get(`${base}/a`), get(`${base}/b`)]);
assert.equal(hits, 4);
console.log("two urls ->", hits, "requests");
Failures are shared, then evicted:
failNext = true;
const outcomes = await Promise.allSettled([get(`${base}/x`), get(`${base}/x`)]);
assert.equal(hits, 5);
assert.ok(outcomes.every((o) => o.status === "rejected"));
const retry = await get(`${base}/x`);
assert.equal(hits, 6);
assert.equal(retry.status, 200);
console.log("failure shared then evicted ->", hits, "requests");
One caller aborting doesn't cancel the other:
const ac = new AbortController();
const p1 = get(`${base}/slow`, { signal: ac.signal });
const p2 = get(`${base}/slow`);
setTimeout(() => ac.abort(new Error("user navigated away")), 20);
const [r1, r2] = await Promise.allSettled([p1, p2]);
assert.equal(r1.status, "rejected");
assert.equal(r1.reason.message, "user navigated away");
assert.equal(r2.status, "fulfilled");
assert.equal(hits, 7);
console.log("abort isolated ->", hits, "requests");
A short TTL turns it into a micro-cache:
const cached = createCoalescingFetch({ ttlMs: 500 });
await cached(`${base}/cfg`);
await cached(`${base}/cfg`);
assert.equal(hits, 8);
await new Promise((r) => setTimeout(r, 600));
await cached(`${base}/cfg`);
assert.equal(hits, 9);
console.log("ttl micro-cache ->", hits, "requests");
server.close();
console.log("all checks passed");
Running it:
$ node demo.mjs
10 callers -> 1 request
after settle -> 2 requests
two urls -> 4 requests
failure shared then evicted -> 6 requests
abort isolated -> 7 requests
ttl micro-cache -> 9 requests
all checks passed
When to use it, and when not to
Good fits:
-
GETrequests for data that many parts of the UI need at once (current user, feature flags, config) - Server-side calls to upstream APIs or slow queries, where a cache miss under load would otherwise hit the backend once per concurrent request
- Expensive non-HTTP async work, too. Swap
fetch(url)for any async function and the URL for any string key.
Don't use it for:
-
Non-idempotent requests. Two
POST /orderscalls are two orders. Never merge them. -
Requests that differ by more than the URL. If headers like
AuthorizationorAccept-Languagechange the response, include them in the key. Otherwise user A can receive user B's data, which on a server is a security bug, not a performance bug. - Streaming or huge responses. This version buffers the whole body into memory to share it.
Takeaways
- Share the promise, not the
Response, because a body can only be read once. - Delete the entry when the request settles, and always on failure.
- Never let one caller's
AbortSignalcancel shared work. Race it per caller instead. - Build the cache key from everything that changes the response.
Forty lines, no dependencies, and your network tab gets a lot quieter.
Note: An AI assistant helped draft this post; all code was run and tested (Node.js 20.19).
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.