A request is in flight from the moment it's sent until its response arrives or it fails. Deduplicating, or deduping, in-flight requests means that when a caller asks for something while a request for that same thing is still in flight, no new request is sent. The caller shares the request already on its way, and gets the same result when it arrives.
It matters because the parts of a page don't know about each other. The header, the profile card, and the comment box may each ask for user 7 as the page loads. Without deduping, that's three identical requests; with it, one. Requests count as the same when they have the same key, such as the URL or the user ID:
| Time | Event | What happens |
|---|---|---|
| 0 ms | getUser(7) is called. | Nothing is in flight for 7, so a request is sent. |
| 10 ms | getUser(7) is called. | A request for 7 is in flight, so this caller shares it. |
| 20 ms | getUser(8) is called. | A different key, so a second request is sent. |
| 200 ms | The response for 7 arrives. | Both callers for 7 get the same user, and 7 is forgotten. |
| 300 ms | getUser(7) is called. | Nothing is in flight for 7 any more, so a new request is sent. |
If a shared request fails, everyone sharing it gets the failure, and the key is forgotten all the same, so the next call tries again.
Deduping isn't caching. It only merges requests that overlap in time: once the response arrives, the key is forgotten, as at 300 ms above, so later calls get fresh data. A cache keeps the result and answers later calls from it without a request, which is faster but can hand out data that's out of date. A memoized async function, one that keeps its result for each argument it has seen, does both: callers share a request while it's in flight, and its result is kept afterwards.
Data-fetching libraries such as TanStack Query and SWR dedupe requests for the same key, and cache the results as well, which is why components can each ask for the data they need without coordinating with each other.
Write dedupe(fn, getKey). fn is an async function, such as one that fetches data. dedupe returns a function that takes the same arguments, passes all of them to fn, and always returns a promise.
While a call for a key is in flight, every other call with that key returns the same promise instead of calling fn again: ten components asking for user 7 at once cause one request, not ten.
The key is getKey(...args); without getKey, it's the first argument. Keys are compared the way Map compares them, so 1 and "1" are different keys.
Once the call settles, fulfilled or rejected, its key is forgotten, and the next call starts a fresh one: nothing is cached after that.
const getUser = dedupe((id) => fetchJson("/api/users/" + id));
const a = getUser(7);
const b = getUser(7); // no second request: b is the same promise as a
const c = getUser(8); // a different key, so a new request
await a;
getUser(7); // the first request has finished, so this starts a new one
const clock = useFakeTimers();
const fn = spy((id) => new Promise((resolve) => setTimeout(resolve, 100, "user " + id)));
const getUser = dedupe(fn);
const a = getUser(7);
const b = getUser(7);
await flush();
expect(fn).toHaveBeenCalledTimes(1);
expect(b).toBe(a);
await clock.tickAsync(100);
await expect(b).resolves.toBe("user 7");
const clock = useFakeTimers();
const fn = spy((id) => new Promise((resolve) => setTimeout(resolve, 100, "user " + id)));
const getUser = dedupe(fn);
const results = [];
getUser(7).then((value) => results.push(value));
getUser(8).then((value) => results.push(value));
await clock.tickAsync(100);
expect(fn).toHaveBeenCalledTimes(2);
expect(results).toEqual(["user 7", "user 8"]);
const clock = useFakeTimers();
const fn = spy((id) => new Promise((resolve) => setTimeout(resolve, 100, "user " + id)));
const getUser = dedupe(fn);
getUser(7);
await clock.tickAsync(100);
getUser(7);
await flush();
expect(fn).toHaveBeenCalledTimes(2);
const clock = useFakeTimers();
const error = new Error("server down");
const getUser = dedupe(() => new Promise((_, reject) => setTimeout(reject, 100, error)));
const reasons = [];
getUser(7).catch((reason) => reasons.push(reason));
getUser(7).catch((reason) => reasons.push(reason));
await clock.tickAsync(100);
expect(reasons).toHaveLength(2);
expect(reasons[0]).toBe(error);
expect(reasons[1]).toBe(error);
const clock = useFakeTimers();
const fn = spy(() => new Promise((_, reject) => setTimeout(reject, 100, new Error("server down"))));
const getUser = dedupe(fn);
getUser(7).catch(() => {}); // it rejects; this test only counts the calls
await clock.tickAsync(100);
getUser(7).catch(() => {});
await flush();
expect(fn).toHaveBeenCalledTimes(2);
const fn = spy(async () => "ok");
const search = dedupe(fn);
await search("shoes", { page: 2 });
expect(fn).toHaveBeenCalledWith("shoes", { page: 2 });
const fn = spy(async (user) => user.id);
const load = dedupe(fn, (user) => user.id);
const a = load({ id: 1, name: "Ada" });
const b = load({ id: 1, name: "Ada L." });
expect(b).toBe(a);
await a;
expect(fn).toHaveBeenCalledTimes(1);
const clock = useFakeTimers();
const fn = spy((id) => new Promise((resolve) => setTimeout(resolve, 100, id)));
const load = dedupe(fn);
const a = load(1);
const b = load("1");
expect(b).not.toBe(a);
await flush();
expect(fn).toHaveBeenCalledTimes(2);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Dedupe in-flight requests. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.