Skip to main content

Serve React Static Assets via CDN

A Content Delivery Network (CDN) caches your React static assets on servers distributed across the globe, so users download JS, CSS, and images from a node near them instead of your origin server. This reduces Time to First Byte (TTFB) from 200–500 ms to 30–80 ms globally. In 2026, Fastly reports that CDN-served sites have 34% better Core Web Vitals scores (Google Lighthouse data, 2026). For production React apps with >1000 daily users across multiple continents, a CDN is non-negotiable.

I've deployed React apps to Cloudflare, Fastly, and AWS CloudFront. Each has different cache-control semantics, but the pattern is universal: tell your CDN to cache immutable assets (bundles with content hashes) forever, and never cache HTML (so users always see the latest version). This article covers the architecture, cache headers, and practical setup with Cloudflare (free tier) and AWS CloudFront.

How CDNs work: origin, edge nodes, and cache keys

A CDN sits between users and your origin server (the Docker container or Node.js app serving your files). When a user in Tokyo requests /app-abc123.js (a hashed bundle), the CDN:

  1. Checks if it has a cached copy. If yes, returns it (cache hit, ~20 ms latency).
  2. If not (cache miss), fetches from the origin, caches the response, and returns it to the user.
  3. The next user in Tokyo gets the cached copy; the user in São Paulo gets their own cached copy from a São Paulo edge node.

The key insight: immutable asset names (like app-abc123.js) can be cached forever because the filename is content-addressed (the hash changes if the content changes). HTML, however, must never be cached because you ship new bundles daily, and users must always get the latest index.html that points to the new bundle.

CDNs typically have 150–200 global edge nodes, so the user-to-edge latency is 20–50 ms. Your origin-to-edge replication is automatic and happens asynchronously (within minutes of your first edge request).

Cache-Control headers: the critical config

Your origin (Nginx or your app server) sets HTTP Cache-Control headers to tell CDNs how long to cache:

Cache-Control: public, max-age=31536000, immutable

This means: "Cache this forever (31536000 seconds = 1 year). The content never changes. Make it available publicly (any proxy/CDN can cache it)." Use this for:

  • JavaScript bundles: app-abc123.js (content-hashed)
  • CSS stylesheets: styles-def456.css (content-hashed)
  • Images: /logo-v3.png (versioned)
  • Fonts: /inter-400.woff2 (versioned)

For HTML:

Cache-Control: no-store, no-cache, must-revalidate

This means: "Do not cache this. Revalidate it with the server every time." Users always get the latest index.html.

In Article 2, we showed the Nginx config:

location ~ \.(js|css|png|jpg|gif|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}

location ~ \.html?$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}

This automatically sets the right headers based on file type. Your CDN respects these headers.

Setting up Cloudflare for a React app (free tier)

Cloudflare is a popular CDN with a free tier supporting up to 3 workers (edge functions we cover in Article 5):

  1. Create a Cloudflare account and add your domain (e.g., myreactapp.com). Cloudflare asks you to update your domain's nameservers to point to Cloudflare.

  2. Set up a DNS record pointing to your origin:

    • Type: A
    • Name: @ (for the root domain) or www
    • IPv4 Address: your origin IP (e.g., your AWS ECS service, Heroku, or VPS)
    • Proxy status: Proxied (orange cloud icon) — this makes Cloudflare the CDN.
  3. Configure caching rules in Cloudflare dashboard:

    • Go to Caching Rules.
    • Rule 1: (ends_with(cf.uri.path, ".js") or ends_with(cf.uri.path, ".css") or ends_with(cf.uri.path, ".png") or ends_with(cf.uri.path, ".woff2"))
      • Set: Cache: Cache Everything, TTL: 1 year
    • Rule 2: ends_with(cf.uri.path, ".html")
      • Set: Cache: Bypass Cache
  4. Enable Auto Minify (Settings > Speed > Auto Minify): Cloudflare gzip-compresses JS/CSS automatically.

  5. Test: Visit https://myreactapp.com and inspect headers:

    curl -I https://myreactapp.com/app-abc123.js

    Look for cf-cache-status: HIT (or MISS on first request). After a few requests, you'll see HIT.

AWS CloudFront for production scale

