SWR Deduplication for Concurrent Headless Requests

Part of SWR Stale-While-Revalidate Patterns, this guide deals with modular headless component trees that fire identical data requests simultaneously across independent UI islands — a header, a nav drawer, and a footer all fetching site metadata on the same render pass. SWR deduplicates these into one in-flight request, but only when cache keys match exactly, every component shares one cache, and hydration is synchronized. Break any of those and the requests bypass the dedup window, multiplying CMS API load and degrading Time to Interactive.

Why deduplication fails

SWR maps a cache key to an in-flight promise. When components request the same key within dedupingInterval (default 2000ms), SWR returns the existing promise instead of starting a new fetch. With matching keys and stable fetchers, three concurrent islands collapse to one round-trip:

Three islands, one in-flight requestThe header starts a fetch for the shared key; the navigation drawer and footer request the same key within the deduping interval and attach to the in-flight promise; one response resolves all three.GlobalHeaderNavDrawerFooterSWR cacheCMS proxyuseSWR(key)single fetchuseSWR(same key)useSWR(same key)attach to in-flightpromiseresponseresolve all three
Only the first hook call reaches the CMS; the others attach to its promise inside the deduping window.

Deduplication fails when the cache lookup diverges from the intended request signature — usually one of three anti-patterns. Understanding how Data Fetching & Caching Strategies intersect with SWR’s key hashing is the basis for diagnosing duplicate round-trips.

Cache-key mismatch

SWR uses the first useSWR argument as a strict-equality (===) key. CMS routing introduces query params that fragment identical requests: a nav component requests /api/cms/navigation?locale=en-US while a footer requests /api/cms/navigation?lang=en-US. Same content, two cache entries. GraphQL fragments similarly — whitespace, field reordering, or differing fragment definitions produce distinct string keys. The fix is a canonical key-normalization layer before the request reaches SWR’s cache.

Fetchers that disagree about one key

SWR deduplicates by key alone. Once a request for a key is in flight, later hooks with the same key attach to it, whatever fetcher they pass. That makes inline fetchers harmless for deduplication, but it creates a subtler bug: when two components use the same key with different fetchers, say one that normalizes the payload and one that returns raw JSON, whichever mounts first decides the shape every other component receives. The footer then crashes on some pages and not others, depending on render order. Inline fetchers are how such divergence usually starts:

JavaScript
// ❌ Breaks deduplication: new function reference per render
useSWR('/api/cms/global-config', () =>
  fetch('/api/cms/global-config').then(res => res.json())
)

Define one fetcher per endpoint family at module scope, or set it once on SWRConfig, so every component that uses a key also uses the same fetcher and receives the same shape.

Hydration and SSR/ISR races

In Next.js, server-rendered payloads and client hydration overlap. If fallbackData isn’t mapped to the exact client cache key, or revalidateOnMount defaults to true, hydration fires a fresh request despite valid server data. This is most disruptive when implementing SWR Stale-While-Revalidate Patterns across server components and client islands. Aligning server data injection with client hydration removes the redundant fetch.

Reproducing it

Mount three components that fetch the same metadata in one render pass — <GlobalHeader />, <NavigationDrawer />, <Footer />. Open the Network panel, filter Fetch/XHR, and reload. Three GET calls to the same endpoint within ~200ms means deduplication is failing. Confirm with a timestamp logger:

JavaScript
const fetcher = async (url) => {
  console.log(`[SWR Fetcher] Invoked at ${performance.now().toFixed(2)}ms for ${url}`);
  const res = await fetch(url);
  return res.json();
};

Overlapping invocation timestamps mean the cache lookup fails before the fetcher runs — a key or reference mismatch, not network latency.

Requests for global metadata per page loadNumber of requests for the same global configuration endpoint on one page load before and after each fix.Fragmented keys (locale vs lang)5 requestsCanonical keys, islands with own caches3 requestsCanonical keys, shared provider1 requestsPlus fallback from server0 requests
Counted in the Network panel on a layout with five components reading site metadata.

The fixes

1. Canonical cache keys

Normalize params and GraphQL payloads before useSWR so identical logical requests map to identical keys:

JavaScript
import { stringify } from 'qs';

function generateCmsKey(endpoint, params = {}) {
  // Sort params alphabetically to prevent key fragmentation
  const sortedParams = Object.keys(params)
    .sort()
    .reduce((acc, key) => ({ ...acc, [key]: params[key] }), {});

  const queryString = stringify(sortedParams, { addQueryPrefix: true });
  return `${endpoint}${queryString}`;
}

// Usage
const key = generateCmsKey('/api/cms/navigation', { locale: 'en-US' });
useSWR(key, fetcher);

2. One fetcher per key family

Extract fetchers to module level so every component that shares a key also shares the response shape:

JSX
// ✅ Single reference across the entire app
export const cmsFetcher = async (url) => {
  // Same-origin proxy: the CMS token stays on the server.
  const res = await fetch(url, { credentials: 'same-origin' });
  if (!res.ok) throw new Error(`CMS Fetch Failed: ${res.status}`);
  return res.json();
};

function Navigation() {
  const { data } = useSWR('/api/cms/navigation', cmsFetcher);
  return <nav>{data?.links.map(link => <a key={link.id} href={link.slug}>{link.title}</a>)}</nav>;
}

3. Provider configuration

Centralize dedup behavior in SWRConfig. Tune dedupingInterval to your CMS cadence and disable aggressive revalidation during hydration:

