Introduction
CDN cache invalidation and purge errors occur when content delivery networks continue serving stale content after deployments, configuration changes fail to propagate, or purge API requests fail silently. CDNs cache static and dynamic content at edge locations globally, reducing latency for end users. When origin content changes, the cached version must be invalidated (purged) or allowed to expire naturally via TTL. Common causes include purge API authentication failures, rate limiting on purge endpoints, cache hierarchy complexity (edge > regional > origin), incorrect Cache-Control headers from origin, wildcard purge pattern errors, propagation delays across edge locations, soft purge not removing stale content, origin shield caching blocking updates, and DNS TTL delays for CDN endpoint changes. The fix requires understanding CDN cache architecture, purge API mechanisms, cache header configuration, and deployment workflows. This guide provides production-proven techniques for cache invalidation across Cloudflare, AWS CloudFront, Fastly, Akamai, and generic CDN implementations.
Symptoms
- Users see stale content after deployment (old CSS, JS, images)
403 Forbiddenor401 Unauthorizedfrom purge APIRate limit exceededwhen purging multiple URLs- Cache purge returns success but content still stale
- Some edge locations show new content, others show old
Cache-Controlheaders ignored by CDN- Purge operation takes hours to complete globally
- Dynamic content not updating despite origin changes
- CDN returns
X-Cache: Hitfor content that should be fresh - Versioned assets (app.v1.js) serving old version after deploy
Common Causes
- Purge API credentials expired or misconfigured
- Rate limiting: too many purge requests in short time
- Wildcard purge patterns not matching intended URLs
- Origin sending
Cache-Control: max-agewith long TTL - Multiple CDN layers (CDN > WAF > Origin cache)
- Soft purge marking stale but not removing content
- Edge location propagation delays (15 minutes to 24 hours)
- DNS caching old CDN endpoint IPs
- Origin shield or parent cache not invalidated
- Query string parameters affecting cache keys
Step-by-Step Fix
### 1. Diagnose cache staleness
Verify what's being served and from where:
```bash # Check CDN cache status curl -I https://cdn.example.com/static/app.js
# Key headers: # X-Cache: Hit from cloudfront (or Miss) # X-Cache-Hits: 5 (number of hits) # Age: 3600 (seconds since cached) # Cache-Control: public, max-age=31536000 # ETag: "abc123" # Last-Modified: Mon, 01 Jan 2026 00:00:00 GMT
# Compare edge vs origin curl -H "Cache-Control: no-cache" -I https://cdn.example.com/static/app.js # Forces origin fetch
# Check from different locations # Using curl with different edge endpoints curl -H "Host: cdn.example.com" https://edge1.cdn.net/static/app.js curl -H "Host: cdn.example.com" https://edge2.cdn.net/static/app.js
# Verify origin has new content curl -I https://origin.example.com/static/app.js # Should show new ETag/Last-Modified ```
Cache debugging headers:
```bash # Cloudflare-specific curl -I https://cdn.example.com/file.js # CF-Cache-Status: HIT (or MISS, EXPIRED, REVALIDATED, UPDATING, DYNAMIC) # CF-RAY: unique request ID for debugging
# CloudFront-specific curl -I https://d123.cloudfront.net/file.js # X-Cache: Hit from cloudfront # X-Cache-Hits: 3 # X-Amz-Cf-Pop: edge location code # X-Amz-Cf-Id: unique request ID
# Fastly-specific curl -I https://cdn.example.com/file.js # X-Cache: HIT # X-Cache-Hits: 5 # X-Served-By: cache_lon12345-LHR # X-Timer: S1234567890,VS0,VE2
# Akamai-specific curl -I https://cdn.example.com/file.js # X-Cache: TCP_HIT from akamai # X-True-Client-IP: original client IP ```
### 2. Purge cache via API
Cloudflare purge API:
```bash # Purge specific URLs (up to 30 per request) curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "X-Auth-Email: user@example.com" \ -H "X-Auth-Key: API_KEY" \ -H "Content-Type: application/json" \ --data '{ "files": [ "https://example.com/static/app.js", "https://example.com/static/style.css" ] }'
# Purge everything (use carefully!) curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "X-Auth-Email: user@example.com" \ -H "X-Auth-Key: API_KEY" \ -H "Content-Type: application/json" \ --data '{"purge_everything": true}'
# Purge by prefix (Enterprise) curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "X-Auth-Email: user@example.com" \ -H "X-Auth-Key: API_KEY" \ -H "Content-Type: application/json" \ --data '{ "prefixes": [ "https://example.com/static/", "https://example.com/images/products/" ] }'
# Purge by tag (Enterprise) curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "X-Auth-Email: user@example.com" \ -H "X-Auth-Key: API_KEY" \ -H "Content-Type: application/json" \ --data '{ "tags": ["product-images", "user-avatars"] }'
# Response # {"success":true,"result":{"id":"purge_123"}} ```
CloudFront invalidation:
```bash # AWS CLI - create invalidation aws cloudfront create-invalidation \ --distribution-id E1234567890ABC \ --paths "/static/app.js" "/static/style.css"
# Wildcard invalidation (careful - counts as 1 path but purges all) aws cloudfront create-invalidation \ --distribution-id E1234567890ABC \ --paths "/*"
# Check invalidation status aws cloudfront get-invalidation \ --distribution-id E1234567890ABC \ --id I1234567890ABC
# Output: # "Status": "InProgress" (or "Completed") # "InvalidationBatch": { # "Paths": { # "Items": ["/static/app.js"], # "Quantity": 1 # } # }
# Wait for completion aws cloudfront wait invalidation-completed \ --distribution-id E1234567890ABC \ --id I1234567890ABC ```
Fastly soft purge vs hard purge:
```bash # Soft purge (marks stale, serves while revalidating) curl -X PURGE "https://api.fastly.com/service/SERVICE_ID/purge/app.js" \ -H "Fastly-Key: FASTLY_API_TOKEN" \ -H "Accept: application/json"
# Response: {"status":"ok"} # Content served from cache while Fastly fetches new version in background
# Hard purge (removes from cache immediately) curl -X PURGE "https://api.fastly.com/service/SERVICE_ID/purge/app.js" \ -H "Fastly-Key: FASTLY_API_TOKEN" \ -H "Fastly-Soft-Purge: 0" \ -H "Accept: application/json"
# Response: {"status":"ok"} # Next request triggers origin fetch
# Purge all (entire service) curl -X PURGE "https://api.fastly.com/service/SERVICE_ID/purge_all" \ -H "Fastly-Key: FASTLY_API_TOKEN"
# Purge by surrogate key curl -X PURGE "https://api.fastly.com/service/SERVICE_ID/purge/product-123" \ -H "Fastly-Key: FASTLY_API_TOKEN" ```
Akamai purge API:
```bash # Fast purge (CCU - Cache Control Utility) curl -X POST "https://akamaiapis.net/ccu/v3/invalidate/url/NETWORK" \ -H "Authorization: EG-edgegrid-auth-header" \ -H "Content-Type: application/json" \ --data '{ "objects": [ "https://example.com/static/app.js" ] }'
# NETWORK: production, staging, or development
# Check purge status curl -X GET "https://akamaiapis.net/ccu/v3/status/ESTIMATE_ID" \ -H "Authorization: EG-edgegrid-auth-header" ```
### 3. Handle rate limiting
CDN APIs have purge rate limits:
```bash # Cloudflare rate limits: # - 30 URLs per purge request # - 3 purge requests per zone per minute # - Use batches for large purges
# Batch purge script #!/bin/bash
URLS_FILE="urls-to-purge.txt" BATCH_SIZE=30 ZONE_ID="your-zone-id" AUTH_EMAIL="user@example.com" AUTH_KEY="API_KEY"
# Read URLs into array mapfile -t urls < "$URLS_FILE" total=${#urls[@]}
for ((i=0; i<total; i+=BATCH_SIZE)); do # Extract batch batch=("${urls[@]:i:BATCH_SIZE}")
# Build JSON json_files=$(printf '"%s",\n' "${batch[@]}") json_files="[${json_files%,}]"
# Send purge request response=$(curl -s -X POST \ "https://api.cloudflare.com/v4/zones/$ZONE_ID/purge_cache" \ -H "X-Auth-Email: $AUTH_EMAIL" \ -H "X-Auth-Key: $AUTH_KEY" \ -H "Content-Type: application/json" \ --data "{\"files\": $json_files}")
echo "Purged $((i+BATCH_SIZE)) of $total URLs"
# Rate limit: wait between batches if [ $((i + BATCH_SIZE)) -lt $total ]; then sleep 65 # Wait for rate limit reset fi done ```
CloudFront rate limits:
```bash # CloudFront limits: # - 1000 invalidation paths per distribution per second # - 15% of distribution's requests per second for invalidations # - Wildcard (/*) counts as 1 path but purges everything
# For large invalidations, use versioning instead # /static/v1.2.3/app.js rather than purging /static/app.js
# Staggered invalidation script #!/bin/bash
DISTRIBUTION_ID="E1234567890ABC" PATHS_FILE="paths-to-invalidate.txt"
# Read paths mapfile -t paths < "$PATHS_FILE"
# Create invalidation every 60 seconds for path in "${paths[@]}"; do aws cloudfront create-invalidation \ --distribution-id "$DISTRIBUTION_ID" \ --paths "$path" \ --query 'Invalidation.Id' \ --output text
sleep 60 # Avoid rate limiting done ```
### 4. Configure proper cache headers
Origin headers control CDN caching:
```nginx # Nginx - set Cache-Control headers
# Static assets (versioned, cache forever) location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; add_header Vary "Accept-Encoding";
# Versioned files: /static/v1.2.3/app.js # Never need purge - new version = new URL }
# HTML files (never cache or short cache) location ~* \.html$ { expires -1; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; }
# Dynamic content (API responses) location /api/ { add_header Cache-Control "private, no-cache, no-store, must-revalidate"; add_header Vary "Authorization, Content-Type"; }
# Images that change infrequently location /images/ { expires 30d; add_header Cache-Control "public, max-age=2592000"; add_header Vary "Accept-Encoding"; }
# ETag for validation etag on; ```
Apache configuration:
```apache # .htaccess or httpd.conf
# Static assets with long cache <FilesMatch "\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$"> Header set Cache-Control "public, max-age=31536000, immutable" Header set Vary "Accept-Encoding" </FilesMatch>
# HTML files <FilesMatch "\.(html|htm)$"> Header set Cache-Control "no-cache, no-store, must-revalidate" Header set Pragma "no-cache" Header set Expires 0 </FilesMatch>
# Dynamic content <LocationMatch "/api/"> Header set Cache-Control "private, no-cache, no-store" Header set Vary "Authorization, Content-Type" </LocationMatch> ```
Application headers (Node.js/Express):
```javascript // Static assets middleware app.use('/static', express.static('public', { maxAge: '1y', immutable: true, setHeaders: (res, path) => { // Add version to ETag res.setHeader('X-Content-Version', '1.2.3'); } }));
// API response caching app.get('/api/data', (req, res) => { // Cache for authenticated user if (req.user) { res.set('Cache-Control', 'private, max-age=60'); res.set('Vary', 'Authorization'); } else { res.set('Cache-Control', 'public, max-age=300'); }
res.json(data); });
// Force no-cache for sensitive data app.get('/api/account', (req, res) => { res.set('Cache-Control', 'no-store, private, must-revalidate'); res.json(accountData); }); ```
### 5. Implement cache busting strategies
URL versioning (recommended):
```html <!-- HTML with versioned assets --> <link rel="stylesheet" href="/static/v1.2.3/style.css"> <script src="/static/v1.2.3/app.js"></script> <img src="/images/products/v456/product-1.jpg">
<!-- Build process generates new URLs on each deploy --> <!-- No purge needed - old versions remain cached, new versions are new URLs --> ```
Build tool integration:
```javascript // Webpack configuration module.exports = { output: { filename: '[name].[contenthash].js', chunkFilename: '[id].[contenthash].js', path: path.resolve(__dirname, 'dist') }, plugins: [ new HtmlWebpackPlugin({ filename: 'index.html', template: 'src/index.html' // Automatically updates HTML with hashed filenames }) ] };
// Output: app.a1b2c3d4.js, style.e5f6g7h8.css // Each build produces new hashes - no purge needed ```
Query string versioning:
```html <!-- Simpler but less effective (some CDNs don't cache query strings by default) --> <link rel="stylesheet" href="/static/style.css?v=1.2.3"> <script src="/static/app.js?v=1.2.3"></script>
<!-- CloudFront: configure query string forwarding --> <!-- Distribution Settings > Cache Behavior > Forward Query Strings: Yes --> ```
### 6. Handle multi-layer caching
CDN often sits behind other caches:
User > Browser Cache > CDN Edge > CDN Regional > Origin Shield > Origin Cache
Clear entire chain:
```bash # 1. Clear browser cache (user side - use versioned URLs)
# 2. Purge CDN edge (as shown above)
# 3. Clear origin cache (if using reverse proxy) # Nginx proxy cache curl -X PURGE "http://localhost/static/app.js" # Or clear all rm -rf /var/cache/nginx/proxy_cache/*
# Varnish cache varnishadm "ban req.url ~ /static/app.js" # Or by tag varnishadm "ban obj.http.x-version ~ old-version"
# Redis cache (application level) redis-cli KEYS "cache:static:*" | xargs redis-cli DEL
# 4. Verify origin has fresh content curl -I http://origin.example.com/static/app.js ```
CloudFront with origin shield:
```bash # Origin Shield adds extra caching layer # Must invalidate both edge and shield
# Disable Origin Shield temporarily for big updates aws cloudfront update-distribution \ --id E1234567890ABC \ --distribution-config file://dist-config.json
# In dist-config.json, set: # "OriginShield": { "Enabled": false }
# Or purge origin shield specifically (if supported) ```
### 7. Debug purge API failures
Common API errors:
```bash # 401 Unauthorized - Check credentials curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "X-Auth-Email: wrong@email.com" \ -H "X-Auth-Key: wrong-key" \ --data '{"purge_everything": true}'
# Response: # {"success":false,"errors":[{"code":9103,"message":"X-Auth-Email or X-Auth-Key invalid"}]}
# Fix: Verify API key in Cloudflare dashboard # Profile > API Tokens > Global API Key
# 403 Forbidden - Insufficient permissions # Response: # {"success":false,"errors":[{"code":9109,"message":"Token does not have permission to purge cache"}]}
# Fix: Use API Token with Zone > Cache Purge > Edit permission # Profile > API Tokens > Create Token > Custom Token
# 429 Too Many Requests - Rate limited # Response: # {"success":false,"errors":[{"code":9107,"message":"Rate limit exceeded"}]}
# Fix: Implement exponential backoff
# 400 Bad Request - Invalid payload curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "Content-Type: application/json" \ --data '{"files": "not-an-array"}'
# Response: # {"success":false,"errors":[{"code":9111,"message":"Invalid files parameter"}]}
# Fix: Ensure files is array of valid URLs ```
Retry logic with backoff:
```python import requests import time from typing import List
def purge_cloudflare_urls(urls: List[str], zone_id: str, auth_email: str, auth_key: str, max_retries: int = 3): """Purge URLs from Cloudflare with rate limit handling."""
batch_size = 30 base_delay = 65 # seconds
for i in range(0, len(urls), batch_size): batch = urls[i:i + batch_size]
for attempt in range(max_retries): response = requests.post( f"https://api.cloudflare.com/v4/zones/{zone_id}/purge_cache", headers={ "X-Auth-Email": auth_email, "X-Auth-Key": auth_key, "Content-Type": "application/json" }, json={"files": batch} )
if response.status_code == 200: print(f"Successfully purged batch {i//batch_size + 1}") break elif response.status_code == 429: # Rate limited - wait and retry delay = base_delay * (2 ** attempt) # Exponential backoff print(f"Rate limited, waiting {delay}s before retry") time.sleep(delay) else: # Other error - log and continue print(f"Purge failed: {response.json()}") break else: print(f"Max retries exceeded for batch {i//batch_size + 1}") ```
### 8. Monitor cache hit rates and freshness
Cache analytics:
```bash # Cloudflare Analytics API curl -X GET "https://api.cloudflare.com/v4/zones/ZONE_ID/analytics/dashboard" \ -H "X-Auth-Email: user@example.com" \ -H "X-Auth-Key: API_KEY"
# Key metrics: # - cached_bytes: bytes served from cache # - uncached_bytes: bytes fetched from origin # - cache hit ratio = cached / (cached + uncached)
# CloudFront metrics via CloudWatch aws cloudwatch get-metric-statistics \ --namespace AWS/CloudFront \ --metric-name Requests \ --dimensions Name=DistributionId,Value=E1234567890ABC \ --start-time 2026-03-31T00:00:00Z \ --end-time 2026-03-31T23:59:59Z \ --period 3600 \ --statistics Sum
# Also check: # - 4xxErrorRate, 5xxErrorRate # - BytesDownloaded # - TotalErrorRate ```
Grafana dashboard for cache monitoring:
```yaml # Prometheus datasource for CDN metrics # Cloudflare Logpush to Prometheus adapter
grafana_dashboard: panels: - title: "Cache Hit Rate" targets: - expr: sum(cloudflare_cache_hit) / sum(cloudflare_requests) * 100
- title: "Cache Purge Operations"
- targets:
- - expr: rate(cloudflare_purge_requests[5m])
- title: "Origin Requests"
- targets:
- - expr: sum(cloudflare_cache_miss)
- title: "Bandwidth Saved"
- targets:
- - expr: sum(cloudflare_cached_bytes)
# Alerting rules groups: - name: cdn_cache rules: - alert: LowCacheHitRate expr: sum(cloudflare_cache_hit) / sum(cloudflare_requests) < 0.8 for: 30m labels: severity: warning annotations: summary: "CDN cache hit rate below 80%"
- alert: StaleContentDetected
- expr: cloudflare_cache_age > 86400
- for: 1h
- labels:
- severity: info
- annotations:
- summary: "Content older than 24 hours being served"
`
Prevention
- Use versioned asset URLs (contenthash) to avoid purge needs
- Set appropriate Cache-Control headers based on content type
- Implement automated purge in CI/CD deployment pipeline
- Monitor cache hit rates and set up alerts for anomalies
- Use soft purge for frequently updated dynamic content
- Test purge operations in staging environment first
- Document purge procedures and API credentials management
- Consider cache key customization for complex invalidation scenarios
Related Errors
- **403 Forbidden**: Purge API authentication or permission failure
- **429 Too Many Requests**: Rate limit exceeded on purge endpoint
- **504 Gateway Timeout**: Purge request timeout
- **X-Cache: Hit for stale content**: Purge didn't propagate to all edges
- **Cache-Control ignored**: Origin shield or intermediate cache interfering