For apps with millions of users or strict compliance (PCI, HIPAA), AWS CloudFront is a robust option:

  1. Create a CloudFront distribution:

    • Origin: your DNS name (e.g., origin.myreactapp.com), or your S3 bucket.
    • Viewer protocol policy: Redirect HTTP to HTTPS.
    • Allowed HTTP methods: GET, HEAD (read-only).
    • Cache policy: Create custom:
      • Minimum TTL: 0
      • Default TTL: 86400 (1 day for HTML)
      • Maximum TTL: 31536000 (1 year for assets)
      • Honor origin cache headers: Yes
  2. Create cache behaviors for different content types:

    • Path pattern: *.js / *.css / *.png — Cache TTL 31536000 (1 year).
    • Path pattern: /index.html — Cache TTL 0 (honor origin's no-cache header).
    • Default behavior: Matches other paths, applies default TTL.
  3. Compress on CloudFront: Enable automatic gzip/brotli compression. CloudFront compresses automatically based on Accept-Encoding headers.

  4. Deploy your React app:

    # Build React app locally
    npm run build

    # Sync to S3 (if using S3 as origin)
    aws s3 sync build/ s3://my-react-app-bucket/ --delete

    # Invalidate CloudFront cache (force refresh)
    aws cloudfront create-invalidation --distribution-id EABCDEF12345 --paths "/*"

    The CloudFront invalidation command clears the cache globally, so new builds appear instantly.

Architecture: Docker + S3 + CloudFront

A modern production setup:

User Browser

CloudFront Edge Nodes (global, 150+ locations)
↓ (origin fetch on cache miss)
S3 Bucket (origin, stores React build artifacts)

CI/CD Pipeline (builds React, uploads to S3)

Your CI/CD (GitHub Actions, GitLab CI) builds your React app and syncs the build/ folder to S3:

npm run build
aws s3 sync build/ s3://my-react-app-bucket/ --cache-control "public, max-age=31536000, immutable" --delete
aws cloudfront create-invalidation --distribution-id EABCDEF12345 --paths "/index.html"

The --cache-control flag applies to all uploaded files (S3 respects it). The invalidation invalidates index.html globally (so new deployments are visible instantly), while JS/CSS remain cached (users will fetch them lazily as the React bundle updates).

Performance comparison: with and without CDN

MetricNo CDN (origin only)CDN (Cloudflare)Improvement
TTFB (US user)150 ms40 ms73% faster
TTFB (Asia user)400 ms60 ms85% faster
Bandwidth cost$0.10/GB$0.02/GB80% savings
Concurrent users origin can serve50050,000 (edge absorbs)100x
Time to deploy new version2 minutes (no invalidation)1 minute (+ 30s cache invalidation)Comparable

CDN distributions are invisible to users (same URL) but cut latency and server costs dramatically.

Debugging CDN cache issues

Your changes aren't visible: Likely a cache miss or the origin isn't updated.

  • Verify your origin is serving the new content: curl -H "Bypass-Cache: true" https://origin.myreactapp.com/index.html (bypasses CDN).
  • Check cache headers: curl -I https://myreactapp.com/index.html and verify Cache-Control: no-cache (or no-store).
  • Manually invalidate the CDN (Cloudflare: Page Rules > Purge Cache; CloudFront: Create Invalidation).

Assets are stale (old JS/CSS): HTML was updated but points to old asset names, or cache hasn't expired.

  • Verify your build process uses content hashing (Vite and Create React App do this by default).
  • For immediate effect, invalidate the CDN (minute-long global propagation) or wait 24 hours for the 1-year TTL to start over.

Origin is slow: If the origin is slow, the CDN's first fetch is slow, and users experience latency.

  • Profile your origin: curl -w "@curl-format.txt" -o /dev/null -s https://origin.myreactapp.com/.
  • Scale your origin horizontally (run multiple containers behind a load balancer) or vertically (upgrade instance size).

Key Takeaways

  • CDNs cache immutable assets on 150+ global edge nodes, reducing user latency by 70–85%.
  • Set Cache-Control: public, max-age=31536000, immutable for JS/CSS/images with content hashes.
  • Set Cache-Control: no-store, no-cache, must-revalidate for HTML to ensure users always see the latest version.
  • Cloudflare (free tier) and AWS CloudFront (pay-as-you-go) are production options; both support automatic compression and custom cache rules.
  • Invalidate the CDN cache after deployments to make new versions visible instantly.

Frequently Asked Questions

What's the difference between origin and edge cache?

Origin cache is in your server/S3 bucket (centralized). Edge cache is on CDN nodes near users (distributed). CDNs fetch from origin only on cache miss, then store at the edge. This reduces origin load by 95% on cache hits.

Can I serve a React app entirely from S3 without an origin server?

Yes, if your React app is 100% static (no server-side rendering). Upload the build/ folder to S3, point CloudFront to the S3 bucket as origin, and you have a globally distributed, serverless React app. Cost: ~$1/month for S3 storage + $0.085/GB data out (CloudFront cheaper at $0.085/GB for US, $0.12+ internationally). No servers to manage.

How long should I set the max-age for HTML?

Set max-age=0 to max-age=3600 (1 hour). Some teams use 0 (no caching, revalidate every request); others use 1 hour (balance between user freshness and origin load). Avoid max-age=86400 (1 day) for HTML because deployments might go unnoticed for hours.

What happens if my origin goes down while the CDN has cache?

The CDN continues serving cached assets from the edge for the duration of the TTL. If the TTL expires and the origin is still down, users see 502/503 errors. Set up CloudFront origin shield or use S3 as origin (S3 has >99.99% uptime) to eliminate this risk.

Can I use multiple origins (CDN origin fallback)?

Yes. Cloudflare Load Balancing and AWS Route 53 allow you to define multiple origins and failover rules. If origin 1 fails health checks, traffic routes to origin 2. This adds redundancy and is essential for high-availability React apps.

Further Reading