Deduplication and batching
When many parts of a page ask for the same data at once, send one request and share the answer; when they ask for many small pieces at once, collect them into one request. Two small ideas that remove most of the wasted traffic in a component-based app.
The problem: everyone asks at once
Picture a project management app. The page has a top bar with the user's avatar, a sidebar that shows the user's teams, a greeting that says "Good morning, Priya", and a settings link that only shows for admins. Four components, written by three different people, and each one needs the current user.
Each component does the natural thing: it fetches /api/me when it mounts. React mounts all four in the same render, so four identical GET /api/me requests leave the browser within a millisecond of each other. The server does the same work four times, the network carries the same bytes four times, and the browser keeps four copies of the same object.
Now add a task list with 50 rows. Each row shows the assignee's name and avatar, and each row fetches /api/users/:id for its assignee. Twelve people are assigned across those 50 rows, so the page sends 50 requests for 12 distinct users. Most of the requests are duplicates, and even the 12 distinct ones could have been a single request.
GET /users?ids=....Two different kinds of waste
The two stories show two different problems. Keep them apart, because they have different fixes.
- Duplicate requests. Many callers ask for exactly the same thing (
/api/me, or user 7 from five rows). The fix is deduplication: send one request, give the same answer to every caller. - Many small requests. Many callers ask for different but related things (users 3, 7, 9, 12). The fix is batching: collect the keys for a short window and send one request for all of them, like
/api/users?ids=3,7,9,12, then hand each caller its own piece.
Deduplication is almost always safe and almost always worth it. Batching needs a server endpoint that accepts many keys, and it trades a few milliseconds of waiting for far fewer round trips. A good data layer does both, and does them without the components knowing.
Why not fetch once at the top?
The old fix was to fetch everything in one parent and pass it down as props. It works, but it couples the parent to every child's data needs. When the sidebar starts showing the user's plan, someone has to remember to change the parent's request too. In a big app with hundreds of components and many teams, that does not scale.
Modern apps prefer colocation: each component declares the data it needs, right next to the code that uses it. Colocation is easy to change and easy to delete. Its cost is exactly the burst you just saw. Deduplication and batching are what make colocation cheap, so the code can say "I need user 7" in fifty places while the network sees one request.
What the waste really costs
A duplicate GET /api/me looks harmless: maybe 2 KB and 80 ms. The costs add up in less obvious places.
- Connection slots. Over HTTP/1.1 a browser opens at most 6 connections per origin. 50 row requests queue behind each other in batches of 6, and the important request for the page's main content waits in the same queue. HTTP/2 removes that limit but not the server cost.
- Server load. Every duplicate runs auth, a database query, and serialisation. A page viewed 1 million times a day with 40 avoidable requests each is 40 million wasted requests a day, about 460 per second.
- Rate limits. Many APIs limit requests per user per minute. A burst of 50 can trip a limit of 60 per minute after one page and a half, and the user sees errors that have nothing to do with their own actions.
- Inconsistent screens. Four separate requests can return four different versions if the data changes between them. The top bar says one name and the greeting says another. One shared response cannot disagree with itself.
The mental model: one key, one trip
Think of a shared office with one coffee run. The first person who wants coffee writes their order on a whiteboard and says "I'm going". Anyone who wants the same thing adds their name under that order and waits.
Anyone who wants something different adds a new line. A minute later one person walks to the cafe with the whole list and comes back with one tray.
Adding your name under an existing order is deduplication. Putting several different orders on one trip is batching. The whiteboard is a small table in your data layer, and the line on it that says what you want is the key.
Three ideas carry the whole lesson
- A key names the request. Two calls are "the same" exactly when their keys are equal.
["user", 7]and["user", 7]share;["user", 7]and["user", 9]don't. Everything else depends on getting the key right. - A promise is the shareable thing. You can't share a response that hasn't arrived yet, but you can share the promise of it. The first caller creates the promise; later callers with the same key get the same promise object.
- A window decides who shares. Dedup shares among callers whose requests overlap in time, or within a short interval after. Batching collects callers within one tick of the event loop (the current synchronous work plus the promise callbacks it queues), or a few milliseconds. Outside the window, a new trip starts.
When the window is "until the promise settles", you have pure in-flight deduplication. Stretch the window to "for 30 seconds after it settles" and you have a cache. Most libraries blur the two on purpose: they keep the answer for a while, show it at once, and refetch in the background when it gets old.
Where deduplication can happen
The same idea shows up at every layer between a component and the database. Each layer catches a different kind of duplicate, and a request that one layer misses can still be caught by the next.
Cache-Control, and shapes the server layers when it renders on the server.| Layer | What it shares | Window | Example |
|---|---|---|---|
| Data layer in the page | Identical reads by key | While in flight, plus a short time after | React Query, SWR, Apollo Client, your own map |
| Batcher in the page | Different keys of the same kind | One tick or a few ms | A DataLoader-style load(id) |
| Browser HTTP cache | Repeat GETs of a cacheable URL | The response's max-age | Cache-Control: max-age=60 |
| CDN or reverse proxy | Concurrent misses from many users | While the origin fetch is in flight | Request collapsing at Fastly, Varnish, nginx proxy_cache_lock |
| Server render | Identical calls during one server request | One request | React cache(), Next.js fetch memoization |
| API server | Database lookups from one request | One tick | DataLoader in a GraphQL resolver |
Deduplication is not caching (but they are neighbours)
People use the two words loosely, so be precise in an interview. In-flight deduplication only merges requests that overlap: once the answer arrives, the shared entry is removed. It never shows old data, because nothing is kept.
A cache keeps the answer after it arrives. That saves far more requests, but now you must decide how long the answer stays good and how it gets refreshed after a change. This lesson stays on sharing and grouping; the server-state libraries lesson later in this module covers the full cache lifecycle.
In-flight sharing, step by step
The simplest deduplication fits in ten lines: a Map from key to promise. Before sending a request, look up its key. If a promise is there, the same request is already on its way, so return that same promise. If not, start the request, store its promise under the key, and delete the entry when it settles.
const inFlight = new Map<string, Promise<unknown>>();
export function dedupe<T>(key: string, load: () => Promise<T>): Promise<T> {
const pending = inFlight.get(key);
if (pending) return pending as Promise<T>; // already on its way: share it
const promise = load().finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
// TopBar, Sidebar, Greeting and AdminLink all call this on mount.
const me = await dedupe("me", () => fetch("/api/me").then((r) => r.json()));
A worked example with timings
Follow the four components from chapter 1 through this function. The server takes 120 ms to answer /api/me.
- t = 0 ms.
TopBarmounts and callsdedupe("me", ...). The map is empty, soload()runs,fetchstarts, and the promise is stored under"me". - t = 0.2 ms.
Sidebar,GreetingandAdminLinkmount in the same commit. Each call finds"me"in the map and gets back the exact same promise object. No new request. - t = 120 ms. The response arrives. The promise resolves, all four
awaits continue with the same user object, andfinallyremoves"me"from the map. - t = 4,000 ms. The user opens a menu that mounts a fifth component asking for
"me". The map is empty again, so a new request goes out.
finally then removes the entry.Three details that matter
Store the promise before the first await. The set has to happen in the same synchronous step as the get. If you write const data = await load(); map.set(key, data), every caller in the burst sees an empty map, because none of them has finished yet. That version dedupes nothing during the burst, which is exactly when you need it.
Clean up on failure too. finally runs on both success and error. If you only delete the entry in then, a failed request stays in the map forever and every later caller receives the same old rejection. The page is stuck on an error until reload.
Everyone gets the same object. All four components receive one shared object, not copies. That's what keeps the screen consistent, but it also means one component that mutates it (user.name = user.name.trim()) changes it for the others. Treat shared results as read-only; some stores, such as Redux Toolkit's, freeze them to catch this.
Cancelling a shared request
Cancellation gets harder once requests are shared. Say three components share one request and the user closes the panel holding one of them. If that component calls controller.abort() on the shared fetch, the other two lose their data too.
The fix is reference counting. The shared entry owns its own AbortController and a count of callers. Each caller passes its own signal; when a caller's signal aborts, the count goes down. Only when it reaches zero does the entry leave the map and the shared controller abort the real request, so a caller that arrives later starts a fresh one.
type Entry = { promise: Promise<unknown>; controller: AbortController; users: number };
const shared = new Map<string, Entry>();
function dedupeAbortable<T>(key: string, load: (s: AbortSignal) => Promise<T>, signal?: AbortSignal) {
if (signal?.aborted) return Promise.reject(signal.reason);
let entry = shared.get(key);
if (!entry) {
const controller = new AbortController();
const promise = load(controller.signal).finally(() => {
if (shared.get(key)?.controller === controller) shared.delete(key);
});
entry = { promise, controller, users: 0 };
shared.set(key, entry);
}
const e = entry;
e.users++;
signal?.addEventListener("abort", () => {
if (--e.users > 0) return;
if (shared.get(key) === e) shared.delete(key); // later callers start fresh
e.controller.abort(); // last one out turns off the light
}, { once: true });
return e.promise as Promise<T>;
}
React Query does this for you: when the last component using a query unmounts while the fetch is in flight, it can cancel the request if your query function passes on the signal it receives. If you write the dedup layer yourself, test this path, because it is the one that breaks quietly.
Only reads, never writes
Deduplicate only requests that are safe to merge: reads with no side effects, which in HTTP terms means GET and HEAD, and GraphQL queries but not mutations. Two POST /comments calls with the same body are two comments the user meant to send, or one double-click that needs a different fix (disable the button, or send an idempotency key, a unique ID per action that lets the server ignore repeats). Merging them silently in the data layer hides a real bug.
React's Strict Mode in development runs every effect, cleans it up, and runs it again, as if each component mounted twice. With the plain dedup map above, the second mount joins the first request, and the Network panel shows one call. Without a dedup layer it shows two, a useful warning that production will send duplicates on remounts too. A layer that aborts on unmount, like the reference-counted one, may cancel the first request and send a second.
12 rows mounted in one render, asking for 4 different users. No sharing: 12 requests went out.
Check your understanding · Exercise 1
A teammate writes async function dedupe(key, load) { if (cache.has(key)) return cache.get(key); const data = await load(); cache.set(key, data); return data; }. Five components call it in the same render. What happens? Select all that apply.
Keys: deciding what counts as the same request
Deduplication is only as good as its key. A key that is too loose merges requests that should be separate, and users see the wrong data. A key that is too strict merges nothing, and the dedup layer silently does no work. Most dedup bugs in real apps are key bugs.
The rule is short: the key must contain every input that changes the answer, and nothing else. The resource type, its ID, the filters, the page, the sort order, the language, and the user or tenant if the same code can run for different ones.
Query keys as arrays
React Query calls them query keys and asks for an array, like ["products", "list", { category: "shoes", page: 2 }]. SWR accepts a string URL or an array. Apollo Client builds the key from the query document and its variables. The shape differs; the idea is the same.
Order the parts from general to specific. Then one prefix can act on a whole family: invalidating ["products"] refreshes every product list and every product page, while ["products", "detail", "p_81"] touches one. Small functions that build keys keep every component spelling them the same way.
export const keys = {
me: () => ["me"] as const,
user: (id: string) => ["users", id] as const,
productList: (f: { category: string; sort: "price" | "new"; page: number }) =>
["products", "list", f] as const,
product: (id: string) => ["products", "detail", id] as const,
};
useQuery({ queryKey: keys.productList({ category: "shoes", sort: "price", page: 2 }), queryFn });
Normalising inputs so equal requests get equal keys
Two components can ask for the same data and still build different keys. One passes { page: 2, sort: "price" }, the other { sort: "price", page: 2 }. One writes /api/search?q=Shoes, the other q=shoes. Unless the key is built in a normal form, these never meet in the map.
- Object key order. Sort object keys before turning the key into a string. React Query and SWR both hash keys with sorted object keys, so property order doesn't matter. Array order still does:
[1, 2]and[2, 1]are different keys. - Missing versus default.
{ page: 1 }and{}may mean the same thing to the server. Fill in defaults (or drop default values) before building the key, in one place. - Undefined values.
{ q: undefined }and{}should be equal.JSON.stringifydropsundefinedobject properties, which helps. - Types.
7and"7"hash differently. Pick one type for IDs, usually string, and convert at the edge where the value comes from the URL. - Case and spaces. Lowercase and trim search terms only if the server treats them the same. Otherwise you would merge two requests that really differ.
- URL details. Query parameter order, a trailing slash, and encoded characters (
%20versus+) can make equal URLs look different. Build URLs from a key, not keys from hand-written URLs.
export function stableKey(value: unknown): string {
return JSON.stringify(value, (_k, v) =>
v && typeof v === "object" && !Array.isArray(v)
? Object.fromEntries(
Object.entries(v)
.filter(([, x]) => x !== undefined)
.sort(([a], [b]) => (a < b ? -1 : 1)),
)
: v,
);
}
stableKey(["products", { page: 2, sort: "price" }]) ===
stableKey(["products", { sort: "price", page: 2, q: undefined }]); // true
When a key is too loose
The dangerous mistake is leaving out an input the request uses. A classic: the key is ["orders"] but the fetch reads the current team from a global store. Switch teams and the key stays the same, so the old team's orders keep showing. Worse, a component for team B can join an in-flight request that was started for team A.
The same bug across users is a security problem. On a server, a module-level map keyed by "me" is shared by every request the process handles, so user B can get user A's profile. On the server, scope dedup to one incoming request (chapter 7 shows how), or put the user ID in the key.
When a key is too strict
The opposite mistake wastes requests without breaking anything, so it lives on unnoticed. Common causes: a timestamp or random request ID inside the key, a whole options object that includes a callback, or a new Date built on every render. Each render makes a brand-new key, nothing is ever shared, and some libraries will even refetch in a loop because the key keeps changing.
Check your understanding · Exercise 2
A product list fetch reads category, page, the user's currency from a settings store, and attaches a fresh X-Request-Id header. Which belong in the query key? Select all that apply.
Batching: many keys, one trip
Deduplication can't help the task list from chapter 1 all the way. After dedup, its 50 rows still need 12 different users, and 12 requests is still 12 round trips, 12 auth checks, and 12 database queries. Batching collects those 12 keys for a moment and sends one request for all of them.
The pattern is known by the name of the library that made it popular: DataLoader, written at Facebook for GraphQL servers, where one query can ask for the author of each of 100 posts. The GraphQL lesson covers that server side. The same pattern works just as well in the browser.
How a batcher works
- Each caller asks for one item with
load("7")and gets a promise back at once. Nothing is sent yet. - The first
loadin a fresh batch schedules a flush for later: at the end of the current task withqueueMicrotask, or after a few milliseconds withsetTimeout. - Every
loadbefore the flush adds its key to a queue. A key that is already in the queue just adds another waiting caller, so the batcher dedupes for free. - The flush takes the queue, starts a fresh empty one for later calls, and sends one request with all the keys:
GET /api/users?ids=3,7,9,12. - When the answer arrives, the batcher resolves each caller's promise with its own item, and resolves missing items with
undefined(or rejects them) so no caller waits forever.
type Waiter<V> = { resolve: (v: V | undefined) => void; reject: (e: unknown) => void };
export function createBatcher<V>(loadMany: (ids: string[]) => Promise<Map<string, V>>, max = 100) {
let queue = new Map<string, Waiter<V>[]>();
function flush() {
const batch = queue;
queue = new Map(); // calls from now on go into the next batch
const ids = [...batch.keys()];
for (let i = 0; i < ids.length; i += max) {
const chunk = ids.slice(i, i + max);
loadMany(chunk).then(
(found) => chunk.forEach((id) => batch.get(id)!.forEach((w) => w.resolve(found.get(id)))),
(error) => chunk.forEach((id) => batch.get(id)!.forEach((w) => w.reject(error))),
);
}
}
return (id: string) => {
if (queue.size === 0) queueMicrotask(flush);
return new Promise<V | undefined>((resolve, reject) => {
queue.set(id, [...(queue.get(id) ?? []), { resolve, reject }]);
});
};
}
max.Calling it from the rows is unchanged from a single fetch: const user = await loadUser(row.assigneeId). In a quick test with six calls (3, 7, 3, 9, 12, 404) and max = 3, the batcher sent exactly two requests, [3, 7, 9] and [12, 404]. Both callers of 3 got the same user, and the caller of the missing 404 got undefined instead of hanging.
Choosing the window
The window is how long the batcher waits for more keys. It is a straight trade: a longer window catches more keys per request but delays every caller by that much.
| Window | Catches | Added delay | Good for |
|---|---|---|---|
Microtask (queueMicrotask) | Calls made in the same synchronous run, like one React commit of effects | Almost none | Lists that render all rows at once |
A few ms (setTimeout(fn, 10)) | Calls spread over a few frames, like rows mounting as they scroll in | Up to the window | Virtualised lists, lazy widgets |
| Animation frame | Everything requested before the next paint | Up to about 16 ms at 60 Hz | Visual updates that paint together anyway |
DataLoader on Node.js waits until the current promise jobs have run, which is about one tick. That is enough on a server, because one GraphQL resolver level asks for all its keys in the same tick. In a browser, React may run effects for one list in a single pass or spread them over several, so check the batch sizes you actually get.
Rules a batch endpoint must follow
- Answer per key. Return items keyed by ID (an object or map), or an array in exactly the order of the requested IDs. DataLoader requires the array form and the same length as the keys. A response that drops a missing item and shifts the rest gives callers each other's data.
- Partial success. One unknown or forbidden ID must not fail the whole batch. Return
nullor an error marker for that key, and let the batcher reject only that caller. - A size limit. Cap the batch (
maxBatchSizein DataLoader) and split bigger ones. LongGETquery strings run into URL limits: many servers and proxies reject request lines over about 8 KB. Above that, usePOSTwith the IDs in the body. - Cacheability.
GET /users?ids=3,7,9andGET /users?ids=3,9,7are different URLs to every HTTP cache. Sort IDs before building the URL so equal batches hit the same cache entry. Batch URLs still cache poorly because the ID mix differs from page to page.
Five rows will each call load(id). The queue is empty and nothing has been sent.
Decision The task list needs the assignee for each of 50 rows. How should it get them?
What we pick: If you own the API, embed the few fields each row needs in the list response: it is the cheapest at runtime. If you don't, or the rows load lazily as you scroll, use a client batcher. Plain per-row requests with dedup are usually fine for a handful of distinct keys on HTTP/2.
Check your understanding · Exercise 3
A batcher asks for ids=3,7,9. User 7 was deleted, and the server returns the array [user3, user9]. The batcher assigns results by position. What do the callers get? Select all that apply.
How React Query, SWR and friends dedupe
You rarely ship your own dedup map in a React app. The server-state libraries all have one inside, plus a cache around it. What differs is how long they share an answer and when they decide to ask again. Knowing those defaults explains most "why did it fetch again?" questions.
Both big libraries follow stale-while-revalidate: show the cached answer at once (even if it might be old), fetch a fresh one in the background, and swap it in when it arrives. The name comes from the HTTP Cache-Control extension of the same name in RFC 5861. SWR the library is named after it.
staleTime decides how long the fresh state lasts; in TanStack Query it is 0 by default, so data goes stale at once.SWR: a time window after each request
SWR dedupes by key with a time window. Its dedupingInterval defaults to 2000 ms: any useSWR call for a key that started a request in the last 2 seconds reuses that request instead of starting another, even if the first one has already finished. Mount ten components with useSWR("/api/me") within 2 seconds and the server sees one request.
After the window, SWR revalidates on its triggers: when a component using the key mounts (revalidateIfStale is true by default), when the window regains focus (revalidateOnFocus, throttled by focusThrottleInterval, 5000 ms by default), and when the network comes back (revalidateOnReconnect). Each trigger goes through the same dedup, so a focus event with 30 components on screen still sends one request per key.
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
function Avatar() {
const { data } = useSWR("/api/me", fetcher, { dedupingInterval: 10_000 });
return data ? <img src={data.avatarUrl} alt="" /> : null;
}
// TopBar, Greeting and AdminLink use the same key and options: at most one request per 10 s.
TanStack Query: in-flight sharing plus staleTime
TanStack Query (React Query) keeps one query object per key in a QueryClient. Every component that calls useQuery with that key subscribes to the same query. If a fetch is already running, a new subscriber simply waits for it: that is the in-flight dedup, and it is always on.
What happens after the fetch settles depends on staleTime, which defaults to 0. Data is stale the moment it arrives, so a component that mounts later shows the cached data at once and also starts a background refetch (refetchOnMount is true). Mount the same widget five times over a minute and you get five requests, each deduped with any already in flight, but five all the same.
Raise staleTime to stretch the sharing window. With staleTime: 30_000, mounts within 30 seconds of the last fetch read the cache with no request at all. Unused data stays in memory for gcTime, 5 minutes by default, before it is garbage collected. A common setup is a global staleTime of 20 to 60 seconds, lowered per query for fast-changing data.
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } }, // share answers for 30 s
});
function useMe() {
return useQuery({
queryKey: ["me"],
queryFn: ({ signal }) => fetch("/api/me", { signal }).then((r) => r.json()),
});
}
signal lets the library abort the request when every subscriber has gone.Apollo, Relay and RTK Query
- Apollo Client dedupes identical in-flight queries (
queryDeduplicationistrueby default) and then serves later reads from its normalized cache, which stores each object once by type and ID. Two different queries that both include user 7 share one copy of user 7. - Relay goes further and makes each screen's data one query, built at compile time from fragments that each component declares. Components don't fetch on their own, so duplicates are rare by design.
- RTK Query (Redux Toolkit) dedupes by endpoint and arguments, and keeps data with no subscribers for
keepUnusedDataFor, 60 seconds by default.
staleTime (default 0), and RTK Query and Apollo serve cached data until it is dropped or refetched.Decision Which tool should a new React app use for deduplicating reads?
What we pick: For REST or RPC APIs, use TanStack Query and set staleTime on purpose; pick SWR if the app mostly reads and you want less to learn. With GraphQL, use the client you already have. Write your own only for one or two global values, and still store promises, not values.
Beyond the browser: CDNs and server rendering
A browser can only dedupe its own requests. When a million browsers ask for the same thing in the same second, the sharing has to happen further along the path: at the CDN in front of your origin, and inside the server that renders the page.
Request collapsing at the CDN
Say a news site publishes a breaking story and sends a push notification. In the next second, 20,000 readers request /story/8812, and every edge location of the CDN has a cache miss at the same moment. If each miss goes to the origin, the origin gets thousands of identical requests and may fall over. This is a cache stampede, also called the thundering herd.
Request collapsing (or request coalescing) is in-flight dedup at the CDN. The first miss for a URL goes to the origin; later requests for the same cache key wait for that response and are all served from it. Fastly and Varnish do this by default for cacheable responses; in nginx it is proxy_cache_lock on, which is off by default.
Large CDNs also add a shield or tiered cache, one inner layer that all edges ask, so the origin sees roughly one request per URL instead of one per edge location.
Collapsing only works for responses the CDN may share. A response with Cache-Control: private or no-store, or one that sets a cookie, can't be given to other users, so the waiting requests must go to the origin themselves. Varnish then remembers for a while that the URL is uncacheable (a hit-for-miss or hit-for-pass object), so later requests stop queueing behind one another.
The frontend's part is to make shared data cacheable, even for a second or two: Cache-Control: public, max-age=1, stale-while-revalidate=30 turns a stampede into about one origin request per second for each cache.
React cache() in Server Components
Server rendering brings back the problem from chapter 1, now on the server. A page built from React Server Components has a layout, a header, a sidebar and a main panel, each an async component that awaits its own data. Four of them call getUser(id). Without help, one page render runs four database queries for the same row.
React's cache(fn), which works only in Server Components, wraps a function so that calls with the same arguments during one server request share one result. Each argument is compared with Object.is, so objects match only if they are the same object: pass primitives like an ID string, not a fresh object each time. The memo is thrown away when the request ends, so user A's render never sees user B's data.
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
// layout.tsx, Header, Sidebar, Main: each awaits getUser(session.userId)
// one query per server request, however many components ask
cache() inside a component creates a new, unshared memo each render.Next.js also memoizes fetch calls during a render: two GET requests with the same URL and options in one render tree run once. This applies to GET only, and only inside the React tree, not in route handlers. It is a separate thing from the Next.js data cache, which keeps results across requests and has its own rules.
DataLoader on the API server, per request
Your API server has the same problem one level down. A GraphQL query for 100 posts with their authors makes 100 author lookups, and DataLoader batches them into one WHERE id IN (...) query (the GraphQL lesson covers this). The rule that matters here: create a new loader for every incoming request. A loader shared across requests caches user A's results for user B and grows without limit.
Check your understanding · Exercise 4
A product page API returns Cache-Control: private, max-age=60 because it includes the signed-in user's cart count. During a sale, the origin is flooded with identical requests for popular products. Which changes help? Select all that apply.
Failure modes and edge cases
Sharing a request means sharing everything about it: its data, its errors, its timing, and its staleness. Each of these goes wrong in a recognisable way. Here is what breaks in production, how you notice it, and the fix.
A request that started before a write
The user renames a project. Just before the save, a background refetch of ["projects", 42] started. The save finishes, the code invalidates the key, and a new caller asks for the project. If the dedup layer hands the new caller the old in-flight request, it gets the name from before the save and shows it as fresh.
Notice it as a value that flips back for a moment after saving. Fix it by making invalidation drop or cancel in-flight requests for that key, so the next caller starts a new one. TanStack Query's invalidateQueries does this by default (it cancels the running fetch and starts a new one), and cancelQueries before an optimistic update stops an old answer overwriting it. The request races and optimistic updates lessons cover the ordering rules.
Shared errors and retry storms
When a shared request fails, every caller gets the same rejection at once. If each caller has its own retry loop, one failure becomes N retries, and 20 components on a dashboard turn a short 503 into a burst of 20 requests every few seconds. Retry inside the shared layer, once per key, with backoff and jitter (see the retries and backoff lesson), and let callers only display the result.
One slow key delays the whole batch
A batch resolves when its slowest item does. If 49 users come from a fast table and one comes from a slow archive, all 50 rows wait. Watch for batch endpoints whose p95 latency (the time 95 percent of requests beat) is far worse than single lookups. Fixes: cap batch size, split known-slow keys into their own request, or let the server stream results as they are ready.
Batching the critical request
A batcher with a 20 ms window adds 20 ms to everything that goes through it, including the request for the main content. Never batch the request that the page's Largest Contentful Paint depends on. Batch the many small, secondary reads, such as avatars, hover cards and badges.
Leaks and stuck entries
- A promise that never settles (a
fetchwith no timeout on a stuck connection) holds its map entry forever, and every later caller waits on it. Give shared requests a timeout withAbortSignal.timeout(10_000). - Caches with no eviction grow with every key ever seen. Pure in-flight maps are safe because entries leave on settle (in
finally, so failures leave too); caches need a time or size limit (gcTimein TanStack Query).
Things that must not be merged
- Writes. As chapter 3 showed,
POST,PUT,PATCHandDELETEare never deduped. - Different users. On a server, anything that outlives one request needs the user in its key, or must not be shared.
- Responses that differ by header. If the server varies the answer by
Accept-Languageor an auth header, the key must include the language or user too. - Streams and uploads. A response body can only be read once. Share the parsed result, not the
Responseobject, or useresponse.clone()for each reader.
| Symptom | Likely cause | Fix |
|---|---|---|
| Same URL several times at page load | Components fetch directly, outside the data layer | Route every read through one hook or client |
| Wrong data after switching team or language | Key missing an input the fetch reads | Add it to the key; turn on the lint rule |
| Requests on every render | Key contains a new object, date or random ID each time | Build keys from stable primitives |
| Old value right after saving | New caller joined a request from before the write | Cancel in-flight requests on invalidation |
| Error bursts during an outage | Each caller retries the shared failure | Retry once in the shared layer, with backoff |
| Another user's data on the server | Module-level memo shared across requests | Scope memos to one request |
Check your understanding · Exercise 5
A dedup layer shares the raw Response from fetch, and each caller calls await res.json(). What goes wrong? Pick one.
In real products and in the interview
Deduplication and batching sit under most fast, data-heavy interfaces. Here is where they show up, and how to use them in a design interview.
Where you will see it
- Facebook and GraphQL. DataLoader came out of Facebook's GraphQL server work. Relay, also from Facebook, has each component declare a fragment, so one query per screen replaces hundreds of component fetches.
- Chat and project apps show the same people's names and avatars hundreds of times. The common design keeps users in a client store keyed by ID and fetches unknown IDs in batches.
- News and commerce sites survive traffic spikes because the CDN collapses identical misses and the HTML or API response is shareable for at least a second or two.
When to bring it up
Bring it up whenever many independent components show overlapping data: a feed with an author per post, a table with a user per row, a header and sidebar that both show the account. It belongs in the data layer part of the architecture, right after the component tree.
- Name the burst. "Each card fetches its author, so 50 cards could mean 50 requests for about 10 people."
- Draw the data layer. A box between components and the network, holding a cache keyed by query key with in-flight sharing.
- Give the key.
["user", id]or["feed", { tab, cursor }], and say it includes every input that changes the answer. - Batch or embed. Say whether you would add a batch endpoint, embed the fields in the list response, or let GraphQL fetch them in one query.
- Mention the edges. Writes are never deduped; invalidation cancels in-flight reads; server-side memos are per request.
| Level | What a strong answer includes |
|---|---|
| Junior | Notices duplicate requests and suggests a shared hook or React Query so components share one request per key. |
| Mid | Explains in-flight sharing with a promise map, designs query keys with all inputs, picks staleTime or dedupingInterval on purpose, and proposes batching or embedding for per-row data. |
| Senior | Covers cancellation with shared requests, retry once per key, stale in-flight requests after writes, batch endpoint contract and size limits, CDN collapsing and cacheability, and per-request scoping on the server. |
Think first
Your design for a social feed renders 30 posts, each with an author card and a like button that shows whether the viewer liked it. How do you fetch the author and like state without 60 extra requests?
Best: return what each card needs in the feed response itself. Each post embeds author: { id, name, avatarUrl } and viewerHasLiked, so the feed renders from one request. Authors repeat across posts, which costs a few KB, and is worth it.
If the feed API can't change, put author lookups through a batcher keyed by user ID: one GET /users?ids=... per page of posts, with the IDs sorted and deduped. Likes go through a second batcher keyed by post ID.
Store authors in a normalized cache by ID so the author card, hover card and profile link share one copy, and so a name change updates every card.
Check your understanding · Exercise 6
A TanStack Query app with default options mounts a UserBadge (key ["user", 7]) in a modal. The user opens and closes the modal 5 times in a minute. Each fetch takes 100 ms. How many requests go out, and what fixes it? Select all that apply.
Summary
- Colocated data fetching creates bursts of identical and near-identical requests; deduplication and batching make colocation cheap.
- Dedup shares one promise per key among overlapping callers. Store the promise before awaiting, and remove it in
finally. - The key must hold every input that changes the answer and nothing that doesn't. Normalise object order, defaults and types.
- Batching collects different keys for one tick or a few ms and sends one request; the batch endpoint must answer per key and allow partial failure.
- SWR dedupes for
dedupingInterval(2 s by default). TanStack Query shares in-flight fetches always and shares settled data only forstaleTime(0 by default). - Never merge writes, and scope every server-side memo to one request: React
cache()and per-request DataLoaders do this. - Across users, CDN request collapsing is the same idea, and it only works for responses that may be shared.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Four components on a page all call /api/me when they mount. How would you make sure only one request goes out?
Route all reads through one data layer that keys requests, for example ["me"]. On a call, it looks up the key in a map of in-flight promises; if one exists it returns that promise, otherwise it starts the request and stores the promise before awaiting anything.
The entry is removed in finally, so failures don't stick. All four components get the same object, which also keeps the screen consistent.
In practice I'd use TanStack Query or SWR, which do this per key, and set staleTime or dedupingInterval so components that mount a little later also reuse the answer.
Question 2
What is the difference between deduplication, batching and caching?
Deduplication merges calls for the same key that overlap in time into one request. Nothing is kept afterwards.
Batching merges calls for different keys of the same kind, made within a short window, into one request like GET /users?ids=3,7,9, then splits the answer per caller.
Caching keeps a settled answer for later calls, which needs a freshness rule and invalidation. Libraries combine all three, but they solve different waste.
Question 3
How would you implement a DataLoader-style batcher on the client, and what can go wrong?
load(id) returns a promise and adds the ID to a queue; the first call schedules a flush with queueMicrotask. The flush swaps in a new queue, sends the distinct IDs in chunks of a maximum size, and resolves each caller with its own item.
What goes wrong: a server response in a different order or missing items gives callers the wrong data, so key the response by ID. One slow ID delays the batch. Very long ID lists hit URL limits, so chunk or use POST. Batch URLs cache poorly, so sort IDs.
Don't batch the request the main content depends on, since the window adds delay.
Question 4
Two components share one in-flight request, and one of them unmounts. Should you abort the request?
No, not while the other still needs it. Give the shared request its own AbortController and count its callers. Each caller's abort lowers the count, and only the last one aborts the real request.
TanStack Query does this when the query function passes its signal to fetch: the request is cancelled once no component is using the query.
Question 5
How do you avoid a stampede on your origin when a popular page's cache entry expires?
Make the response shareable and let the CDN collapse concurrent misses: the first request for the URL goes to the origin and the rest wait for it. Fastly and Varnish do this by default; nginx needs proxy_cache_lock.
Add stale-while-revalidate so the CDN keeps serving the old copy while one background request refreshes it, and use a shield layer so edges ask one inner cache rather than the origin.
Split per-user parts out of the response, because private responses can't be collapsed.
Practice it
These design problems lean on deduplication and batching.
References
- DataLoader (GitHub) The original batching and per-request caching utility, with its batch function contract.
- TanStack Query: Important Defaults
staleTime,gcTimeand when queries refetch. - TanStack Query: Query Keys How keys are hashed and why every input belongs in them.
- SWR API options
dedupingInterval, revalidation triggers and their defaults. - React: cache Per-request memoization in Server Components.
- RFC 5861: HTTP Cache-Control Extensions for Stale Content Where
stale-while-revalidatecomes from. - Next.js: Caching Request memoization of
fetchduring a render, and how it differs from the data cache.
Read next
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- GraphQLA typed query language for APIs: the client asks one endpoint for exactly the fields a screen needs, across many related objects, in one round trip, and pays for it with harder caching and more work on the server.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.