Fixing AWS CloudFront Cache Invalidations That Still Serve Stale Content

May 29, 2026 6 min read 37 views
Flat illustration of a cloud CDN node with layered cache tiers and a refresh arrow on a soft blue gradient background

You ran a CloudFront invalidation, watched the AWS console flip it to Completed, hit refresh in your browser, and the old JavaScript bundle is still loading. The invalidation clearly finished β€” so why is CloudFront still serving yesterday's file?

The answer is almost never a broken invalidation. It's almost always one of a handful of misunderstood caching layers sitting between your origin and your user. This guide walks through each one so you can isolate the real culprit and fix it.

What you'll learn

  • How CloudFront's edge and regional caches interact with invalidations
  • Why Cache-Control headers from your origin can override everything
  • How Origin Shield adds a caching layer that invalidations often miss
  • How to use versioned file names to make cache staleness a non-issue
  • How to verify with curl that the problem is CloudFront and not your browser

Prerequisites

You'll need an AWS account with an existing CloudFront distribution, basic familiarity with S3 or a custom origin, and access to the AWS CLI. The curl command is used heavily for diagnosis β€” it's available on macOS, Linux, and Windows Subsystem for Linux.

Confirm the stale response is actually coming from CloudFront

Before you chase CloudFront bugs, rule out your browser cache. A browser can cache a response for hours regardless of what CloudFront does.

Run this to bypass the browser entirely:

curl -I https://your-distribution.cloudfront.net/path/to/file.js

Look at two headers in the response:

  • X-Cache: Hit from cloudfront means the edge served it from cache. Miss from cloudfront means it fetched from origin.
  • Age: The number of seconds the object has been cached. A high value confirms the old copy is sitting in an edge node.

If X-Cache shows a miss but the response is still the old file, the problem is at your origin, not CloudFront. If it shows a hit after a completed invalidation, keep reading.

Understand what an invalidation actually does

A CloudFront invalidation sends a signal to every edge location telling it to expire a specific path from its local cache. The next request to that edge fetches a fresh copy from the regional cache tier or the origin.

The key phrase is next request. The invalidation does not push a new file β€” it marks the existing cached copy as expired. That matters because if the regional cache (or Origin Shield) still has the old object, the edge will pull the stale copy from there instead of your origin.

This is the most common cause of invalidations appearing to do nothing.

Check whether Origin Shield is in the picture

Origin Shield is an optional extra caching layer that sits between CloudFront's regional edges and your origin. It reduces origin load, but it also means there are now three places a stale object can live: the edge, the regional cache, and Origin Shield.

An invalidation propagates through all three tiers, but the propagation is not instantaneous. Under heavy traffic, a stale object can be served from Origin Shield for a minute or two after the invalidation completes on the edge.

To check if Origin Shield is enabled for your distribution:

aws cloudfront get-distribution-config --id YOUR_DISTRIBUTION_ID \
  | grep -A5 OriginShield

If Enabled is true, add a minute or two to your expectations after triggering an invalidation. If staleness is a hard requirement, consider disabling Origin Shield for the specific origin that serves frequently-updated assets.

Audit your Cache-Control headers

This is where most teams find their real problem. If your origin sends a long max-age directive, CloudFront will respect it β€” and the object won't be re-fetched from origin until that TTL expires, even after an invalidation on a downstream edge.

Check what your origin is actually sending:

curl -I https://your-origin-bucket.s3.amazonaws.com/path/to/file.js

A response that looks like this is your culprit:

Cache-Control: max-age=86400
Expires: Thu, 20 Jun 2025 12:00:00 GMT

With max-age=86400, CloudFront caches the object for 24 hours and won't fetch from origin until that window closes. Your invalidation expired the edge copy, but the regional cache fetched the same stale object from origin again because max-age is the TTL governing the entire chain.

The fix depends on what you're serving:

  • HTML files and API responses that change on every deploy: set Cache-Control: no-cache, no-store or max-age=0, must-revalidate.
  • Versioned static assets (JS, CSS with content hashes in their filenames): max-age=31536000, immutable is fine because you'll change the filename on every build.
  • Images and fonts that change rarely: max-age=604800 (one week) is a reasonable middle ground.

