LEARN · DEBUGGING GUIDE

AWS CloudFront Serving Stale Content – Debugging Cache Invalidation Failures

Stale content in CloudFront almost always means your invalidation never propagated or your TTLs are longer than you think. Here's how to prove which one and fix it in minutes.

IntermediateCloud7 min read

What this usually means

CloudFront edge caches respect the Cache-Control or Expires headers from your origin, or its own default TTL (24h). When you run an invalidation, CloudFront marks the cached object as stale across all edges, but the invalidation is asynchronous and can take several minutes to fully propagate. However, the most common cause of persistent stale content is that the invalidation path didn't match the actual request, or the origin itself is still returning the old object (e.g., because the origin has its own caching or the S3 object key hasn't updated). Another frequent culprit is using wildcard invalidation (*) which can exceed the 3000-object-per-invalidation limit silently, causing partial invalidation. Also, if you set Minimum TTL, Maximum TTL, and Default TTL incorrectly, CloudFront may ignore Cache-Control headers and cache longer than intended.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Check CloudFront invalidation status: aws cloudfront list-invalidations --distribution-id YOUR_ID --max-items 5 | grep Status. If status is 'Completed', invalidation propagated to all edges.
  • 2Verify the exact URL that's stale: curl -I -H "Cache-Control: no-cache" https://d123.cloudfront.net/path/to/file. Look at x-cache header: should be Miss from cloudfront if invalidation worked.
  • 3Compare CloudFront logs with S3 logs: download CloudFront access logs from S3, grep for the stale path, check x-edge-result-type column. Look for Hit that shouldn't be there.
  • 4Check origin response headers: curl -I https://origin.example.com/path/to/file. Look for Cache-Control: max-age, s-maxage, and Expires. CloudFront will cache for the longest of these values (bounded by minimum/maximum TTL).
  • 5Test with a unique query string: curl -I "https://d123.cloudfront.net/path/to/file?timestamp=$(date +%s)". If new content appears, it's a cache hit on the original URL.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchCloudFront Distribution ID: invalidation list and details (AWS Console or CLI)
  • searchCloudFront access logs: S3 bucket configured under distribution settings
  • searchOrigin response headers: Cache-Control, Expires, and Last-Modified from curl to origin
  • searchS3 object metadata: check ETag and LastModified for the file
  • searchCloudFront behavior settings: Minimum TTL, Maximum TTL, Default TTL (Console > Behaviors tab)
  • searchAWS CloudTrail: look for Invalidation events to see who and what was invalidated
  • searchBrowser DevTools Network tab: verify the request URL and response headers (x-cache, via, age)
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningInvalidation path doesn't match the exact URL (trailing slash, extra characters, case sensitivity)
  • warningWildcard invalidation (*) hits the 3000-object limit and silently fails for the rest
  • warningOrigin Cache-Control headers have max-age longer than expected; CloudFront respects them
  • warningMinimum TTL set too high (e.g., 86400) overrides Cache-Control: no-cache
  • warningMultiple origins behind same distribution with different cache behaviors
  • warningBrowser or CDN intermediary caching (not CloudFront) – user's local cache
  • warningInvalidation completed but origin itself still returns old content (e.g., S3 object not updated)
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildUse exact invalidation path: /images/logo.png instead of /images/* if you only changed one file
  • buildIf invalidating many files, split into multiple invalidation requests each under 3000 objects
  • buildSet Minimum TTL to 0 and Default TTL to a low value (e.g., 60 seconds) during deployments, then revert
  • buildAdd unique query string to asset URLs (e.g., ?v=buildnumber) to bypass cache entirely
  • buildFor S3 origin, enable 'Use Origin Cache Headers' and set Cache-Control: public, max-age=0, must-revalidate on the S3 object
  • buildUse CloudFront Functions or Lambda@Edge to modify Cache-Control headers dynamically
  • buildIncrease invalidation parallelism: batch invalidations by path prefix, not a single wildcard
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter fix, curl the exact URL and check x-cache: should be Miss from cloudfront
  • verifiedCheck CloudFront invalidation status: Status should be 'Completed' and no errors
  • verifiedTest from multiple geographic locations using a global checker (e.g., check-host.net)
  • verifiedMonitor CloudFront logs for the path: x-edge-result-type should be Miss for the first request
  • verifiedSimulate the exact user flow: open incognito window, navigate to the page, verify new content
  • verifiedSet up a synthetic check that compares content hash from origin vs edge every minute
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningInvalidating * when you only changed a few files – wastes time and hits limits
  • warningAssuming invalidation is instant; wait up to 5 minutes for full propagation
  • warningForgetting to clear your own browser cache or proxy cache
  • warningSetting Minimum TTL to a high value thinking it helps performance – it blocks invalidation
  • warningMisconfiguring multiple origins: each origin behavior has its own TTL settings
  • warningNot verifying the origin itself returns the new content before invalidating
( 07 )War story

The Case of the Stale Product Page

DevOps EngineerAWS CloudFront + S3 + React SPA

Timeline

  1. 10:00Deployed new React build to S3 bucket (myapp-prod)
  2. 10:02Created CloudFront invalidation for /index.html and /static/*
  3. 10:05Checked invalidation status: Completed
  4. 10:10User reports old product page still shows
  5. 10:12curl -I https://d123.cloudfront.net/index.html -> x-cache: Hit from cloudfront
  6. 10:15Checked S3 object: index.html had old LastModified (from last week)
  7. 10:18Realized build script didn't upload new index.html due to permission error
  8. 10:20Re-ran deployment and invalidated again
  9. 10:25curl shows x-cache: Miss from cloudfront, content updated

At 10 AM I deployed a new React build to our S3 bucket. The build script ran successfully and uploaded static assets, but I noticed a permission error for index.html. I thought it was a warning and ignored it. I then created a CloudFront invalidation for /index.html and /static/*. The invalidation completed almost immediately, which should have been a red flag – it usually takes a minute or two.

At 10:10 a user reported the old product page was still loading. I checked the URL and saw x-cache: Hit. That meant the invalidation didn't actually clear the cache. I checked the S3 object and saw the old index.html (LastModified from last week). The static assets were new, but the entry point was old. The invalidation had indeed completed, but it invalidated the old object from CloudFront, so when CloudFront fetched from origin, it got the same old object again.

I re-ran the deployment to upload the correct index.html, then issued a new invalidation. This time I waited and verified with curl: x-cache: Miss. The user confirmed the page was updated. The root cause was a silent failure in the build script that didn't upload the new file. I added a check in CI to verify file hashes after upload.

Root cause

Build script failed to upload new index.html to S3 due to permission error; invalidation of old object caused CloudFront to re-fetch the same old object.

The fix

Fixed build script permissions, re-uploaded, and re-invalidated. Added post-deployment health check that compares ETags.

The lesson

Always verify the origin content before invalidating. A 'Completed' invalidation only means CloudFront cleared its cache, not that the origin has new data.

( 08 )How CloudFront Cache Invalidation Actually Works

When you create an invalidation, CloudFront sends a request to each edge location to remove the specified objects from cache. The invalidation is asynchronous: the API returns immediately with a status of 'InProgress'. It takes 1-5 minutes to fully propagate to all edges. During propagation, some edges may still serve the old content. The status changes to 'Completed' once all edges have confirmed removal.

Crucially, an invalidation does not force CloudFront to fetch new content from the origin. When the next request arrives for the invalidated path, CloudFront forwards it to the origin, and the origin's response (including headers) determines how the new object is cached. If the origin returns the same old content (e.g., because S3 has stale object), CloudFront caches that stale content again. Always verify the origin first.

( 09 )TTL Configuration Pitfalls

CloudFront has three TTL settings per cache behavior: Minimum TTL, Default TTL, and Maximum TTL. The actual cache duration is determined by the origin's Cache-Control max-age (or s-maxage) header, but clamped between Minimum and Maximum TTL. If Minimum TTL is set to 86400 (24 hours) and your origin returns max-age=0, CloudFront will still cache for 24 hours.

A common misconfiguration is setting Minimum TTL too high for 'performance' reasons, which effectively disables invalidation for that period. During deployments, best practice is to temporarily set Minimum TTL to 0, Default TTL to a low value (e.g., 60), then revert after all edges have the new content. Alternatively, use versioned file names to bypass cache.

( 10 )Wildcard Invalidation Limits and Silent Failures

Each invalidation can include up to 3,000 object paths (or 15 wildcards). If you use a wildcard like /images/* that matches more than 3,000 objects, CloudFront will only invalidate the first 3,000 and silently ignore the rest. You won't get an error; the invalidation status shows 'Completed' but some objects remain cached.

To avoid this, either split invalidations into batches of 3,000 objects, or use more specific paths. For assets named with hashes (e.g., main.abc123.js), you can invalidate the exact path. For large directories, consider using versioned prefixes (e.g., /v2/images/) instead of wildcard invalidation.

( 11 )Using Query Strings for Cache Busting

CloudFront can be configured to forward or not forward query strings to the origin. If you set the behavior to 'Forward all, cache based on all', then a unique query string like ?v=1 creates a new cache entry. This is a reliable way to bypass stale content without invalidation.

However, be careful: if you forward query strings but don't include them in the cache key (option: 'Forward all, cache based on whitelist'), then query strings are ignored for caching, and adding ?v=1 won't help. Also, many static file servers (including S3) ignore query strings, so they still return the same object. This method works best when combined with versioned filenames.

Frequently asked questions

How long does a CloudFront invalidation take to complete?

Typically 1-5 minutes for propagation to all edge locations. Larger invalidations (thousands of paths) may take longer. You can check the status via CLI: aws cloudfront get-invalidation --distribution-id YOUR_ID --id INVALIDATION_ID. Status 'Completed' means all edges have processed the invalidation.

Why does my invalidation complete but old content still shows?

Most likely the origin itself is serving old content. After invalidation, CloudFront forwards the next request to the origin. If the origin returns the same old object (e.g., S3 object not updated, or intermediate cache), CloudFront caches it again. Also check that the invalidation path exactly matches the request URL (case-sensitive, encoding).

Can I invalidate objects by prefix or wildcard?

Yes, you can use a wildcard at the end of a path, e.g., /images/*. But be aware of the 3,000 object limit per invalidation. If the wildcard matches more than 3,000 objects, only the first 3,000 are invalidated. For large directories, use multiple invalidations or versioned paths.

What is the difference between Minimum TTL and Default TTL?

Default TTL is used when the origin does not provide Cache-Control or Expires headers. Minimum TTL is the minimum time CloudFront will cache an object, regardless of origin headers. If your origin sends max-age=0 but Minimum TTL is 3600, CloudFront caches for 3600 seconds. Set Minimum TTL to 0 during deployments to allow immediate updates.

Does CloudFront charge for cache invalidations?

Yes, the first 1,000 paths per month are free, then $0.005 per path after that. Wildcards count as one path. For high-volume deployments, consider using versioned filenames or query strings to avoid invalidation costs.