CDN Purge Strategies for Fingerprinted Assets

Properly fingerprinted assets embed their content hash directly into the filename, making them immutable by definition — CDN purges for those URLs are not only unnecessary but counterproductive. The real purge surface is narrow: HTML entry points, unversioned manifests, and any legacy content that cannot carry a hash in its filename. This guide covers cache invalidation architecture across Cloudflare, Fastly, CloudFront, and Nginx, with concrete CLI and config for each provider.

CDN purge decision flow A flow diagram showing that fingerprinted assets need no purge and get long-TTL immutable caching, while mutable HTML entry points and unversioned resources go through the purge path per provider (Cloudflare, Fastly, CloudFront, Nginx). Deploy Event new build artifacts Fingerprinted filename? YES No purge needed max-age=31536000 immutable NO Purge entry points index.html, manifest Cloudflare purge URL / tag Fastly instant / surrogate key CloudFront invalidation path Nginx proxy_cache_purge
Purge decision flow: fingerprinted filenames need no CDN intervention; only mutable entry points route to provider-specific purge APIs.

Cache Fundamentals: Key, TTL, and the Immutable Directive

The cache key architecture for a CDN is usually the full request URL, including the path. When a file is renamed from main.js to main.a1b2c3d4.js, the CDN treats the two URLs as entirely different resources. The old entry ages out naturally; the new one populates from the origin on first request. This is the core mechanism that eliminates nearly every purge requirement for fingerprinted assets.

Three HTTP response headers govern cache lifetime:

  • Cache-Control: max-age=N — the number of seconds a response stays fresh. For fingerprinted files, max-age=31536000 (one year) is the practical ceiling.
  • Cache-Control: immutable — a browser-level hint that the response will never change, suppressing conditional revalidation (If-None-Match / If-Modified-Since) within max-age. Supported in all modern browsers and most CDN configurations.
  • Surrogate-Control / CDN-Cache-Control — CDN-specific headers that override Cache-Control at the edge without propagating the directive to the browser.

The ETag vs immutable cache-control reference covers the interaction between ETag conditional requests and the immutable directive in detail.

For mutable content — HTML pages, API responses, unversioned manifests — CDNs respect Cache-Control: no-cache (validate with origin on every request) or short max-age values combined with s-maxage for shared cache lifetime. These are the only resources that ever need active purging.

Provider Strategy Matrix