Fix Cache-Control on S3-hosted assets

If your origin is an S3 bucket, S3 does not automatically set sensible Cache-Control headers. You need to set them explicitly at upload time.

Using the AWS CLI, you can upload with the correct header in one step:

aws s3 cp ./dist/index.html s3://your-bucket/index.html \
  --cache-control "no-cache, no-store" \
  --content-type "text/html"

For a whole build output folder where HTML files get no-cache and versioned assets get long TTLs, use two sync commands:

# Versioned assets β€” long TTL
aws s3 sync ./dist s3://your-bucket \
  --exclude "*.html" \
  --cache-control "max-age=31536000, immutable"

# HTML β€” no cache
aws s3 sync ./dist s3://your-bucket \
  --exclude "*" --include "*.html" \
  --cache-control "no-cache, no-store"

After re-uploading with corrected headers, trigger one final invalidation for /* to flush the old copies from every edge.

Wildcard invalidations and path specificity

When you invalidate /*, CloudFront expires every cached object in the distribution. When you invalidate /static/app.js, it expires only that exact path.

A common trap is invalidating a path that doesn't exactly match what's cached. If a file was cached with a query string β€” say /static/app.js?v=1.2 β€” invalidating /static/app.js won't touch it. CloudFront treats query strings as part of the cache key if your distribution is configured to forward them.

Check your cache behavior's query string settings:

aws cloudfront get-distribution-config --id YOUR_DISTRIBUTION_ID \
  | grep -A3 QueryString

If QueryString is true, you need to either invalidate the exact URL with its query string, or invalidate with a wildcard: /static/app.js*.

Use file versioning to make invalidations optional

Invalidations have a cost above the first 1,000 paths per month (AWS charges per path after that). More importantly, they add operational complexity and race conditions. The cleaner approach is content-addressed file names.

Modern build tools like Vite, webpack, and Parcel automatically append a content hash to output filenames:

app.a3f92b1c.js
vendor.d8e1234a.js

Because the filename changes with the content, the old filename stays cached (safely, since it's immutable) and the new filename is fetched fresh on first request. You only need to invalidate /index.html β€” the entry point that references the new filenames.

This single-path invalidation is fast, cheap, and deterministic. If you're doing full /* invalidations on every deploy, switching to hashed filenames will immediately simplify your deployment pipeline.

Common pitfalls to watch for

Invalidation propagation delay: AWS states that invalidations typically complete in under a minute, but under high traffic conditions they can take a few minutes. Don't test immediately β€” wait two to three minutes before concluding the invalidation failed.

Compressed variants cached separately: If CloudFront is configured to compress objects, it may cache both a gzip and an uncompressed version of the same path under different internal cache keys. An invalidation covers both, but if you're testing with curl with and without Accept-Encoding: gzip, you may see different cached versions temporarily.

Multiple behaviors with different TTL settings: CloudFront lets you define different cache behaviors per path pattern. A behavior covering /api/* might have a different TTL than one covering /static/*. Check that the behavior matching your path has the TTL settings you expect, not just the default behavior.

The browser is still the culprit: Test with curl before concluding CloudFront is misbehaving. Incognito mode alone is not enough β€” browsers can still use disk cache in private windows.

Wrapping up

Most CloudFront cache staleness issues come down to the same short list of root causes. Work through these steps in order:

  1. Verify with curl that the stale response is coming from CloudFront (X-Cache: Hit), not your browser or origin.
  2. Check Origin Shield on your distribution and allow extra propagation time if it's enabled.
  3. Audit Cache-Control headers on your origin β€” long max-age values on HTML files are the most common offender.
  4. Fix S3 object metadata to set correct headers at upload time, then flush with a one-time /* invalidation.
  5. Switch to content-hashed filenames for static assets so future deploys only need a single /index.html invalidation.

Once your Cache-Control headers are correct and your assets are versioned, cache invalidation becomes a small, predictable operation rather than a debugging session.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.