Home › Modern Web › CDN React SPA Caching
Modern Web

CDN Caching for React SPAs: Code Splitting, Chunk Hashing, and Prefetching

By Sarah Mitchell, Edge Computing Engineer September 2, 2026 9 min read

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.

Core Principle: Treat your React build as two distinct asset classes. The 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.

Performance Comparison: Edge vs Origin Processing
Metric Edge Processing Origin Processing Improvement
Latency (p50)8–15 ms120–300 ms15–20x faster
Cache Hit Ratio85–95%N/AReduces origin load
TLS Handshake1–5 ms (nearby PoP)50–150 ms10–30x faster
Bandwidth Cost$0.01–0.04/GB$0.05–0.12/GB3–5x cheaper
Availability99.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.

CDN Caching Flow for Hashed React Chunks Diagram showing browser requesting index.html with no-cache, then fetching immutable hashed chunks from CDN edge cache. BROWSER ◯ index.html no-cache, ETag app.a3f9.js immutable, 1y vendor.8c1d.js immutable, 1y MISS → 200 HIT CDN EDGE CACHE: index.html max-age=0, must-revalidate CACHE: *. [hash].js max-age=31536000, immutable HIT RATIO > 95% for hashed assets revalidate ORIGIN S3 / R2 / Nginx ETag / Last-Modified Only hit on HTML revalidation & new hashes
Figure 1: HTML is revalidated on every navigation, while content-hashed chunks are served immutably from the edge for a year.

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:

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.

Anti-pattern: Do not prefetch on page load for all routes. This creates a thundering herd on deploy when every user requests every new chunk simultaneously, causing origin overload and edge cache churn. Prefetch lazily, on intent.

CDN Configuration Checklist for React SPAs

Beyond headers, configure your CDN for SPA-specific behavior:

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

Should I cache index.html at the CDN edge?

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.

How many chunks is too many for code splitting?

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.

Does prefetching hurt CDN cache hit ratio?

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.

What is the difference between [hash], [chunkhash], and [contenthash]?

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

References & Standards: OWASP CDN Security · RFC 7234