cf.completefrontend
My progress
FUNDAMENTALS · MODULE 4

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.

Two panels. Left, without a data layer: four components, TopBar, Sidebar, Greeting and AdminLink, each send their own GET /api/me to the API, four requests for one user. A task list with 50 rows and 12 assignees sends 50 requests. Right, with deduplication: the same four components go through one dedup layer, which sends a single request and gives the answer to all four. The task list sends 12 requests after dedup, or 1 with batching.without a data layerwith deduplicationTopBarTopBarSidebarSidebarGreetingGreetingAdminLinkAdminLinkAPIdedupeAPI4 requests for 1 user1 request, 4 answers50 rows, 12 users: 50 requests12 with dedup, 1 batched
Colocated components cannot see each other, so each one asks. A shared layer between them and the network turns four identical calls into one, and 50 per-row calls into 12, or into one batched 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

  1. 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.
  2. 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.
  3. 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.

A stack of six layers from the component down to the database, grouped in three zones. In the browser: the query cache, which shares the same key while in flight; the batcher, which merges many keys in one tick; the HTTP cache, which reuses the same URL for its max-age. On the network: the CDN edge, which collapses the same URL across users. On the server: React cache, which shares the same call within one request, and DataLoader, which merges many IDs into one database query.BrowserNetworkServerquery cachesame key, while in flightbatchermany keys, one tickHTTP cachesame URL, for max-ageCDN edgesame URL, across usersReact cache()same call, one requestDataLoadermany IDs, one query
Each layer catches duplicates the one above it missed. The frontend owns the top two directly, steers the HTTP cache and CDN with Cache-Control, and shapes the server layers when it renders on the server.
The frontend controls the top two rows directly and influences the rest with headers and server code.
LayerWhat it sharesWindowExample
Data layer in the pageIdentical reads by keyWhile in flight, plus a short time afterReact Query, SWR, Apollo Client, your own map
Batcher in the pageDifferent keys of the same kindOne tick or a few msA DataLoader-style load(id)
Browser HTTP cacheRepeat GETs of a cacheable URLThe response's max-ageCache-Control: max-age=60
CDN or reverse proxyConcurrent misses from many usersWhile the origin fetch is in flightRequest collapsing at Fastly, Varnish, nginx proxy_cache_lock
Server renderIdentical calls during one server requestOne requestReact cache(), Next.js fetch memoization
API serverDatabase lookups from one requestOne tickDataLoader 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.

ts
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 tiny in-flight deduplicator. Four callers in the same render send one request.

A worked example with timings

Follow the four components from chapter 1 through this function. The server takes 120 ms to answer /api/me.

  1. t = 0 ms. TopBar mounts and calls dedupe("me", ...). The map is empty, so load() runs, fetch starts, and the promise is stored under "me".
  2. t = 0.2 ms. Sidebar, Greeting and AdminLink mount in the same commit. Each call finds "me" in the map and gets back the exact same promise object. No new request.
  3. t = 120 ms. The response arrives. The promise resolves, all four awaits continue with the same user object, and finally removes "me" from the map.
  4. 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.
A sequence diagram with four columns: TopBar, Sidebar, the dedupe map, and the API. TopBar asks the map for me; the map has no entry, so it sends GET /api/me to the API. Sidebar asks the map for me a moment later; the map returns the same promise. The API answers 200 at 120 milliseconds. The map resolves the user to TopBar and to Sidebar, and deletes its entry.TopBarSidebardedupeAPIget meGET /api/meget mesame promise200 at 120 msusersame user
Only one arrow reaches the API. The second caller gets the promise that already exists, and when it resolves both continue with the same object. 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.

ts
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>;
}
The real request is aborted only when every caller has left. A caller that left simply ignores the result.

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.

Rows:
Mount:
Data layer:
12 rows mounted in one render, asking for 4 different users. No sharing: 12 requests went out. Requests sent: GET /users/1; GET /users/2; GET /users/3; GET /users/4; GET /users/1; GET /users/2; GET /users/3; GET /users/4; GET /users/1; GET /users/2; GET /users/3; GET /users/4.12 rows123412341234orange: started a requestgreen: reused one12 requests sentGET /users/1GET /users/2GET /users/3GET /users/4GET /users/1GET /users/2GET /users/3and 5 more
Left: one cell per row, showing its user. An orange cell started a request; a green cell reused one. Right: the requests that actually went out. Each fetch takes 150 ms.

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.

ts
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 });
A key factory: components never write key arrays by hand.

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.stringify drops undefined object properties, which helps.
  • Types. 7 and "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 (%20 versus +) can make equal URLs look different. Build URLs from a key, not keys from hand-written URLs.
