Introduction
CDN cache purge failed errors occur when cache invalidation requests to the Content Delivery Network are rejected, partially processed, or take longer than expected to propagate. CDN purge operations are critical for deploying fresh content, fixing broken assets, removing sensitive files, and complying with right-to-be-forgotten requests. When purges fail, users continue seeing stale content, old JavaScript/CSS causes application errors, removed images return 403 errors, and sensitive data remains publicly accessible. The fix requires understanding CDN invalidation mechanisms, API authentication, path pattern syntax, propagation delays, multi-CDN coordination, and cache-busting alternatives. This guide provides production-proven troubleshooting for CDN purge scenarios across Cloudflare, AWS CloudFront, Fastly, Akamai, and multi-CDN architectures.
Symptoms
- CDN purge API returns errors:
Invalid path pattern,Authentication failed,Rate limit exceeded - Updated files still serve old content after purge request
- Some edge locations show new content while others show stale content
- Purge requests queued for hours without completion
- Application deploy succeeds but users report seeing old version
- Removed assets still accessible via CDN URLs
- CDN purge dashboard shows
FailedorPartialstatus - Cache headers still show
X-Cache: HITfor purged content
Common Causes
- Invalid path pattern syntax (wildcards, regex not supported by all CDNs)
- API authentication credentials expired or misconfigured
- Rate limiting exceeded (too many purge requests in short time)
- Purge request for entire zone/domain requires elevated permissions
- Edge propagation delay (purge takes 5-15 minutes to reach all edges)
- Multi-CDN setup where purge sent to only one provider
- Origin server still caching, immediately repopulates CDN cache
- Cache rules excluding certain paths from purge (e.g., static assets)
- Purge API called with wrong HTTP method or endpoint
- Wildcard purge not supported on current CDN plan tier
Step-by-Step Fix
### 1. Confirm purge failure diagnosis
Check purge API response:
```bash # Cloudflare purge API curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "Authorization: Bearer API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "files": ["https://example.com/assets/style.css"] }'
# Success response: # {"success":true,"errors":[],"messages":[],"result":{"id":"purge_id"}}
# Error response (403 Forbidden): # {"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}
# Error response (400 Bad Request): # {"success":false,"errors":[{"code":10100,"message":"Invalid file pattern"}]}
# AWS CloudFront invalidation aws cloudfront create-invalidation \ --distribution-id E1234567890ABC \ --paths "/assets/style.css"
# Check invalidation status aws cloudfront get-invalidation \ --distribution-id E1234567890ABC \ --id I1234567890ABC
# Output shows status: Completed | InProgress | Failed ```
Verify cache status:
```bash # Check if content is still cached curl -I https://cdn.example.com/assets/style.css
# Look for cache headers: # x-cache: HIT # Still cached (purge not propagated) # x-cache: MISS # Cache miss (purge successful) # cf-cache-status: DYNAMIC # Cloudflare bypassing cache # x-amz-cf-id: ... # CloudFront request ID
# Check from multiple locations (propagation test) curl -I --resolve cdn.example.com:443:EDGE_IP_1 https://cdn.example.com/asset curl -I --resolve cdn.example.com:443:EDGE_IP_2 https://cdn.example.com/asset
# Check cache age curl -I https://cdn.example.com/asset | grep -i age # age: 3600 # Object cached for 1 hour (not purged) ```
### 2. Fix path pattern syntax
CDN purge path patterns have specific syntax:
```bash # Cloudflare purge patterns # Single file curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "Authorization: Bearer API_TOKEN" \ -d '{ "files": ["https://example.com/style.css"] }'
# Multiple files (up to 30 per request) curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "Authorization: Bearer API_TOKEN" \ -d '{ "files": [ "https://example.com/style.css", "https://example.com/app.js", "https://example.com/image.png" ] }'
# Pattern with wildcard (Enterprise only) curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "Authorization: Bearer API_TOKEN" \ -d '{ "tags": ["example.com/assets/*"] }'
# Purge entire domain (Enterprise only, rate limited) curl -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "Authorization: Bearer API_TOKEN" \ -d '{"purge_everything": true}'
# AWS CloudFront patterns # Single file aws cloudfront create-invalidation \ --distribution-id E1234567890ABC \ --paths "/style.css"
# Multiple files aws cloudfront create-invalidation \ --distribution-id E1234567890ABC \ --paths "/style.css" "/app.js" "/image.png"
# Wildcard pattern (any CDN) aws cloudfront create-invalidation \ --distribution-id E1234567890ABC \ --paths "/assets/*"
# All files (use carefully!) aws cloudfront create-invalidation \ --distribution-id E1234567890ABC \ --paths "/*"
# Fastly patterns # Purge by URL curl -X PURGE "https://api.fastly.com/service/SERVICE_ID/purge" \ -H "Fastly-Key: API_TOKEN" \ -H "Accept: application/json" \ -d '{"url": "https://example.com/style.css"}'
# Purge by surrogate key (Fastly feature) curl -X POST "https://api.fastly.com/service/SERVICE_ID/purge/example-key" \ -H "Fastly-Key: API_TOKEN" \ -H "Accept: application/json" ```
Path pattern guidelines:
| CDN | Single File | Wildcard | Everything | |-----|-------------|----------|------------| | Cloudflare (Free/Pro) | ✓ | ✗ | ✗ | | Cloudflare (Enterprise) | ✓ | ✓ | ✓ | | CloudFront | ✓ | ✓ | ✓ | | Fastly | ✓ | ✓ (via keys) | ✓ | | Akamai | ✓ | ✓ | ✓ |
### 3. Fix API authentication
Verify and rotate API credentials:
```bash # Cloudflare API token (recommended over API key) # Create token with Zone > Cache Purge > Purge permission # Go to: https://dash.cloudflare.com/profile/api-tokens
# Test token validity curl -X GET "https://api.cloudflare.com/v4/user/tokens/verify" \ -H "Authorization: Bearer API_TOKEN"
# Success: # {"success":true,"result":{"status":"active"}}
# Failure: # {"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}
# Store credentials securely (never in code) # Use environment variables or secret manager export CLOUDFLARE_API_TOKEN="your_token_here" export CLOUDFLARE_ZONE_ID="your_zone_id"
# CI/CD integration (GitHub Actions example) # .github/workflows/deploy.yml - name: Purge Cloudflare cache run: | curl -X POST "https://api.cloudflare.com/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \ -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \ -H "Content-Type: application/json" \ -d '{"purge_everything": true}'
# AWS CloudFront (IAM authentication) # Create IAM user with CloudFront FullAccess policy # Or create custom policy: { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "cloudfront:CreateInvalidation", "cloudfront:GetInvalidation" ], "Resource": "*" } ] }
# Configure AWS CLI aws configure set aws_access_key_id YOUR_ACCESS_KEY aws configure set aws_secret_access_key YOUR_SECRET_KEY aws configure set default.region us-east-1 # CloudFront is global
# Or use IAM role (EC2, ECS, Lambda) # No credentials needed, role is assumed automatically ```
### 4. Handle rate limiting
CDN APIs have purge rate limits:
```bash # Cloudflare rate limits # - Free/Pro/Business: 30 purge requests per 10 minutes # - Enterprise: Custom limits (higher) # - Each request can purge up to 30 individual URLs
# Check rate limit headers curl -i -X POST "https://api.cloudflare.com/v4/zones/ZONE_ID/purge_cache" \ -H "Authorization: Bearer TOKEN" \ -d '{"files":["https://example.com/1.css"]}'
# Response headers: # X-RateLimit-Limit: 30 # X-RateLimit-Remaining: 25 # X-RateLimit-Reset: 1617182400 # Unix timestamp
# Handle rate limiting with retry purge_with_retry() { local max_retries=5 local retry_count=0
while [ $retry_count -lt $max_retries ]; do response=$(curl -s -w "%{http_code}" -o /tmp/purge_response.json \ -X POST "https://api.cloudflare.com/v4/zones/$ZONE_ID/purge_cache" \ -H "Authorization: Bearer $TOKEN" \ -d "{\"files\":[\"$1\"]}")
http_code=${response: -3}
if [ "$http_code" = "200" ]; then echo "Purge successful" return 0 elif [ "$http_code" = "429" ]; then # Rate limited - wait and retry retry_count=$((retry_count + 1)) wait_time=$((2 ** retry_count)) # Exponential backoff echo "Rate limited, waiting ${wait_time}s before retry $retry_count" sleep $wait_time else echo "Purge failed with HTTP $http_code" cat /tmp/purge_response.json return 1 fi done
echo "Purge failed after $max_retries retries" return 1 }
# AWS CloudFront rate limits # - 2000 invalidation requests per day per distribution # - 15 concurrent invalidations per distribution # - 100 paths per invalidation request
# Check CloudFront API rate limit # No explicit rate limit headers, monitor via CloudWatch # Metric: Requests > 429 indicates rate limiting ```
Batch large purge operations:
```bash #!/bin/bash # Batch purge for Cloudflare (30 URLs per request)
URLS_FILE="urls_to_purge.txt" # One URL per line ZONE_ID="your_zone_id" API_TOKEN="your_token"
# Read URLs into array mapfile -t urls < "$URLS_FILE" total=${#urls[@]} batch_size=30
echo "Purging $total URLs in batches of $batch_size"
for ((i=0; i<total; i+=batch_size)); do # Get batch of URLs batch=("${urls[@]:i:batch_size}")
# Build JSON array json_urls=$(printf '"%s",' "${batch[@]}") json_urls="[${json_urls%,}]" # Remove trailing comma
# Make purge request response=$(curl -s -w "%{http_code}" -o /tmp/batch_response.json \ -X POST "https://api.cloudflare.com/v4/zones/$ZONE_ID/purge_cache" \ -H "Authorization: Bearer $API_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"files\":$json_urls}")
http_code=${response: -3}
if [ "$http_code" = "200" ]; then echo "Batch $((i/batch_size + 1)): Purged $((i + ${#batch[@]})) of $total URLs" elif [ "$http_code" = "429" ]; then echo "Rate limited, waiting 60s..." sleep 60 i=$((i - batch_size)) # Retry this batch else echo "Batch failed with HTTP $http_code" cat /tmp/batch_response.json fi
# Small delay between batches sleep 1 done
echo "Purge complete" ```
### 5. Handle propagation delays
CDN purge propagation takes time:
```bash # Cloudflare propagation # - Typical: 5-15 minutes for global propagation # - Some edges may take up to 30 minutes # - Can check propagation status
# Check if purge completed PURGE_ID="purge_id_from_api" curl -X GET "https://api.cloudflare.com/v4/zones/ZONE_ID/cache/purges/$PURGE_ID" \ -H "Authorization: Bearer TOKEN"
# Response: # {"success":true,"result":{"id":"purge_id","status":"complete"}}
# AWS CloudFront propagation # - Typical: 10-15 minutes for global propagation # - Check invalidation status
aws cloudfront get-invalidation \ --distribution-id E1234567890ABC \ --id I1234567890ABC \ --query 'Invalidation.Status'
# Output: Completed | InProgress | Failed
# Wait for propagation in CI/CD wait_for_invalidation() { local dist_id=$1 local inv_id=$2 local timeout=900 # 15 minutes local elapsed=0
echo "Waiting for CloudFront invalidation $inv_id..."
while [ $elapsed -lt $timeout ]; do status=$(aws cloudfront get-invalidation \ --distribution-id "$dist_id" \ --id "$inv_id" \ --query 'Invalidation.Status' \ --output text)
echo "Status: $status (elapsed: ${elapsed}s)"
if [ "$status" = "Completed" ]; then echo "Invalidation complete" return 0 elif [ "$status" = "Failed" ]; then echo "Invalidation failed" return 1 fi
sleep 30 elapsed=$((elapsed + 30)) done
echo "Timeout waiting for invalidation" return 1 }
# Fastly propagation # - Near-instant (sub-second for most edges) # - Soft purge available (serve stale while revalidating)
# Soft purge (serve stale during revalidation) curl -X POST "https://api.fastly.com/service/SERVICE_ID/purge/URL" \ -H "Fastly-Key: API_TOKEN" \ -H "Fastly-Soft-Purge: 1" # Serve stale for 60s while fetching new
# Hard purge (immediate invalidation) curl -X POST "https://api.fastly.com/service/SERVICE_ID/purge/URL" \ -H "Fastly-Key: API_TOKEN" \ -H "Fastly-Soft-Purge: 0" ```
### 6. Handle origin caching
Origin server may repopulate CDN cache:
```bash # Problem: CDN purged, but origin has cache headers causing immediate re-caching
# Check origin cache headers curl -I https://origin.example.com/assets/style.css
# Look for: # Cache-Control: public, max-age=31536000 # Cache for 1 year # ETag: "abc123" # Last-Modified: Mon, 01 Jan 2026 00:00:00 GMT
# If origin sends strong cache headers, CDN will immediately re-cache
# Solution 1: Set appropriate cache headers on origin # Nginx configuration for assets that need frequent updates location /assets/ { # Short cache for HTML, longer for versioned assets location ~* \.(html)$ { add_header Cache-Control "no-cache, must-revalidate"; } location ~* \.(css|js)$ { # Versioned assets can cache longer add_header Cache-Control "public, max-age=604800"; # 1 week } location ~* \.(png|jpg|gif|svg|woff2)$ { add_header Cache-Control "public, max-age=2592000"; # 30 days } }
# Solution 2: Use cache-busting URLs # Instead of purging, change the URL: # Old: /assets/style.css # New: /assets/style.v2.css or /assets/style.css?v=20260331
# In HTML templates: # <link rel="stylesheet" href="/assets/style.css?v={{ build_timestamp }}">
# Solution 3: Configure CDN to respect origin purge # Cloudflare: Cache Level > Bypass for specific paths # CloudFront: Cache behavior > Forward query strings > Yes ```
### 7. Coordinate multi-CDN purges
Multi-CDN setups require coordinated purges:
```bash #!/bin/bash # Multi-CDN purge script
PURGE_URL="https://example.com/assets/style.css"
# Purge Cloudflare purge_cloudflare() { curl -X POST "https://api.cloudflare.com/v4/zones/$CF_ZONE_ID/purge_cache" \ -H "Authorization: Bearer $CF_API_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"files\":[\"$PURGE_URL\"]}" }
# Purge CloudFront purge_cloudfront() { aws cloudfront create-invalidation \ --distribution-id "$CF_DIST_ID" \ --paths "$PURGE_URL" }
# Purge Fastly purge_fastly() { curl -X PURGE "https://api.fastly.com/service/$FASTLY_SERVICE_ID/purge" \ -H "Fastly-Key: $FASTLY_API_TOKEN" \ -H "Accept: application/json" \ -d "{\"url\":\"$PURGE_URL\"}" }
# Purge all CDNs in parallel echo "Purging all CDNs..." purge_cloudflare & purge_cloudfront & purge_fastly & wait
echo "Purge requests sent to all CDNs"
# Or purge with delay (staggered approach) echo "Purging primary CDN..." purge_cloudflare
# Wait for primary to complete sleep 300 # 5 minutes
echo "Purging secondary CDN..." purge_cloudfront ```
DNS-based CDN switching for instant "purge":
```bash # Instead of purging, switch CDN provider via DNS # Requires traffic management (Route53, Cloudflare Load Balancing)
# Scenario: Primary CDN has stale cache, need instant refresh
# Step 1: Purge secondary CDN (not serving traffic) aws cloudfront create-invalidation \ --distribution-id $SECONDARY_DIST_ID \ --paths "/*"
# Step 2: Wait for secondary purge to complete # (Use wait function from earlier)
# Step 3: Update DNS to point to secondary CDN # Using AWS Route53 aws route53 change-resource-record-sets \ --hosted-zone-id $ZONE_ID \ --change-batch '{ "Changes": [{ "Action": "UPSERT", "ResourceRecordSet": { "Name": "cdn.example.com", "Type": "CNAME", "TTL": 60, "ResourceRecords": [{"Value": "secondary-cloudfront-id.cloudfront.net"}] } }] }'
# Step 4: Monitor traffic shift # (Most users see fresh content from secondary CDN) ```
### 8. Implement cache-busting
Avoid purge needs with cache-busting URLs:
```html <!-- HTML with cache-busting query strings --> <link rel="stylesheet" href="/assets/style.css?v=20260331-123456"> <script src="/assets/app.js?v=20260331-123456"></script> <img src="/images/logo.png?v=20260331-123456">
<!-- Or use content hash (better for long-term caching) --> <link rel="stylesheet" href="/assets/style.a1b2c3d4.css"> <script src="/assets/app.e5f6g7h8.js"></script>
<!-- Webpack example with content hash --> <!-- webpack.config.js --> module.exports = { output: { filename: '[name].[contenthash].js', chunkFilename: '[name].[contenthash].chunk.js' } }
<!-- Result: New filename on every build, no purge needed -->
<!-- Nginx configuration for cache-busted assets --> location ~* \.(css|js|png|jpg|gif|svg|woff2)\.(?:[0-9a-f]+)\.(?:css|js|png|jpg|gif|svg|woff2)$ { # Assets with hash in filename can be cached indefinitely add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; }
<!-- Alternative: Query string versioning --> <!-- Works with most CDNs (query string forwarding enabled) --> <link rel="stylesheet" href="/assets/style.css?v={{ BUILD_VERSION }}"> ```
Cache-busting in CI/CD:
```yaml # GitHub Actions example name: Deploy on: push
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3
- name: Build with cache busting
- run: |
- VERSION=$(git rev-parse --short HEAD)-$(date +%Y%m%d%H%M%S)
- # Inject version into HTML
- sed -i "s/style.css/style.css?v=$VERSION/g" dist/index.html
- sed -i "s/app.js/app.js?v=$VERSION/g" dist/index.html
- name: Deploy to origin
- run: |
- aws s3 sync dist/ s3://my-bucket/
# No purge needed! New URLs = fresh content ```
### 9. Monitor purge operations
Set up purge monitoring:
```bash # Cloudflare purge monitoring with webhook # Create Cloudflare Logpush job for cache purge events
# CloudWatch alarm for CloudFront invalidations aws cloudwatch put-metric-alarm \ --alarm-name "CloudFront-Invalidation-Failed" \ --alarm-description "CloudFront invalidation failed" \ --metric-name InvalidationRequests \ --namespace AWS/CloudFront \ --statistic Sum \ --period 300 \ --threshold 0 \ --comparison-operator GreaterThanThreshold \ --evaluation-periods 1 \ --dimensions Name=DistributionId,Value=E1234567890ABC \ --alarm-actions arn:aws:sns:us-east-1:123456789012:alerts
# Custom purge monitoring script #!/bin/bash # Monitor pending invalidations
aws cloudfront list-invalidations \ --distribution-id $DIST_ID \ --query "InvalidationList.Items[?Status=='InProgress']" \ --output table
# Send alert if invalidation takes > 30 minutes INPROGRESS=$(aws cloudfront list-invalidations \ --distribution-id $DIST_ID \ --query "length(InvalidationList.Items[?Status=='InProgress'])" \ --output text)
if [ "$INPROGRESS" -gt 0 ]; then # Check oldest inprogress invalidation OLDEST=$(aws cloudfront list-invalidations \ --distribution-id $DIST_ID \ --query "InvalidationList.Items[0].CreateTime" \ --output text)
# Compare with current time, alert if > 30 min # (Implementation depends on your monitoring system) fi ```
Prometheus metrics for purge operations:
```yaml # Custom exporter for CDN purge metrics groups: - name: cdn_purges rules: - alert: CDNInvalidationSlow expr: cdn_invalidation_duration_seconds > 900 for: 10m labels: severity: warning annotations: summary: "CDN invalidation taking longer than 15 minutes" description: "Invalidation {{ $labels.invalidation_id }} on {{ $labels.cdn }} is taking {{ $value }}s"
- alert: CDNInvalidationFailed
- expr: cdn_invalidation_failures_total > 0
- for: 5m
- labels:
- severity: critical
- annotations:
- summary: "CDN invalidation failures detected"
- description: "{{ $value }} invalidation failures on {{ $labels.cdn }}"
- alert: CDNStaleContent
- expr: cdn_cache_hit_ratio < 0.5
- for: 10m
- labels:
- severity: warning
- annotations:
- summary: "CDN cache hit ratio low"
- description: "Cache hit ratio is {{ $value | humanizePercentage }} - possible stale content"
`
Prevention
- Use cache-busting URLs (content hash or version query string)
- Set appropriate origin cache headers (short TTL for frequently updated content)
- Implement purge retry logic with exponential backoff
- Batch purge requests to stay within rate limits
- Monitor purge propagation with multi-location checks
- Document purge procedures for each CDN provider
- Test purge operations in staging before production incidents
- Consider CDN-agnostic cache invalidation layer
- Use soft purge for non-critical content updates
- Implement cache warming for critical assets after purge
Related Errors
- **403 Forbidden**: API authentication failed
- **429 Too Many Requests**: Rate limit exceeded
- **400 Bad Request**: Invalid path pattern syntax
- **X-Cache: HIT**: Content still cached (purge not propagated)
- **Invalidation Failed**: Purge operation failed