Single-page applications built with React ship a lot of JavaScript. Without a deliberate CDN strategy, every deploy invalidates the entire bundle, users re-download megabytes of unchanged code, and edge caches provide little value. The solution is not to cache less, but to cache smarter: split your application into granular chunks, fingerprint them with content hashes, and use the CDN to serve immutable assets forever while keeping the HTML shell fresh.
This guide covers a production-grade caching architecture for React SPAs on a CDN. You will learn how to configure code splitting for optimal cache granularity, implement chunk hashing that maximizes long-term edge hits, and add prefetching without destroying cache efficiency or bandwidth budgets.
index.html is never cached at the edge (or cached for seconds with revalidation). Everything with a [contenthash] in its filename is immutable and cached for one year. This separation is the foundation of every high-performance SPA deployment.
Why React SPAs Break Traditional CDN Caching
Traditional multi-page sites cache HTML documents at the edge for minutes or hours. That model fails for SPAs. A React SPA serves a single index.html shell that bootstraps the entire app. If you cache that HTML for an hour, users will not see your latest deploy for an hour. If you do not cache it at all and also bundle your app into a single app.js, every code change busts the cache for every user.
The bundle problem is amplified by modern React apps. A typical Create React App or Vite build without splitting can produce a 1.2–2.5 MB main bundle. Vendor libraries like React, React Router, and date-fns change infrequently, yet they are re-downloaded whenever you fix a typo in a component. This wastes origin bandwidth, increases Time to Interactive (TTI), and defeats the CDN's purpose.
Effective CDN caching strategies for SPAs solve this by decoupling cache lifetimes. The HTML document uses Cache-Control: no-cache with ETag revalidation, so the browser always checks freshness but can use a 304 Not Modified if nothing changed. Hashed JS, CSS, and media assets use Cache-Control: public, max-age=31536000, immutable, allowing both browsers and edge nodes to serve them for a year without revalidation. Understanding how HTTP/2 and HTTP/3 with QUIC multiplex these many small chunks is also critical for performance.
1. Code Splitting for Cache Granularity
Code splitting is the most impactful optimization for CDN cache hit ratio. Instead of one monolithic bundle, you emit many small chunks that can be cached independently. When you change one route, only that route's chunk gets a new hash and needs to be fetched.
| Metric | Edge Processing | Origin Processing | Improvement |
|---|---|---|---|
| Latency (p50) | 8–15 ms | 120–300 ms | 15–20x faster |
| Cache Hit Ratio | 85–95% | N/A | Reduces origin load |
| TLS Handshake | 1–5 ms (nearby PoP) | 50–150 ms | 10–30x faster |
| Bandwidth Cost | $0.01–0.04/GB | $0.05–0.12/GB | 3–5x cheaper |
| Availability | 99.99% (multi-PoP) | 99.9% (single DC) | 10x fewer outages |
Route-Based Splitting
Start with React.lazy and dynamic import(). This is the baseline for any SPA:
// App.jsx - Route-based splitting
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./routes/Dashboard.jsx'));
const Checkout = lazy(() => import('./routes/Checkout.jsx'));
const Settings = lazy(() => import('./routes/Settings.jsx'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}Each import() becomes a separate chunk. A user who only visits the Dashboard never downloads Checkout code, saving bandwidth and improving edge cache utilization because fewer unique chunks are hot.
Vendor and Framework Splitting
For finer control, configure your bundler to isolate stable dependencies. With Vite or webpack, split large vendors into their own chunks:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-ui': ['@headlessui/react', 'framer-motion'],
'vendor-chart': ['recharts'],
}
}
}
}
}This creates a vendor-react.[hash].js that may not change for months. Even if you deploy daily, edge nodes continue to serve this chunk from cache with a 100% hit ratio. Avoid over-splitting into dozens of tiny chunks under 20 kB; the overhead of extra HTTP requests and compression dictionary resets can outweigh cache benefits. Aim for chunks between 30–150 kB gzipped.
For apps with heavy libraries loaded conditionally, use component-level splitting as well. A rich text editor or map component should be loaded only when needed, keeping the initial entry chunk lean for the fastest First Contentful Paint.
2. Chunk Hashing for Immutable Caching
Chunk hashing determines whether your CDN can cache aggressively. The goal is deterministic, content-based filenames that only change when the content changes.
Use [contenthash], Not [hash] or [chunkhash]
Webpack and Vite support three placeholders. [hash] is the hash of the entire build; any file change invalidates every asset. [chunkhash] is based on the chunk's content but can be unstable across builds. [contenthash] is derived from the extracted content itself and is the only correct choice for long-term caching.
// webpack.config.js - Correct hashing
module.exports = {
output: {
filename: 'static/js/[name].[contenthash:8].js',
chunkFilename: 'static/js/[name].[contenthash:8].chunk.js',
clean: true,
},
optimization: {
moduleIds: 'deterministic', // stable ids between builds
runtimeChunk: 'single', // isolate runtime to avoid hash churn
}
}With moduleIds: 'deterministic' and a separate runtimeChunk, adding a new route does not change the hash of existing vendor chunks. Without this, webpack's internal module IDs shift and cause unnecessary cache busting. Vite enables deterministic hashing by default, but you should still verify output.
CDN Cache-Control Headers
Configure your origin or CDN to emit precise headers based on filename pattern. Most CDNs allow rule-based header overrides:
# Nginx or CloudFront Function / Worker logic
location ~* \.[a-f0-9]{8}\.(js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header CDN-Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
add_header Cache-Control "public, max-age=0, must-revalidate";
add_header CDN-Cache-Control "public, max-age=60, stale-while-revalidate=300";
}Note the split between Cache-Control for browsers and CDN-Cache-Control for edge nodes. You may want browsers to revalidate HTML immediately while allowing the edge to serve stale HTML for 60 seconds to absorb traffic spikes. For immutable assets, both headers are identical. Proper CDN security headers should also be applied without interfering with cacheability.
Always emit ETag and Last-Modified for HTML, and ensure hashed assets are served with Content-Encoding: br or gzip pre-compressed at build time to reduce origin CPU.
| Asset Type | Example Filename | Cache-Control | CDN Behavior |
|---|---|---|---|
| HTML Shell | index.html |
max-age=0, must-revalidate |
Revalidate Edge caches 60s, browser always checks |
| Hashed JS Chunk | main.a3f9c1d2.js |
max-age=31536000, immutable |
Immutable Served from edge for 1 year, no origin hit |
| Hashed CSS | app.8c1d4e5f.css |
max-age=31536000, immutable |
Immutable Same as JS chunks |
| Hashed Media | hero.b2a7f9c1.webp |
max-age=31536000, immutable |
Immutable Image optimization at edge still cached |
| Service Worker | sw.js |
max-age=0, must-revalidate |
No Cache Never cache SW, breaks updates |
| API / JSON | /api/user |
private, no-store |
Bypass Do not cache at edge |
3. Prefetching Without Wasting Cache and Bandwidth
Prefetching makes SPAs feel instant, but naive prefetching can flood the CDN with low-priority requests, evict hot chunks, and waste mobile data. The key is to prefetch only what is highly probable and to use browser hints that respect cache and priority.
Link Prefetch vs. Preload
preload is for the current navigation and is high priority. prefetch is for the next navigation and is low priority, fetched only when idle. For React route chunks, always use prefetch:
<!-- In index.html or injected via webpack -->
<link rel="prefetch" href="/static/js/Checkout.9a2b3c4d.chunk.js" as="script" crossorigin>
<link rel="prefetch" href="/static/js/Settings.1f8e7a6b.chunk.js" as="script" crossorigin>Webpack can inject these automatically with /* webpackPrefetch: true */ magic comments, but that prefetches every lazy chunk, which is often too aggressive.
// Selective prefetch - only for likely next routes
const Checkout = lazy(() =>
import('./routes/Checkout.jsx' /* webpackPrefetch: true */)
);
// Better: manual, interaction-based prefetch
function ProductCard({ id }) {
const prefetchCheckout = () => {
import('./routes/Checkout.jsx'); // warms cache, no render
};
return <div onMouseEnter={prefetchCheckout} onFocus={prefetchCheckout}>...</div>;
}Intelligent Prefetch Strategies
For high-traffic SPAs, implement these rules:
- Hover and viewport prefetch: Prefetch on
mouseenter,focus, or when a link enters the viewport via IntersectionObserver. This has 70–80% accuracy versus 20% for blind prefetch. - Respect Save-Data and connection: Skip prefetch if
navigator.connection.saveDatais true oreffectiveTypeis2gorslow-2g. - Limit concurrency: Prefetch at most 2–3 chunks ahead. Each prefetch is a cacheable request at the edge; too many will lower hit ratio for critical chunks.
- Use Speculation Rules API: For modern browsers, use the Speculation Rules API for prerendering with finer control than
<link>.
From a CDN perspective, prefetched chunks benefit enormously from immutable caching. A prefetched chunk that is already in the edge cache is served in 20–40 ms globally. Without hashing, the same prefetch would revalidate and add latency. Monitor your CDN performance metrics to ensure prefetch hit ratio stays above 85% and that origin load does not spike after enabling it.
CDN Configuration Checklist for React SPAs
Beyond headers, configure your CDN for SPA-specific behavior:
- SPA Fallback: Serve
index.htmlfor 404s that match client-side routes (e.g.,/dashboard/*), but return real 404s for missing hashed assets. Never cache the fallback as 200 for asset URLs. - Brotli and Zstd: Pre-compress hashed assets at build time and serve with
Content-Encoding: br. Enable CDN automatic compression as fallback. Brotli saves 15–20% over gzip for JS. - HTTP/3 and Early Hints: Enable HTTP/3 to reduce head-of-line blocking for many chunks. Use 103 Early Hints to push critical chunks before HTML finishes.
- Stale-While-Revalidate: For HTML, use
stale-while-revalidate=300so edge serves stale HTML instantly while fetching fresh in background, smoothing deploys. - Cache Tag Purging: Tag immutable assets with
immutableand HTML withhtml-shell. On deploy, purge onlyhtml-shellby tag instead of full purge, preserving edge cache for hashed chunks.
Pair this with solid monitoring and observability to track cache hit ratio per asset class, p95 TTFB for HTML vs. chunks, and prefetch waste ratio. A healthy SPA should show >98% hit ratio for hashed assets and <30% origin requests overall.
Deploy Strategy and Cache Invalidation
Immutable assets should never be invalidated; they are versioned by hash. Your deploy should upload new hashed files alongside old ones, then atomically update index.html to point to new hashes. Keep old chunks for at least 7 days to support users with stale HTML in browser cache or open tabs.
Avoid query-string versioning like app.js?v=123. Some proxies and older CDNs do not cache query strings efficiently, and it prevents immutable semantics. Filename hashing is always superior.
For rollbacks, simply revert index.html to reference previous hashes. Since old chunks were never deleted, rollback is instant with no cache purge needed.
Conclusion
CDN caching for React SPAs is not about caching more, but caching with precision. Split code by route and vendor, hash every static asset with [contenthash] and serve it as immutable for a year, and prefetch only on strong user intent. Keep the HTML shell fresh with revalidation and stale-while-revalidate. When done correctly, your CDN serves 95% of requests from the edge, deploys are atomic and instant, and users experience near-instant navigations because the next chunk is already warm in both edge and browser cache.
Frequently Asked Questions
Cache it, but with a very short TTL. Use Cache-Control: public, max-age=0, must-revalidate for browsers and CDN-Cache-Control: public, max-age=60, stale-while-revalidate=300 at the edge. This protects your origin from traffic spikes while ensuring users get the latest HTML within 60 seconds. Never use immutable caching for HTML.
Aim for 8–15 async chunks for a medium SPA, each 30–150 kB gzipped. More than 30 small chunks under 20 kB increases HTTP overhead and can hurt performance despite better cache granularity. Measure with Lighthouse and your CDN's cache hit ratio; if vendor chunks change hash on every build, your splitting config needs deterministic module IDs.
Only if done aggressively. Blind prefetch of all routes on page load can evict hot chunks and waste bandwidth. Intent-based prefetch on hover, focus, or viewport entry keeps waste below 20% and actually improves hit ratio because chunks are warmed before navigation. Always respect Save-Data and slow connections.
[hash] changes when any file in the build changes, busting all caches. [chunkhash] is per-chunk but can be unstable due to module ID shifts. [contenthash] is based solely on the file's content and is stable across builds, making it the only correct choice for immutable CDN caching of React chunks.