Provider Purge granularity Latency Cost model Fingerprinted-asset fit
Cloudflare URL, tag, prefix, everything < 5 s globally Included in plan; no per-purge charge Excellent — purge HTML by URL; hashed assets never touched
Fastly URL (instant purge), surrogate key, soft purge < 150 ms globally Included; high-volume API calls rate-limited Excellent — surrogate keys map one purge to all HTML variants
AWS CloudFront Path (exact or wildcard /*) 10–30 s First 1 000 paths/month free; $0.005 per path after Good — wildcard /*.html covers entry points; avoid /*
Nginx (ngx_cache_purge) Exact URL via PURGE HTTP method Milliseconds (local) Free module; no per-request cost Good for self-hosted; requires proxy_cache_key alignment

Cloudflare Cache Rules and Purge API

Cloudflare’s Cache Rules (the successor to Page Rules) let you set fine-grained Cache-Control overrides per URL pattern. Define a rule that matches hashed asset paths and enforces an Edge Cache TTL of one year with Cache-Control: public, max-age=31536000, immutable:

# wrangler.toml — Cloudflare Workers / Pages cache rule configuration
[[rules]]
description = "Fingerprinted JS and CSS — immutable"
expression = '(http.request.uri.path matches "^/assets/.*\\.[0-9a-f]{8,16}\\.(js|css|woff2)$")'
action = "set_cache_settings"

[rules.action_parameters.cache]
enabled = true
edge_ttl = { mode = "override_origin", default = 31536000 }
browser_ttl = { mode = "override_origin", default = 31536000 }

[rules.action_parameters.response_headers.set_headers]
"Cache-Control" = "public, max-age=31536000, immutable"

Purge only the HTML entry points after a deploy. Use the Cloudflare REST API with a Bearer token:

#!/usr/bin/env bash
# purge-cloudflare.sh — purge HTML entry points after deploy
CF_ZONE_ID="your_zone_id"
CF_TOKEN="your_api_token"

curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "files": [
      "https://example.com/",
      "https://example.com/index.html",
      "https://example.com/app.html"
    ]
  }'

For sites with hundreds of HTML routes, Cloudflare Cache Tags offer a better model: tag each HTML response with a custom Cache-Tag: html-pages header, then purge the entire group in a single API call:

curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"tags": ["html-pages"]}'

The Cache-Tag header must be present in origin responses. Set it in your application server or Cloudflare Worker:

// Cloudflare Worker — attach cache tag to HTML responses
export default {
  async fetch(request, env) {
    const response = await fetch(request);
    const contentType = response.headers.get('content-type') || '';
    if (contentType.includes('text/html')) {
      const newHeaders = new Headers(response.headers);
      newHeaders.set('Cache-Tag', 'html-pages');
      return new Response(response.body, { ...response, headers: newHeaders });
    }
    return response;
  }
};

The detailed guide to Cloudflare cache rules and purge covers tag limits, prefix purge, and the purge_by_prefix API endpoint.

Fastly: Instant Purge and Surrogate Keys

Fastly’s architecture separates the CDN-facing TTL (Surrogate-Control: max-age) from the browser-facing one (Cache-Control). This lets you cache HTML at Fastly for hours while the browser always validates. Surrogate keys (Fastly’s term for cache tags) allow atomic invalidation of all HTML variants in a single request.

Set surrogate keys in VCL or in your origin response headers:

# fastly_recv.vcl — attach surrogate key to HTML responses
sub vcl_fetch {
  if (beresp.http.Content-Type ~ "text/html") {
    set beresp.http.Surrogate-Key = "html-pages";
    set beresp.http.Surrogate-Control = "max-age=3600";
    set beresp.http.Cache-Control = "no-store";
  }
  if (req.url ~ "^/assets/.*\.[0-9a-f]{8,16}\.(js|css|woff2)") {
    set beresp.http.Surrogate-Control = "max-age=31536000";
    set beresp.http.Cache-Control = "public, max-age=31536000, immutable";
  }
  #FASTLY fetch
}

Purge by surrogate key via the Fastly API after every deploy:

#!/usr/bin/env bash
# purge-fastly.sh — purge all pages tagged html-pages
FASTLY_SERVICE_ID="your_service_id"
FASTLY_TOKEN="your_api_token"

curl -s -X POST \
  "https://api.fastly.com/service/${FASTLY_SERVICE_ID}/purge/html-pages" \
  -H "Fastly-Key: ${FASTLY_TOKEN}"

Soft purge marks objects as stale (stale-while-revalidate semantics) rather than removing them immediately. Visitors receive the old HTML while Fastly revalidates in the background:

curl -s -X POST \
  "https://api.fastly.com/service/${FASTLY_SERVICE_ID}/purge/html-pages" \
  -H "Fastly-Key: ${FASTLY_TOKEN}" \
  -H "Fastly-Soft-Purge: 1"

The guide to Fastly instant purge and surrogate keys covers multi-region propagation timing, Fiddle testing, and the stale-while-revalidate interaction with soft purge.

AWS CloudFront Invalidations

CloudFront invalidations identify paths to evict from all edge locations globally. Unlike Cloudflare or Fastly, CloudFront charges per invalidation path after the first 1,000 free paths per month per distribution. A wildcard counts as one path regardless of how many objects it matches.

#!/usr/bin/env bash
# purge-cloudfront.sh — invalidate HTML entry points
DIST_ID="E1EXAMPLE00DIST"

aws cloudfront create-invalidation \
  --distribution-id "${DIST_ID}" \
  --paths "/index.html" "/app.html" "/pricing/index.html"

Wildcard invalidation covers dynamic HTML routes without enumerating every path, but /* evicts fingerprinted assets too — avoid it. A targeted wildcard like /*.html hits only HTML files:

aws cloudfront create-invalidation \
  --distribution-id "${DIST_ID}" \
  --paths "/*.html" "/sitemap.xml"

To avoid invalidation costs entirely, set a very short s-maxage on HTML (60–300 seconds) combined with stale-while-revalidate. CloudFront will revalidate automatically using ETag or Last-Modified, making explicit purges optional for content that can tolerate a short delay.

{
  "CachePolicyConfig": {
    "Name": "html-short-ttl",
    "DefaultTTL": 60,
    "MaxTTL": 300,
    "MinTTL": 0,
    "ParametersInCacheKeyAndForwardedToOrigin": {
      "HeadersConfig": { "HeaderBehavior": "none" },
      "CookiesConfig": { "CookieBehavior": "none" },
      "QueryStringsConfig": { "QueryStringBehavior": "none" },
      "EnableAcceptEncodingGzip": true,
      "EnableAcceptEncodingBrotli": true
    }
  }
}

The CloudFront invalidation cost and wildcard limits reference documents the per-path pricing tiers and the interaction between cache policies and origin request policies.

Nginx proxy_cache_purge

The ngx_cache_purge module adds a PURGE HTTP method that evicts a cache entry by matching the proxy_cache_key. This is relevant for self-hosted Nginx instances serving as reverse proxies or as an internal edge layer in front of object storage.

Install the module and configure a purge location:

# /etc/nginx/sites-available/example.com
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=assets:64m
                 max_size=10g inactive=365d use_temp_path=off;

server {
    listen 443 ssl http2;
    server_name example.com;

    location /assets/ {
        proxy_pass         http://origin_upstream;
        proxy_cache        assets;
        proxy_cache_key    "$scheme$host$request_uri";
        proxy_cache_valid  200 365d;
        proxy_cache_use_stale error timeout updating;
        add_header         Cache-Control "public, max-age=31536000, immutable";
        add_header         X-Cache-Status $upstream_cache_status;
    }

    location / {
        proxy_pass         http://origin_upstream;
        proxy_cache        assets;
        proxy_cache_key    "$scheme$host$request_uri";
        proxy_cache_valid  200 60s;
        add_header         Cache-Control "no-cache";
        add_header         X-Cache-Status $upstream_cache_status;
    }

    location ~ /purge(/.*) {
        allow              10.0.0.0/8;
        deny               all;
        proxy_cache_purge  assets "$scheme$host$1";
    }
}

Purge an HTML path from CI after deploy:

#!/usr/bin/env bash
# purge-nginx.sh — purge HTML entry points from Nginx proxy cache
NGINX_HOST="10.0.1.5"

for path in "/index.html" "/app.html" "/pricing/index.html"; do
  curl -s -X PURGE "http://${NGINX_HOST}/purge${path}"
done

Because the proxy_cache_key includes $scheme$host$request_uri, the PURGE request must match the full key. If your origin serves both http and https, send two purge requests or normalise the scheme in the cache key.

The Nginx cache purge for fingerprinted assets guide covers proxy_cache_lock, key normalisation, and the ngx_cache_purge versus proxy_cache_bypass alternatives.

Core Patterns

Purge Only HTML Entry Points

The cleanest CDN purge strategy for a site with proper content hashing is to purge a handful of URLs on every deploy and nothing else. The canonical list is short: index.html, any other HTML entry points, manifest.json (if unversioned), service-worker.js, and robots.txt. Everything under /assets/ carries a hash in the filename and therefore never appears in a purge list.

In a GitHub Actions workflow:

name: Deploy and Purge HTML

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
      CF_TOKEN: ${{ secrets.CF_TOKEN }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - run: npm ci && npm run build

      - name: Upload fingerprinted assets (immutable headers)
        run: |
          aws s3 sync dist/assets/ s3://${{ secrets.BUCKET }}/assets/ \
            --cache-control "public, max-age=31536000, immutable" \
            --metadata-directive REPLACE

      - name: Upload HTML (short TTL)
        run: |
          aws s3 sync dist/ s3://${{ secrets.BUCKET }}/ \
            --exclude "assets/*" \
            --cache-control "public, max-age=60, stale-while-revalidate=3600" \
            --metadata-directive REPLACE

      - name: Purge Cloudflare HTML cache
        run: |
          curl -s -X POST \
            "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
            -H "Authorization: Bearer ${CF_TOKEN}" \
            -H "Content-Type: application/json" \
            --data '{"files":["https://example.com/","https://example.com/index.html"]}'

Surrogate / Cache Tag Pattern

Cache tags decouple purge operations from URL enumeration. Every HTML response includes a Cache-Tag (Cloudflare) or Surrogate-Key (Fastly) header, and a single API call evicts the entire group. This is the right model for sites with server-side rendering where the set of HTML URLs is not known at deploy time.

Define tag groups by content type, not by individual page:

Tag Content covered Purge frequency
html-pages All text/html responses Every deploy
api-responses JSON API endpoints When API changes
sitemaps sitemap.xml, sitemap-index.xml Weekly or on content change

Avoid assigning a unique tag per page — tag sprawl inflates response header size and complicates purge logic without adding practical benefit. Group by invalidation cohort, not by URL.

Atomic Deploy Pattern

An atomic deploy ensures that no user ever receives a new HTML file that references old (now non-existent) hashed assets, or vice versa. The sequence matters:

  1. Upload all fingerprinted assets first. Because filenames change with each build, old and new assets coexist safely on the CDN.
  2. Upload HTML last. At this point, all assets referenced in the new HTML already exist on origin.
  3. Purge HTML from the CDN. Edges now fetch the updated HTML, which points to the new hashed filenames.

Reversing steps 2 and 3 — purging HTML before uploading assets — creates a window where the CDN serves HTML pointing to assets that do not yet exist on origin, causing 404s.

The CI/CD asset pipeline integration guide expands on this ordering with GitHub Actions and GitLab CI examples.

Build Pipeline Integration: Manifest-Driven Selective Purge

Build tools emit a JSON asset manifest mapping logical names to hashed filenames. That manifest is the ground truth for selective purge scripts: read it, compare it to the previous deploy’s manifest, and purge only the mutable assets whose logical paths changed.

Generating the Manifest

Vite and Webpack both emit manifests automatically when configured:

// vite.config.js — enable manifest generation
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    manifest: true,
    rollupOptions: {
      output: {
        assetFileNames: 'assets/[name]-[hash:8][extname]',
        chunkFileNames: 'chunks/[name]-[hash:8].js',
        entryFileNames: 'entries/[name]-[hash:8].js'
      }
    }
  }
});

The output is .vite/manifest.json:

{
  "src/main.ts": {
    "file": "entries/main-a1b2c3d4.js",
    "css": ["assets/main-e5f67890.css"],
    "isEntry": true
  },
  "src/utils.ts": {
    "file": "chunks/utils-1234abcd.js"
  }
}

Comparing Manifests to Identify Changed Entry Points

Hash the previous and current manifests in CI to detect which HTML entry points now reference different assets:

#!/usr/bin/env bash
# compare-manifests.sh — emit a list of HTML paths to purge
# Usage: ./compare-manifests.sh previous-manifest.json current-manifest.json

PREV_MANIFEST="$1"
CURR_MANIFEST="$2"
PURGE_LIST=()

while IFS= read -r entry; do
  prev_file=$(jq -r --arg e "$entry" '.[$e].file // empty' "$PREV_MANIFEST")
  curr_file=$(jq -r --arg e "$entry" '.[$e].file // empty' "$CURR_MANIFEST")
  if [[ -n "$curr_file" && "$prev_file" != "$curr_file" ]]; then
    echo "Changed: $entry ($prev_file -> $curr_file)"
    # The HTML entry point referencing this asset must be purged
    PURGE_LIST+=("/")
  fi
done < <(jq -r 'keys[]' "$CURR_MANIFEST")

# Deduplicate and output
printf '%s\n' "${PURGE_LIST[@]}" | sort -u

Store the manifest as a build artifact in CI to enable diff-based purge decisions on subsequent deploys.

esbuild Metafile Integration

esbuild emits a metafile (not a Webpack-style manifest) when --metafile is passed. Parse it to extract the output filenames:

// scripts/purge-from-metafile.mjs
import { readFileSync } from 'fs';

const meta = JSON.parse(readFileSync('meta.json', 'utf8'));
const changedEntries = [];

for (const [outFile, info] of Object.entries(meta.outputs)) {
  if (info.entryPoint) {
    changedEntries.push({ out: outFile, entry: info.entryPoint });
  }
}

console.log(JSON.stringify(changedEntries, null, 2));

Run esbuild with the metafile flag:

npx esbuild src/main.ts \
  --bundle \
  --minify \
  --hash:8 \
  --entry-names="[dir]/[name]-[hash]" \
  --outdir=dist/assets/ \
  --metafile=meta.json

The esbuild fingerprinting plugins guide covers the full CDN integration workflow from metafile to purge.

CDN and Edge Behavior: TTL, Immutable, and Propagation

stale-while-revalidate for HTML

The stale-while-revalidate directive (defined in RFC 5861 and widely supported) permits serving a cached response past its max-age while fetching a fresh copy asynchronously:

Cache-Control: public, max-age=60, stale-while-revalidate=3600

This is the recommended header for HTML entry points. A user who hits the CDN 61 seconds after the last fetch receives the cached (slightly stale) HTML immediately, while the CDN fetches a fresh copy in the background. The next request within the stale-while-revalidate window receives the updated HTML. The stale-while-revalidate tuning guide covers window sizing and interaction with explicit purges.

Global Propagation Timing

Purge propagation time varies by provider architecture:

Provider Typical propagation Mechanism
Cloudflare 1–5 seconds Control-plane message to all PoPs
Fastly 50–200 milliseconds Distributed instant purge protocol
CloudFront 10–30 seconds Per-PoP invalidation via control plane
Nginx (single node) Immediate Local cache eviction

During propagation, some edge nodes may still serve the old HTML. Requests that land in the propagation window may fetch old HTML but the new hashed assets — which is safe because old hashed assets still exist on the origin. This is why uploading assets before purging HTML is critical.

CDN-Cache-Control vs Surrogate-Control vs Cache-Control

Use provider-specific headers to decouple the CDN TTL from what the browser caches:

Cache-Control: public, max-age=60, stale-while-revalidate=3600
CDN-Cache-Control: max-age=86400

CDN-Cache-Control is a draft standard supported by Cloudflare and Fastly. It overrides Cache-Control at the edge but is stripped before the response reaches the browser. This lets the CDN hold HTML for 24 hours while browsers still validate every minute, giving purge operations a much smaller blast radius — only the CDN needs to be told to re-fetch, not every browser.

The cache-control immutable and TTL tuning reference documents the full header precedence hierarchy per provider.

Verification: Confirming Purge Success

After every deploy and purge, verify that:

  1. Hashed asset responses carry immutable and Cache-Control: public, max-age=31536000.
  2. HTML responses carry the new content (updated references to new hashed filenames).
  3. The CDN reports a cache miss (MISS) for the freshly purged HTML, then HIT on subsequent requests.

Header Inspection per Provider

Cloudflare:

curl -s -I https://example.com/index.html | grep -i "cf-cache-status\|cache-control\|age"
# Expected after purge:
# CF-Cache-Status: MISS
# Cache-Control: public, max-age=60, stale-while-revalidate=3600
# Age: 0

Fastly:

curl -s -I https://example.com/index.html | grep -i "x-cache\|age\|surrogate-key"
# Expected after purge:
# X-Cache: MISS
# Age: 0

CloudFront:

curl -s -I https://example.com/index.html | grep -i "x-cache\|age\|via"
# Expected after invalidation:
# X-Cache: Miss from cloudfront
# Via: 1.1 abc123.cloudfront.net (CloudFront)

Nginx:

curl -s -I http://example.com/index.html | grep -i "x-cache-status"
# Expected after purge:
# X-Cache-Status: MISS

Verify that hashed assets are never purged accidentally:

curl -s -I https://example.com/assets/main-a1b2c3d4.js \
  | grep -i "cache-control\|cf-cache-status\|x-cache"
# Expected: HIT + immutable header unchanged

Confirming Asset Hash Integrity

Cross-reference the served file against the local build output:

# Fetch the asset and compare hash against build manifest
ASSET_URL="https://example.com/assets/main-a1b2c3d4.js"
LOCAL_FILE="dist/assets/main-a1b2c3d4.js"

REMOTE_HASH=$(curl -sL "$ASSET_URL" | sha256sum | awk '{print $1}')
LOCAL_HASH=$(sha256sum "$LOCAL_FILE" | awk '{print $1}')

if [[ "$REMOTE_HASH" == "$LOCAL_HASH" ]]; then
  echo "OK: hashes match"
else
  echo "MISMATCH: remote $REMOTE_HASH vs local $LOCAL_HASH"
  exit 1
fi

Add this check to your deploy pipeline as a post-deploy smoke test. The deterministic build outputs guide covers how to debug hash mismatches between local and CI builds.

Failure Modes and Gotchas

Purging Everything (Thundering Herd)

Invalidating the entire CDN cache with purge_everything: true (Cloudflare) or /* (CloudFront) evicts all fingerprinted assets simultaneously. On the next request, every asset must be fetched from origin. For high-traffic sites, this creates a thundering herd: hundreds of concurrent origin requests for files that were perfectly cacheable and unchanged. Response time spikes, origin load surges, and the incident that prompted the purge gets compounded.

Reserve purge_everything for genuine emergencies — a security breach that requires removing sensitive data from all edge nodes, or a CDN misconfiguration that cached poisoned responses. Never use it as a routine deploy step.

Cache Tag Sprawl

Assigning unique cache tags per page, per user segment, or per locale creates operational debt. Purge lists grow, header sizes bloat (Cloudflare limits Cache-Tag headers to 16KB per response, Fastly limits Surrogate-Key to 16KB as well), and the mental model of “what does this purge affect?” becomes unclear. Group tags by invalidation behaviour — content type, deployment cohort, release version — not by URL structure.

CloudFront Invalidation Cost Blowups

A wildcard invalidation (/*.html) counts as one path against the 1,000 free monthly paths, but an enumerated list of 500 HTML paths counts as 500 paths. Teams that switch from wildcard to per-path invalidation (often to be more precise) can accidentally generate thousands of billable invalidation paths per month on high-deployment schedules.

Mitigate by using short max-age on HTML (30–120 seconds) and accepting the brief stale window, relying on CloudFront’s ETag-based conditional revalidation rather than explicit invalidations. Only invoke create-invalidation for immediate correctness requirements such as legal content removals or security patches.

Rollback Edge Cases

Rolling back a deploy does not automatically restore the CDN’s view of the world. If the rollback restores old HTML that references hashed filenames from a previous build, those filenames must still exist on the origin. Most S3-backed deployments retain old assets indefinitely, so this is safe — but pipelines that run aws s3 sync --delete remove files no longer in the current build, potentially deleting assets that rollback HTML still references.

Disable --delete on the assets prefix, or use an explicit keep-list derived from the previous two build manifests. The cache key rollback reference covers the full rollback sequence including manifest recovery.

Immutable Header on Mutable Paths

Serving Cache-Control: public, max-age=31536000, immutable on any URL that is reused across deploys — index.html, /app, /api/config.json — makes that content effectively permanent in browsers that honour immutable. Subsequent deploys update the origin, the CDN may purge its edge copy, but browsers that already cached the response under immutable will not revalidate for a full year. Always restrict immutable to fingerprinted URLs. Apply no-cache or short max-age with must-revalidate to entry points.

Propagation Race During Rollout

In a blue-green or canary deployment where traffic shifts gradually, some requests may hit the old origin (serving old HTML with old asset hashes) while others hit the new origin (serving new HTML with new hashes). Both sets of asset hashes must be present on the CDN or reachable from origin simultaneously. This is another argument for never deleting old hashed assets until at least two deploys after they were retired.

Pre-Deploy Checklist

  • [name]-[hash:8].[ext] (or [hash:12][hash:16] for monorepos with thousands of chunks).
  • Cache-Control: public, max-age=31536000, immutable is set on all /assets/* paths, verified with curl -I.
  • Cache-Control: public, max-age=60, stale-while-revalidate=3600 (or provider-equivalent short TTL).
  • /assets/.
  • --delete flag (or equivalent) is NOT applied to the fingerprinted assets prefix in S3 or GCS sync.
  • CF-Cache-Status: MISS (or provider equivalent) for purged HTML, then HIT on second request.
  • HIT and immutable header unchanged.

Frequently Asked Questions

Do I need to purge CDN cache after deploying fingerprinted JS and CSS?

No. Fingerprinted filenames are unique per build because the hash encodes the file’s content. When the content changes, the filename changes, and the CDN treats the new URL as a brand new resource. There is nothing to purge — the old URL naturally ages out while the new URL populates from origin on first request.

What is the minimum set of URLs I should purge on every deploy?

Purge only the HTML entry points and any other unversioned resources: index.html, any additional HTML pages your build produces, manifest.json (if you expose it unversioned), service-worker.js, and robots.txt. Everything under a hashed assets directory requires no purge. If you use cache tags, a single tag group covers all HTML variants.

How do surrogate keys differ from Cloudflare Cache Tags?

They are the same concept with different header names. Fastly calls the response header Surrogate-Key; Cloudflare calls it Cache-Tag. Both associate a response with one or more labels, and the corresponding purge API accepts those labels to evict all matching entries simultaneously. Fastly processes the purge in under 200 milliseconds; Cloudflare typically takes 1–5 seconds. Both providers strip the tagging header before forwarding the response to the browser.

When should I use CloudFront invalidations versus short TTLs?

Use explicit invalidations when correctness is immediate — security patches, legal content removals, or A/B test configuration changes. For routine deploys where a 30–120 second stale window is acceptable, set a short max-age on HTML and rely on CloudFront’s ETag-based conditional revalidation. The latter avoids per-path charges entirely and reduces operational complexity. Reserve wildcard invalidations (/*.html) to avoid per-path billing on sites with many routes.

Can I use stale-while-revalidate with explicit purge?

Yes, and it is the recommended combination for HTML. Set Cache-Control: public, max-age=60, stale-while-revalidate=3600. On deploy, purge the HTML. Edges that receive the purge signal immediately revalidate. Edges that do not receive it before the next request serve the stale copy (at most 60 seconds old, then up to 3600 seconds stale) while fetching fresh content. The purge ensures most users see the new HTML within seconds; the stale-while-revalidate window ensures no user gets a hard wait for a cold miss during propagation.

What happens to browser caches when I purge a CDN edge?

Nothing. CDN purge APIs evict entries from the CDN’s shared cache only. Browsers that already fetched and cached an HTML response under a positive max-age continue to serve from their local cache until that max-age expires. This is why HTML should never carry long max-age values or the immutable directive. Keep HTML max-age at 60 seconds or below to bound the browser-side stale window independently of the CDN purge.