ts
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
Sorting object keys and dropping undefined values turns equal requests into equal strings.
Four calls with raw key objects: sort price and page 2; page 2 and sort price; page 2, sort price and q undefined; and page 3 and sort price. All go through stableKey, which sorts the property names and drops undefined values. The map ends with two entries: page 2 and sort price, shared by the first three calls, and page 3 and sort price for the fourth.4 calls, raw keysmap entriessort:price page:2page:2 sort:pricepage:2 sort:price q:undefinedpage:3 sort:pricestableKeypage:2 sort:pricepage:3 sort:price3 callers, 1 request1 caller, 1 request4 calls, 2 requests
The first three calls mean the same request and now share one entry. The fourth really differs, so it stays separate. Normalising never merges requests the server would answer differently.

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

  1. Each caller asks for one item with load("7") and gets a promise back at once. Nothing is sent yet.
  2. The first load in a fresh batch schedules a flush for later: at the end of the current task with queueMicrotask, or after a few milliseconds with setTimeout.
  3. Every load before 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.
  4. 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.
  5. 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.
ts
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 }]);
    });
  };
}
About 20 lines: one flush per tick, duplicate IDs merged, big batches split into chunks of 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.

A timeline from 0 to 80 milliseconds. Four load calls arrive at 0, 4, 8 and 24 milliseconds, for users 3, 7, 9 and 5, each in its own task. A microtask batcher flushes after each one: four requests of 50 milliseconds, the last done at 74 milliseconds. A batcher with a 10 millisecond window sends ids 3, 7 and 9 together at 10 milliseconds and id 5 at 34 milliseconds: two requests, the last done at 84 milliseconds.load(id) calls3795microtask flush4 requests10 ms window2 requestsids=3,7,9ids=50 ms20 ms40 ms60 ms80 ms
A longer window catches calls that arrive a few milliseconds apart, at the price of starting every request later. When all calls happen in one render, a microtask catches them all with no delay.

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.

Start with a microtask. Widen the window only if the Network panel shows many small batches.
WindowCatchesAdded delayGood for
Microtask (queueMicrotask)Calls made in the same synchronous run, like one React commit of effectsAlmost noneLists that render all rows at once
A few ms (setTimeout(fn, 10))Calls spread over a few frames, like rows mounting as they scroll inUp to the windowVirtualised lists, lazy widgets
Animation frameEverything requested before the next paintUp to about 16 ms at 60 HzVisual 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 null or an error marker for that key, and let the batcher reject only that caller.
  • A size limit. Cap the batch (maxBatchSize in DataLoader) and split bigger ones. Long GET query strings run into URL limits: many servers and proxies reject request lines over about 8 KB. Above that, use POST with the IDs in the body.
  • Cacheability. GET /users?ids=3,7,9 and GET /users?ids=3,9,7 are 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.
Step 1 of 10
Step 1. Five rows will each call load(id). The queue is empty and nothing has been sent. Queue: empty. Requests sent: none.queue (key: callers)emptysentnothing yetcallersABCDE
Top left: the queue, one row per key with the callers waiting on it. Top right: requests sent. Bottom: each caller, hatched while queued, orange once its request is sent, green when resolved.

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?

One request per row, deduped

Each row calls getUser(id) through the dedup map.

50 calls become 12 requests, one per distinct user.

Pros

  • No new endpoint.
  • Each user response caches well on its own URL.
  • Simple to reason about.

Cons

  • 12 round trips and 12 server queries.
  • Over HTTP/1.1, 12 requests queue on 6 connections.

Client-side batcher

Rows call loadUser(id); one flush per tick sends GET /users?ids=... with the sorted distinct IDs.

Pros

  • One round trip, one database query with WHERE id IN (...).
  • Components stay colocated.

Cons

  • Needs a batch endpoint.
  • Batch responses cache poorly.
  • One slow ID delays all of them.

Include the data in the list response

The list endpoint (or a BFF, backend for frontend) returns tasks with an embedded assignee: { id, name, avatarUrl }, or GraphQL asks for it in the same query.

Pros

  • Zero extra requests.
  • The page renders complete in one round trip.

Cons

  • Bigger list response, user data repeated per row.
  • List and user caches can disagree after an update.

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.

A state machine for one query key. From empty, the first use moves it to fetching, where new callers join the request already in flight. The response moves it to fresh, where reads need no request. When staleTime ends it becomes stale, where the old data is shown at once. A mount, a window focus or an invalidation moves it back to fetching, with the stale data still on screen. A note says SWR also joins any fetch started in the last 2 seconds.emptyfetchingnew callers joinfreshread, no requeststaleshown at oncefirst use200staleTime endsmount, focus,invalidateSWR also joins anyfetch from the last 2 s
Dedup lives in the fetching state: every caller that arrives there shares one request. 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.

tsx
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.

tsx
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()),
  });
}
Passing on signal lets the library abort the request when every subscriber has gone.

