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 "";
}
}
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=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.
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
- Content hashing vs semantic versioning — versioning philosophy that underpins the strategy choice
- Fingerprinting in HTTP headers — ETag, Cache-Control, and Vary interaction with cache keys