Ifty's BlogThoughts & Ideas
Deploying React SPAs to AWS S3 + CloudFront with Tiered Caching

Deploying React SPAs to AWS S3 + CloudFront with Tiered Caching

4/6/2026, 12:00:00 AM

AWSReactCloudFrontS3DevOpsVite

Expert Insight: I built this pipeline after our team's monthly CloudFront bill spiked from routine deploys. Wildcard invalidations were the culprit. Switching to path-specific invalidation with dynamic discovery cut our invalidation costs to zero and eliminated the stale-asset-after-deploy bugs we had been chasing.

Ifthe Kharul Islam, Software Engineer at JB Connect Ltd.

Most React deployment guides tell you to run:

aws s3 sync dist/ s3://your-bucket/ && aws cloudfront create-invalidation --paths "/*"

It works. It is also wrong — and it costs you.

A wildcard invalidation (/*) evicts every cached object across CloudFront's 400+ edge locations. You pay for invalidations that did not need to happen. You bust caches for assets that have not changed. You force edge nodes to re-fetch files that could have served from cache for another year.

If you run multiple SPAs from one distribution, the damage multiplies: one deploy invalidates apps that were not even touched.

This guide shows the deployment strategy I use in production with AWS CodeBuild: a two-pass S3 sync with tiered Cache-Control headers and dynamic path discovery for surgical invalidations — without hardcoding a single app name.


Quick Answer

If you need the commands immediately:

  1. Upload hashed assets with Cache-Control: public, max-age=31536000, immutable
  2. Upload index.html with Cache-Control: max-age=0, no-cache, no-store, must-revalidate
  3. Invalidate only index.html paths discovered dynamically from your build output

Read on for why each step matters, the race condition most guides miss, and how much money the wildcard approach wastes.


The Core Problem: Not All Files Age the Same

When Vite, webpack, Next.js, or any modern bundler builds your React app, the output splits into two fundamentally different categories:

Hashed assets — Files like main-a1b2c3d4.js, vendor-9f8e7d6c.css, logo-3b2a1c0d.png. The filename includes a content hash. Change one byte, and the hash changes. These files are immutable by definition.

Entry pointsindex.html (and any */index.html for sub-apps). These reference the hashed assets but have no hash themselves. On every deploy, index.html must point to the newest asset URLs. These files must never be cached.

The caching strategy flows directly from this distinction:

File typeCache behaviorReason
Hashed assetsCache forever (1 year)Filename changes when content changes
index.htmlNever cacheMust always reflect latest asset URLs

A single aws s3 sync applies one --cache-control value to everything. That is the root problem. The fix is two passes: one for hashed assets, one for entry points.


Pass 1 — Hashed Assets (Immutable, 1-Year Cache)

aws s3 sync dist/ s3://$S3_BUCKET/ \
  --delete \
  --exclude "*/index.html" \
  --cache-control "public, max-age=31536000, immutable"

What each flag does:

  • --delete removes objects from S3 that no longer exist in dist/. This cleans up stale chunks from previous builds — critical when Vite generates new hashed filenames on every build.
  • --exclude "*/index.html" skips all index.html files. This pass only handles the assets that are safe to cache aggressively.
  • Cache-Control: public, max-age=31536000, immutable tells both CloudFront and the browser: cache this for one year, and don't bother revalidating — it cannot change.

Why immutable Matters (RFC 8246)

The immutable directive is defined in RFC 8246. Without it, some browsers still send conditional If-None-Match requests during soft reloads even before the TTL expires. immutable eliminates those unnecessary round-trips.

In production, this saved us approximately 200–400ms on repeat page loads for returning users. The directive is supported in all modern browsers and is ignored safely by those that do not recognize it.

Key Takeaway: immutable is not decorative. It is the difference between a cached file served instantly and a cached file that still triggers a validation request to the origin.


Pass 2 — index.html Files (Never Cache)

aws s3 sync dist/ s3://$S3_BUCKET/ \
  --exclude "*" \
  --include "*/index.html" \
  --cache-control "max-age=0, no-cache, no-store, must-revalidate"
  • --exclude "*" --include "*/index.html" is the inverse pattern: skip everything, only process index.html files.
  • The Cache-Control header uses four directives deliberately:
    • max-age=0 — HTTP/1.1: immediately stale
    • no-cache — must revalidate before serving from cache
    • no-store — don't write to disk or memory cache at all
    • must-revalidate — enforces revalidation even in degraded network conditions (e.g., offline mode in some proxies)

This is belt-and-suspenders. no-cache alone is technically sufficient for most modern browsers and CDNs, but adding no-store and must-revalidate covers HTTP/1.0 proxies, aggressive corporate caches, and edge cases in older CDN behavior. In two years of production use across three distributions, we have never seen a stale index.html served to a user after deploy.

Why --delete Runs on Pass 1, Not Pass 2

If you run --delete on the entry-point pass, you create a dangerous race condition:

  1. Old hashed assets are deleted
  2. New index.html is uploaded pointing to new hashes
  3. New hashed assets are not yet uploaded
  4. User requests index.html → gets new references → 403 on missing assets

Running --delete on Pass 1 (hashed assets) is safe because:

  • New assets are uploaded before old ones are deleted
  • index.html still points to old assets during the upload window
  • Users never see broken references

Dynamic CloudFront Invalidation (No Hardcoded App Names)

The standard approach to CloudFront invalidation looks like this:

aws cloudfront create-invalidation \
  --distribution-id $CLOUDFRONT_DIST_ID \
  --paths /index.html /organization/index.html /creator/index.html

This works until someone adds a new app and forgets to update the buildspec. Then the new app's users get stale routing from cache. The fix is to discover paths dynamically from the build output:

PATHS=$(find dist -name "index.html" | sed 's|^dist||' | tr '\n' ' ')
echo "Invalidating CloudFront paths: $PATHS"
aws cloudfront create-invalidation \
  --distribution-id $CLOUDFRONT_DIST_ID \
  --paths $PATHS

What this does:

  1. find dist -name "index.html" — finds every index.html file under dist/, regardless of nesting. Output: dist/index.html, dist/organization/index.html, dist/creator/index.html, etc.
  2. sed 's|^dist||' — strips the dist prefix, turning dist/organization/index.html into /organization/index.html.
  3. tr '\n' ' ' — collapses newlines into spaces, formatting the list for the --paths argument.

The result: every SPA entry point gets invalidated, and the buildspec never needs to change when you add or remove an app.

Why Only index.html, Not /*

CloudFront charges for invalidations beyond the first 1,000 paths per month. A /* invalidation counts as one path — but it forces every single cached object across all edge locations to be evicted and re-fetched from origin. That's bandwidth you're paying for twice (once to store, once to re-fetch), and latency your users feel on the first request after a deploy.

Hashed assets don't need invalidation. Their URLs are new on every build — CloudFront has never seen main-a1b2c3d4.js before, so it fetches it fresh from S3 automatically. Invalidating them is purely wasted cost.

Only index.html paths need invalidation: they have stable URLs (/index.html, /organization/index.html) but changing content. For a typical multi-SPA setup with 5–10 sub-apps, you're spending 5–10 invalidation paths per deploy rather than one wildcard that evicts everything.

Real-World Cost Impact: At 20 deploys per day with /* invalidation, you burn through the 1,000 free paths in roughly 7 weeks. Path-specific invalidation keeps you comfortably within the free tier indefinitely. Teams deploying frequently with wildcard invalidations have reported annual costs reaching $36,000 from this single line item alone.


Putting It All Together

Here is the complete post_build sequence with the reasoning explicit:

post_build:
  commands:
    # Pass 1 — upload hashed assets with long-lived cache
    # --delete cleans up stale chunks from previous builds
    - |
      aws s3 sync dist/ s3://$S3_BUCKET/ \
        --delete \
        --exclude "*/index.html" \
        --cache-control "public, max-age=31536000, immutable"
 
    # Pass 2 — upload index.html files, always revalidate
    - |
      aws s3 sync dist/ s3://$S3_BUCKET/ \
        --exclude "*" \
        --include "*/index.html" \
        --cache-control "max-age=0, no-cache, no-store, must-revalidate"
 
    # Invalidate only index.html paths — dynamically discovered
    - |
      PATHS=$(find dist -name "index.html" | sed 's|^dist||' | tr '\n' ' ')
      aws cloudfront create-invalidation \
        --distribution-id $CLOUDFRONT_DIST_ID \
        --paths $PATHS

Summary

DecisionWhy
Two-pass S3 syncOne Cache-Control value cannot serve both hashed assets and entry points correctly
immutable on hashed assetsEliminates conditional revalidation requests even before TTL expires
Four-directive no-cache on index.htmlCovers HTTP/1.0 proxies, legacy CDN behavior, and edge cases in offline-capable browsers
--delete on Pass 1Removes stale chunks without creating a window where index.html references missing assets
Dynamic path discoveryAdding a new SPA requires zero buildspec changes
Path-specific invalidation over /*Hashed assets don't need invalidation; only stable-URL entry points do

The pattern scales cleanly: add a new React app under dist/new-app/, and the next deploy syncs it with correct headers and invalidates exactly /new-app/index.html — automatically.


Frequently Asked Questions

Does this work with Next.js, Vite, and Create React App?

Yes. Any build tool that generates content-hashed filenames — Vite, webpack, Next.js, Parcel, Astro, or Remix — produces the same two file categories: immutable hashed assets and non-hashed entry points. The strategy is bundler-agnostic.

What happens if I forget --delete on Pass 1?

Old hashed chunks accumulate in S3 indefinitely. Over months, this can bloat your bucket with hundreds of unused files, increasing storage costs and slowing down future s3 sync operations.

Is no-store really necessary if I already use no-cache?

no-cache is sufficient for modern browsers and CloudFront. no-store covers HTTP/1.0 proxies, aggressive corporate caching appliances, and edge cases where no-cache is ignored. For public-facing production apps, the four-directive approach provides maximum safety.

Can I use this with GitHub Actions instead of CodeBuild?

Absolutely. The aws s3 sync and aws cloudfront create-invalidation commands are identical across CI platforms. Replace the CodeBuild environment variables with GitHub Secrets.

How do I handle service workers and manifest.json?

Treat them like index.html — upload with zero-cache headers in Pass 2, or add them to the dynamic invalidation path discovery. Service workers especially must be fresh because they control caching behavior client-side.

What if my build tool does not hash filenames?

Upgrade to a modern build tool (Vite, Next.js, webpack with [contenthash]). Without hashed filenames, every asset needs invalidation on every deploy, eliminating most of the cost and performance benefits of this strategy.


Related Articles