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.
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 "";
}
}
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.
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=hashat 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_keyoverrides 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.
Related
- Cache key architecture — parent reference: key components, Vary, CDN configuration tables
- Rolling back cache keys after a bad deploy — recovering to a previous hashed revision
- Vary header pitfalls with fingerprinted assets — the other way an edge key multiplies beyond the path
- Fingerprinting in HTTP headers — ETag, Cache-Control, and Vary interaction with cache keys
- Static asset fingerprinting fundamentals — section overview