JSX
import { SWRConfig } from 'swr';

export default function App({ children, pageProps }) {
  return (
    <SWRConfig value={{
      fetcher: cmsFetcher,
      dedupingInterval: 5000, // Extend window for CMS-heavy layouts
      revalidateOnFocus: false,
      revalidateOnReconnect: true,
      shouldRetryOnError: true,
      errorRetryCount: 3,
      // Seed the cache with server-rendered data
      fallback: pageProps?.fallbackData || {}
    }}>
      {children}
    </SWRConfig>
  );
}

4. Hydration alignment

Map server-fetched data to the exact client cache key and disable revalidateOnMount so hydration doesn’t re-fetch:

JSX
// Server Component (App Router)
export default async function Page() {
  const data = await fetchCmsData('/api/cms/global-config');

  return (
    <ClientLayout fallbackData={{ '/api/cms/global-config': data }}>
      <GlobalHeader />
      <Footer />
    </ClientLayout>
  );
}

// Client Component
function ClientLayout({ children, fallbackData }) {
  return (
    <SWRConfig value={{ fallback: fallbackData }}>
      {children}
    </SWRConfig>
  );
}

Seeding the cache before hydration makes SWR treat the payload as fresh and skip the request, per the SWR performance guidelines.

5. Share one cache across islands

Frameworks with partial hydration, such as Astro islands or several React roots mounted into a server-rendered page, create one React tree per island. Each tree gets its own default SWR cache, so deduplication cannot work across islands even with perfect keys. Pass the same cache provider to every island’s SWRConfig:

TSX
// lib/swr-shared-cache.ts: one Map for every island in this bundle
import type { Cache } from "swr";

const shared = new Map() as Cache;
export const sharedProvider = (): Cache => shared;

// islands/Header.tsx (and Footer.tsx, NavDrawer.tsx)
import { SWRConfig } from "swr";
import { sharedProvider } from "@/lib/swr-shared-cache";
import { cmsFetcher } from "@/lib/cms-fetcher";

export default function HeaderIsland() {
  return (
    <SWRConfig value={{ provider: sharedProvider, fetcher: cmsFetcher, dedupingInterval: 5000 }}>
      <GlobalHeader />
    </SWRConfig>
  );
}

The Map is shared only if every island imports the same module instance, which bundlers guarantee when the islands are part of one build and the module lands in a shared chunk. Verify it once in DevTools: a second island mounting after the first should read cached data with no request.

Separate island caches versus a shared providerWithout a shared provider each island has its own cache and sends its own request; with a module-level Map provider all islands read one cache and send one request.Header islandNav islandFooter islandShared MapproviderCMS proxy1 request
Deduplication happens inside a cache; islands only benefit when they share one.

Validation and monitoring

Confirm the fix in staging and production via the Network waterfall, and assert request counts in CI:

JavaScript
// Playwright test example
await page.route('/api/cms/global-config', route => route.continue());
const [request] = await Promise.all([
  page.waitForResponse('/api/cms/global-config'),
  page.goto('/headless-layout')
]);
// Assert only one request despite multiple components
expect(page.requests().filter(r => r.url().includes('/api/cms/global-config')).length).toBe(1);

In production, monitor cache hit ratios and CMS throughput. Under provider rate limits, SWR dedup acts as a circuit breaker. Pair it with edge caching (Cache-Control: s-maxage, stale-while-revalidate) so cleared client caches still hit the edge before the origin. For multi-locale or preview setups, add a webhook hook that broadcasts a mutate() on the canonical key prefix to revalidate all mounted components at once.

Deduplication isn’t set-and-forget. Disciplined key normalization, stable fetcher references, and synchronized hydration turn a concurrent request storm into a single optimized pipeline that preserves CMS quota and frontend performance.

Configuration Reference

Option Value Why
dedupingInterval 2000 to 5000 ms Window in which same-key requests share one fetch.
provider shared module-level Map Lets separate React roots share one cache.
fallback server data keyed by canonical key Seeds the cache so hydration sends no request.
revalidateOnMount false when fallback exists Prevents an immediate refetch of seeded data.
Key normalization sorted params, canonical names Identical requests map to identical keys.

Gotchas & Edge Cases

  • Array keys. SWR serializes array keys with a stable hash, so ["/api/cms/nav", { locale }] deduplicates correctly. Object property order inside the array does not matter, but the parameter names must match exactly.
  • Different deduping intervals per hook. The interval is read from the first hook that starts the request. Set it on the provider, not per hook, so behaviour does not depend on render order.
  • Server components cannot use SWR. Fetch shared metadata once in a server component and pass it as fallback; the client islands then deduplicate against seeded data.
  • Preview flag outside the key. A preview island and a published island with the same key share one entry. Always encode preview state in the key.

Frequently Asked Questions

Does SWR deduplicate across browser tabs?

No. Each tab has its own JavaScript heap and cache. Cross-tab sharing needs a BroadcastChannel or a service worker, which is rarely worth it for CMS content; the CDN in front of the proxy already absorbs repeated requests from many tabs.

What happens if the deduping window is too long?

A long window means a second component mounting a few seconds later reuses the earlier result instead of revalidating. For global metadata that is desirable. For fast-changing data it delays freshness, so keep longer windows to content that changes rarely.

How do I dedupe GraphQL requests with SWR?

Use a key that includes a normalized query identifier, such as the operation name or a persisted query hash, plus sorted variables, and not the raw query string, which differs with whitespace. The same canonicalization rule as for REST parameters applies.