Implementing Cache Keys with Query Parameters vs Filenames

The choice between query-parameter versioning (app.js?v=a1b2c3d4) and filename hashing (app.a1b2c3d4.js) determines how CDNs construct their storage keys, whether you need active purge operations after a deploy, and how difficult incident diagnosis becomes at 2 AM. Both strategies embed a version identifier in the URL; only one of them works correctly by default on every CDN.

Diagnosis: Which Strategy Is Causing Your Stale Assets?

Before changing anything, confirm which pattern is in use and whether the edge is handling it correctly.

# Inspect cache status headers for a query-param versioned asset
curl -sI "https://example.com/bundle.js?v=a1b2c3d4" \
  | grep -E "^(cf-cache-status|x-cache|age|cache-control|etag):" -i
Response Header Value Meaning Immediate Diagnosis
CF-Cache-Status: HIT on a freshly deployed ?v=new CDN stripped the query string; old bytes served CDN is normalising away your version parameter
CF-Cache-Status: MISS every request CDN sees a unique key per param value Normalisation is off; check hit ratio for fragmentation
X-Cache: HIT (CloudFront / Nginx) with Age: 0 First hit after the CDN populated the entry Normal cold-cache behaviour
Age: 86400 on new deploy CDN cached the previous response; stale Purge required or switch to filename hashing

For filename-hashed assets (/assets/bundle.a1b2c3d4.js), a stale-hit is structurally impossible: the URL changes with the content, so the CDN always treats it as a new object. The only failure mode is a browser holding a stale HTML file that still references the old hash.

Concept Clarification

Both strategies are forms of cache key architecture — they differ in where the version token sits and how it interacts with CDN normalisation.

Query-parameter versioning keeps the base path stable and appends a version suffix:

GET /static/app.js?v=a1b2c3d4

The CDN constructs a key from the full URL — unless it strips query strings for static MIME types, which most CDNs do by default to maximise hit ratios. When stripping occurs, ?v=a1b2c3d4 and ?v=deadbeef both map to /static/app.js, and the CDN serves the cached bytes from the first request indefinitely.

Filename hashing embeds the token in the path:

GET /static/app.a1b2c3d4.js

The query string is empty, so CDN normalisation rules have nothing to strip. The path is the key. A content change produces a new path (app.deadbeef.js), which is a new key, which is a cache miss — and the correct bytes are fetched from the origin exactly once.

Decision Matrix

Factor Query Parameters Filename Hashing
CDN default behaviour Strips query string for static types — requires override Works without any CDN configuration change
Cache-Control: immutable effectiveness Unreliable — proxy may still revalidate Full: URL uniquely identifies content revision
Rollback complexity Re-deploy with previous ?v= value + purge CDN Re-point HTML to prior hashed filename; no purge needed
Build pipeline requirement None — append ?v= at runtime or in HTML template Bundler must rename files and emit a manifest
Multi-CDN / proxy-chain risk High — each hop may normalise differently Low — path-based lookup is universal
Tracking parameter collision High — ?utm_source=x&v=hash may pollute the key None — path carries no analytics parameters
Browser cache interaction Old base path in browser cache survives version change New filename bypasses browser cache automatically
Incident diagnosis speed Requires checking each CDN’s normalisation config Simple: curl the hashed URL, check Age: header
Suitable for legacy HTML with no build step Yes No

Side-by-Side Configuration

The sections below show the minimum configuration required to make each strategy work correctly on Cloudflare, CloudFront, and Nginx.

Cloudflare

Query-param strategy — preserve query strings in the cache key:

{
  "cache_key": {
    "ignore_query_strings_order": false,
    "custom_key": {
      "query_string": {
        "include": ["v"]
      },
      "header": {
        "include": ["accept-encoding"]
      }
    }
  }
}

Cloudflare Cache Rules UI: set Query String to Include specific parameters, add v. This forces a distinct cache entry per ?v= value.

Filename-hash strategy — strip query strings entirely:

{
  "cache_key": {
    "ignore_query_strings_order": true,
    "custom_key": {
      "query_string": {
        "include": []
      },
      "header": {
        "include": ["accept-encoding"]
      }
    }
  }
}

No query string configuration is needed beyond the default; Cloudflare already ignores query strings for most static types. The explicit include: [] documents intent and prevents future accidents.

AWS CloudFront

Query-param strategy — forward v parameter to cache key:

{
  "CachePolicyConfig": {
    "Name": "query-param-versioning",
    "DefaultTTL": 86400,
    "MaxTTL": 31536000,
    "MinTTL": 0,
    "ParametersInCacheKeyAndForwardedToOrigin": {
      "EnableAcceptEncodingGzip": true,
      "EnableAcceptEncodingBrotli": true,
      "HeadersConfig": { "HeaderBehavior": "none" },
      "CookiesConfig": { "CookieBehavior": "none" },
      "QueryStringsConfig": {
        "QueryStringBehavior": "whitelist",
        "QueryStrings": { "Quantity": 1, "Items": ["v"] }
      }
    }
  }
}