Apollo, Relay and RTK Query

  • Apollo Client dedupes identical in-flight queries (queryDeduplication is true by 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.
A matrix of five tools against three questions: does it share a request in flight, does it share after the request settles, and which option controls that. Own map: yes, no, none. SWR: yes, for 2 seconds, dedupingInterval. TanStack Query: yes, for staleTime, staleTime, which defaults to 0. RTK Query: yes, while cached, keepUnusedDataFor, 60 seconds by default. Apollo Client: yes, cache-first, fetchPolicy.in flightafter it settlesthe knobown mapyesnononeSWRyesfor 2 sdedupingIntervalTanStack Queryyesfor staleTimestaleTimeRTK Queryyeswhile cachedkeepUnusedDataForApollo Clientyescache-firstfetchPolicy
Every tool shares a request in flight. They differ in what happens after: SWR uses a time window, TanStack Query uses 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?

Your own dedup map

A shared dedupe(key, load) like chapter 3, called from hooks.

Pros

  • No dependency, about 30 lines.
  • Fine for a few global resources like the current user or feature flags.

Cons

  • You also have to build caching, refetching, cancellation, errors and devtools yourself.
  • Easy to get the edge cases wrong.

SWR

useSWR(key, fetcher); tune dedupingInterval and the revalidation triggers.

Pros

  • Small and simple.
  • Time-window dedup catches remounts, not only overlaps.

Cons

  • Fewer tools for mutations, dependent queries and fine-grained invalidation than TanStack Query.

TanStack Query

useQuery({ queryKey, queryFn }) with a QueryClient; set a sensible staleTime.

Pros

  • Full lifecycle: invalidation by key prefix, cancellation, retries, devtools, SSR hydration.
  • Works with any framework and any API.

Cons

  • More concepts to learn.
  • The default staleTime: 0 surprises people with background refetches.

GraphQL client (Apollo, Relay)

Queries go through the client, which dedupes and stores entities in a normalized cache.

Pros

  • Entity-level sharing across different queries.
  • Relay avoids most duplicates by design.

Cons

  • Only for GraphQL APIs.
  • A normalized cache adds its own complexity.

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.

A bar chart on a log scale of origin requests for one new story URL when 20,000 readers ask in the first second and the origin takes 300 milliseconds to build it. With no CDN cache: 20,000 origin requests. With a cache but no collapsing: about 6,000, every request that arrives during the first 300 milliseconds. With collapsing at 50 edge locations: 50. With collapsing and a shield layer: 1.origin requests, log scaleno CDN cache20,000cache, no collapsing6,000collapsing at 50 edges50collapsing + shield1
Illustrative numbers for one spike. Without collapsing, every request that arrives while the first miss is still being built goes to the origin too. Collapsing turns those into one request per cache, and a shield makes it one in total.

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.

ts
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
Define the cached function once, at module level, and import it everywhere. Calling 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.

A tree of server components for one page request: the page has four children, layout, header, sidebar and main. Each calls getUser 7. All four calls go to one cached getUser, wrapped in React cache, which runs one query against the users database. A note says the memo lasts for this request only.pagelayoutheadersidebarmaingetUser(7)users4 callscache(): memo forthis request only1 query
Four components ask, one query runs. Because the memo is thrown away when the request ends, a request from the next user starts with an empty one, so data never leaks between users.

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 fetch with no timeout on a stuck connection) holds its map entry forever, and every later caller waits on it. Give shared requests a timeout with AbortSignal.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 (gcTime in TanStack Query).

Things that must not be merged

  • Writes. As chapter 3 showed, POST, PUT, PATCH and DELETE are 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-Language or 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 Response object, or use response.clone() for each reader.
A flowchart. Is it a read? If no, do not merge it: disable the button or send an idempotency key. If yes, is the same key already in flight? If yes, share its promise. If no, are many small keys of one kind requested together? If yes, batch them in one tick with a size limit. If no, send it alone, and cache it if a few seconds old is fine.Is it a read?Same key in flight?Many small keys?don't merge itdisable, idempotency keyshare its promiseno new requestbatch themone tick, max sizesend it alonecache if it can be oldnoyesyesyesnono
Writes never merge. Reads join a matching request if one is in flight, batch with their neighbours if a batch endpoint exists, and otherwise go out alone.
SymptomLikely causeFix
Same URL several times at page loadComponents fetch directly, outside the data layerRoute every read through one hook or client
Wrong data after switching team or languageKey missing an input the fetch readsAdd it to the key; turn on the lint rule
Requests on every renderKey contains a new object, date or random ID each timeBuild keys from stable primitives
Old value right after savingNew caller joined a request from before the writeCancel in-flight requests on invalidation
Error bursts during an outageEach caller retries the shared failureRetry once in the shared layer, with backoff
Another user's data on the serverModule-level memo shared across requestsScope 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.

  1. Name the burst. "Each card fetches its author, so 50 cards could mean 50 requests for about 10 people."
  2. Draw the data layer. A box between components and the network, holding a cache keyed by query key with in-flight sharing.
  3. Give the key. ["user", id] or ["feed", { tab, cursor }], and say it includes every input that changes the answer.
  4. 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.
  5. Mention the edges. Writes are never deduped; invalidation cancels in-flight reads; server-side memos are per request.
LevelWhat a strong answer includes
JuniorNotices duplicate requests and suggests a shared hook or React Query so components share one request per key.
MidExplains 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.
SeniorCovers 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?

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 for staleTime (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?

Question 2

What is the difference between deduplication, batching and caching?

Question 3

How would you implement a DataLoader-style batcher on the client, and what can go wrong?

Question 4

Two components share one in-flight request, and one of them unmounts. Should you abort the request?

Question 5

How do you avoid a stampede on your origin when a popular page's cache entry expires?

Practice it

These design problems lean on deduplication and batching.

References

Read next