Fixing AWS CloudFront Cache Invalidations That Still Serve Stale Content
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-Controlheaders 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.jsLook at two headers in the response:
X-Cache:Hit from cloudfrontmeans the edge served it from cache.Miss from cloudfrontmeans 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 OriginShieldIf 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.jsA response that looks like this is your culprit:
Cache-Control: max-age=86400
Expires: Thu, 20 Jun 2025 12:00:00 GMTWith 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-storeormax-age=0, must-revalidate. - Versioned static assets (JS, CSS with content hashes in their filenames):
max-age=31536000, immutableis 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 QueryStringIf 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.jsBecause 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:
- Verify with curl that the stale response is coming from CloudFront (
X-Cache: Hit), not your browser or origin. - Check Origin Shield on your distribution and allow extra propagation time if it's enabled.
- Audit
Cache-Controlheaders on your origin β longmax-agevalues on HTML files are the most common offender. - Fix S3 object metadata to set correct headers at upload time, then flush with a one-time
/*invalidation. - Switch to content-hashed filenames for static assets so future deploys only need a single
/index.htmlinvalidation.
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 saveRelated Articles
Comments (0)
No comments yet. Be the first!