SWR Deduplication for Concurrent Headless Requests
Modular headless component trees 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, fetcher references are stable, 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:
sequenceDiagram participant H as GlobalHeader participant N as NavigationDrawer participant F as Footer participant S as SWR cache participant API as CMS API H->>S: useSWR(key) S->>API: single in-flight fetch N->>S: useSWR(same key) S-->>N: attach to in-flight promise F->>S: useSWR(same key) S-->>F: attach to in-flight promise API-->>S: response S-->>H: resolve S-->>N: resolve S-->>F: resolve
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.
Fetcher identity vs. logical equivalence
SWR tracks fetcher identity by reference, not implementation. An inline arrow function creates a new object every render:
// ❌ Breaks deduplication: new function reference per render
useSWR('/api/cms/global-config', () =>
fetch('/api/cms/global-config').then(res => res.json())
)
Even with a matching key, SWR ties the in-flight request to the specific fetcher reference, so concurrent mounts with different references spawn parallel requests. Hoist fetchers to module scope or memoize them for reference stability.
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. Module-scope fetchers
Extract fetchers to module level (or memoize with stable deps) for reference equality across renders:
// ✅ Single reference across the entire app
export const cmsFetcher = async (url) => {
const res = await fetch(url, {
headers: { 'X-CMS-Preview-Token': process.env.NEXT_PUBLIC_CMS_PREVIEW || '' }
});
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.
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.