Filename-hash strategy — exclude all query strings:

{
  "CachePolicyConfig": {
    "Name": "filename-hash-immutable",
    "DefaultTTL": 31536000,
    "MaxTTL": 31536000,
    "MinTTL": 0,
    "ParametersInCacheKeyAndForwardedToOrigin": {
      "EnableAcceptEncodingGzip": true,
      "EnableAcceptEncodingBrotli": true,
      "HeadersConfig": { "HeaderBehavior": "none" },
      "CookiesConfig": { "CookieBehavior": "none" },
      "QueryStringsConfig": { "QueryStringBehavior": "none" }
    }
  }
}

Apply either policy:

aws cloudfront create-cache-policy \
  --cache-policy-config file://policy.json

Nginx

Query-param strategy — include $request_uri (path + query) in the key:

proxy_cache_path /var/cache/nginx levels=1:2
  keys_zone=assets_qp:32m max_size=5g inactive=30d;

server {
  listen 443 ssl;
  server_name example.com;

  location ~* \.(js|css|png|jpg|svg|woff2)$ {
    proxy_pass      http://origin;
    proxy_cache     assets_qp;
    # Include full request URI so ?v=hash creates a distinct key
    proxy_cache_key "$scheme$request_method$host$request_uri";
    proxy_cache_valid 200 1d;
    add_header      Vary "Accept-Encoding";
  }
}

Filename-hash strategy — strip query strings, key on path only:

proxy_cache_path /var/cache/nginx levels=1:2
  keys_zone=assets_fh:32m max_size=10g inactive=365d;

server {
  listen 443 ssl;
  server_name example.com;

  location ~* ^/assets/[a-z0-9._-]+\.[a-f0-9]{8,}\.(js|css|png|jpg|svg|woff2)$ {
    proxy_pass      http://origin;
    proxy_cache     assets_fh;
    # Key excludes query string — path alone is sufficient
    proxy_cache_key "$scheme$proxy_host$uri";
    proxy_cache_valid 200 365d;
    add_header      Cache-Control "public, max-age=31536000, immutable";
    add_header      Vary "Accept-Encoding";
    # Drop any stray query parameters before forwarding
    set $args "";
  }
}
Query param vs filename hash cache key paths Two parallel request flows showing how a CDN handles query-parameter versioning (top track) versus filename-hash versioning (bottom track), illustrating key construction, normalisation, and cache outcome at the edge. QUERY PARAM Browser GET /app.js?v=old CDN default strips ?v= → key=/app.js Cache HIT stale bytes returned Fix required CDN config override + purge FILENAME HASH Browser GET /app.a1b2c3d4.js CDN default no params → key=path Cache MISS new path → origin fetch Cached + immutable max-age=31536000, no purge Requires CDN override or manual purge Works by default, no operational overhead
Query-parameter versioning requires explicit CDN configuration and often a manual purge; filename hashing works correctly with default CDN behaviour and requires no active cache management.

Verification

After configuring either strategy, run this targeted check:

# For query-param strategy: confirm CDN is including the parameter in its key
curl -sI "https://example.com/app.js?v=a1b2c3d4" | grep -i "cf-cache-status\|x-cache\|age"
# Deploy a new version, then confirm a HIT does NOT appear for the new ?v= value
curl -sI "https://example.com/app.js?v=deadbeef" | grep -i "cf-cache-status\|age"
# Expect: CF-Cache-Status: MISS (or EXPIRED) — not HIT

# For filename-hash strategy: confirm immutable header is present and a HIT occurs
curl -sI "https://example.com/assets/app.a1b2c3d4.js" \
  | grep -E "^(cache-control|cf-cache-status|age|vary):" -i
# Expect: cache-control contains "immutable" and CF-Cache-Status: HIT after first fetch

When to Reconsider

Filename hashing is the correct default, but query-parameter versioning is the better choice when:

  • Legacy CMS or static HTML — the build pipeline cannot automatically rewrite <script src> and <link href> references. Adding ?v=hash at the template layer is lower risk than breaking all asset references.
  • Rapid A/B testing of a single file — toggling ?v= is instant; generating a new filename requires a full build.
  • Reverse proxy you cannot configure — if you cannot add proxy_cache_key overrides to Nginx and the CDN in front strips query strings, neither strategy works without cooperation from the infrastructure. In that case, a server-side purge at deploy time is the only option.
  • Query string already carries meaningful state — rare, but some signed-URL schemes place an HMAC in the query string. Stripping query strings for the cache key in that context would conflate authenticated and unauthenticated requests. Use filename hashing instead, and move the HMAC to a header if you must cache.

After stabilising on filename hashing, the next operational challenge is recovering from a bad deploy. The rollback guide covers re-pointing HTML to previous hashed URLs without a purge.