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.

Fragmentation Arithmetic

The stale-hit failure is the loud one. The quiet one is fragmentation, and it appears the moment a CDN is configured to respect the query string rather than strip it — which is exactly what the query-param strategy requires.

A version parameter is not the only thing that ends up in an asset URL. Email campaigns, social referrers and ad platforms append their own parameters to whatever link they touch, and a <script src> copied into a marketing landing page inherits them. Once the edge treats the query string as significant, ?v=a1b2c3d4, ?v=a1b2c3d4&utm_source=nl and ?utm_source=nl&v=a1b2c3d4 are three distinct keys for one 42 KB file. Parameter order alone doubles the space unless the CDN sorts keys, which Cloudflare does via ignore_query_strings_order and CloudFront does not.

Query-string fan-out A single origin file reached through six different query-string permutations produces six independent edge cache objects holding identical bytes. ONE FILE, SIX OBJECTS /static/app.js 42 KB at origin ?v=a1b2c3d4 ?v=a1b2c3d4&utm_source=nl ?utm_source=nl&v=a1b2c3d4 ?v=a1b2c3d4&fbclid=IwAR9 ?v=a1b2c3d4&ref=partner ?v=a1b2c3d4&gclid=Cj0KEQ object 1 · 42 KB object 2 · 42 KB object 3 · 42 KB object 4 · 42 KB object 5 · 42 KB object 6 · 42 KB Six cold misses, six origin fetches, 252 KB of edge storage for 42 KB of content.
Once the query string is significant to the key, every tracking parameter a marketer appends mints another independent copy of the same bytes.

Filename hashing is immune to this by construction: the path is the whole key, tracking parameters are stripped without loss, and the entropy in the key is exactly the entropy in the content.

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

Default Query-String Handling per Edge

Before writing any rule, know what the platform already does. The defaults differ enough that a configuration copied from one provider’s documentation can silently invert the behaviour on another.

Platform Query string in key by default Parameter order normalised Override mechanism
Cloudflare (Free/Pro) Ignored for common static extensions; included otherwise Yes, when ignore_query_strings_order is set Cache Rules → Cache Key → Query String
Cloudflare (Business/Enterprise) Same defaults, plus custom cache key Yes Cache Rules, or Workers cf.cacheKey
AWS CloudFront none on the managed CachingOptimized policy No — ?a=1&b=2 and ?b=2&a=1 are distinct Custom cache policy QueryStringsConfig
Fastly Full URL including query is the default hash input No vcl_hash rewrite, or querystring.filter
Nginx proxy_cache $request_uri includes the query in most sample configs No proxy_cache_key with $uri instead

The two rows that surprise people are CloudFront and Nginx. CloudFront’s most-recommended managed policy strips the query string entirely, so a ?v= strategy deployed under CachingOptimized fails on day one with a permanent stale hit. Nginx’s canonical proxy_cache_key "$scheme$request_method$host$request_uri" does the opposite: it includes the query verbatim and un-sorted, so it fragments on tracking parameters until you swap $request_uri for $uri.

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 tracks showing how a CDN handles query-parameter versioning against filename-hash versioning, from browser request through edge normalisation to the cache outcome and the operational work each leaves behind. QUERY PARAM Browser GET /app.js?v=old CDN default strips ?v= to /app.js Cache HIT stale bytes returned Fix required CDN override + purge FILENAME HASH Browser GET /app.a1b2c3d4.js CDN default no params, key=path Cache MISS new path, one fetch Cached, immutable max-age=1y, no purge Needs a CDN override and a manual purge Correct under default settings, zero upkeep
Query-parameter versioning ends every deploy with configuration work and a purge; filename hashing ends it with a single origin fetch and nothing left to do.

Migrating from Query Parameters to Filename Hashing

The migration is safe because the two schemes address different URLs. Nothing forces a cutover instant; you can serve both forms for as long as the longest HTML TTL in your system, then retire the old routes.

Step 1 — emit hashed filenames alongside the flat ones. Configure the bundler to write hashed names, and add a build step that also writes an unhashed copy for the transition window:

// vite.config.js
import { defineConfig } from 'vite';
import { copyFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

export default defineConfig({
  build: {
    manifest: true,
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name]-[hash:8].js',
        chunkFileNames: 'assets/[name]-[hash:8].js',
        assetFileNames: 'assets/[name]-[hash:8][extname]',
      },
    },
  },
  plugins: [
    {
      name: 'legacy-flat-copies',
      closeBundle() {
        const dir = join(process.cwd(), 'dist/assets');
        for (const file of readdirSync(dir)) {
          const flat = file.replace(/-[a-f0-9]{8}(\.[a-z]+)$/, '$1');
          if (flat !== file) copyFileSync(join(dir, file), join(dir, flat));
        }
      },
    },
  ],
});

Step 2 — switch the HTML to the manifest. Every <script> and <link> reference now resolves through dist/.vite/manifest.json, so newly served documents point exclusively at hashed URLs. Old documents already in browser and edge caches keep requesting ?v=, and they keep working because Step 1 still writes the flat copies.

Step 3 — invert the cache rule. Change the asset prefix rule from “include v in the key” to “exclude all query strings”, and raise the TTL to a year. Because hashed URLs are new keys, this cannot serve anyone a stale response.

Step 4 — retire the flat copies. Once Age on your longest-lived HTML object exceeds its TTL — typically minutes, but confirm against the actual header — no live document references the flat paths. Delete the copy step, redeploy, and purge the flat URLs so the edge stops holding dead objects.

Migration window A schedule showing query-parameter URLs served first, an overlapping dual-serving window where both URL forms resolve, then hashed-only serving, and finally retirement of the legacy flat routes. MIGRATION WINDOW, QUERY PARAMS TO FILENAME HASHES Query-param URLs served Dual serving: both URL forms resolve Hashed URLs only, one-year TTL Delete flat copies, purge old routes deploy 1 deploy 2 deploy 3
The dual-serving overlap only needs to outlast the HTML TTL, so a three-deploy migration is normal even on a busy site.

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

A single-URL check proves correctness but not efficiency. To see fragmentation, replay the parameter permutations your analytics platform actually appends and count how many of them come back cold:

# Count how many query permutations produce an independent cache object
BASE="https://example.com/app.js?v=a1b2c3d4"
for suffix in "" "&utm_source=nl" "&utm_source=nl&utm_medium=email" \
              "&fbclid=IwAR9" "&gclid=Cj0KEQ" "&ref=partner"; do
  status=$(curl -sI "${BASE}${suffix}" | grep -i "^cf-cache-status:" | tr -d '\r' | awk '{print $2}')
  printf '%-40s %s\n' "${suffix:-<none>}" "${status:-unknown}"
done
# Every line reporting MISS on a second pass is a separate object holding identical bytes.

Run the loop twice. On the second pass a healthy configuration reports HIT for all six because the edge normalised them onto one key; a fragmented one reports MISS or EXPIRED on the permutations it has not seen recently. The same signal shows up in aggregate as a hit ratio that never climbs above the mid-60s no matter how long the TTL is.

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.

Frequently Asked Questions

Can I keep the query string for cache busting but strip tracking parameters at the edge?

Yes, and it is the least-bad way to run the query-param strategy. Cloudflare Cache Rules and CloudFront cache policies both accept an allow-list: include only v in the key and discard everything else. The residual risks are that the allow-list has to be maintained by hand as new parameters appear, and that a parameter you forgot to list still reaches the origin even though it is absent from the key. Filename hashing removes both failure modes because there is no list to maintain.

Does Cache-Control: immutable work on a query-parameter URL?

The directive is honoured — browsers will skip revalidation for /app.js?v=a1b2c3d4 just as they would for a hashed path. The problem is what happens at the next version. With a hashed filename the new URL is unrelated to the old one, so the old entry simply ages out. With a query parameter the base path is unchanged, so any intermediate proxy that stripped the query on the way in now holds an immutable entry for /app.js that it will refuse to revalidate for a year. Never combine immutable with query-param versioning unless you control every cache in the path.

Which strategy should a site with no build step use?

Query parameters, with the version value driven by the file’s own modification time or content digest rather than a hand-edited constant. A server-side template that emits ?v=<sha256 prefix> gives you most of the correctness of filename hashing without a bundler, and the content hashing guide covers how to derive that value. Pair it with an edge rule that includes only v in the key.