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:
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:
// ❌ 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:
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.
The fixes
1. Canonical cache keys
Normalize params and GraphQL payloads before useSWR so identical logical requests map to identical keys:
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:
// ✅ 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:
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:
// 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:
// 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.
Validation and monitoring
Confirm the fix in staging and production via the Network waterfall, and assert request counts in CI:
// 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.