Sorting Query Parameters for a Stable Cache Key
Problem Statement
Two requests return byte-identical bodies and miss the cache independently, because one arrived as ?q=boots&page=2 and the other as ?page=2&q=boots. To the application these are the same request. To an edge that hashes the query string as written they are two different objects, each with its own cold start and its own origin fetch.
Prerequisite Concepts
- Cache key normalization and query strings — the pipeline this step belongs to and the order the steps must run in.
- How to debug CDN cache key mismatches — the isolation method that identifies ordering as the cause.
- Cache hit, miss and bypass mechanics — why each distinct key pays a full origin fetch.
Step-by-Step Resolution
Step 1 — Confirm ordering is the cause
Two requests, one reordering, one comparison:
curl -sI 'https://example.com/search?q=boots&page=2' | grep -iE 'cf-cache-status|^age'
curl -sI 'https://example.com/search?page=2&q=boots' | grep -iE 'cf-cache-status|^age'
A HIT followed by a MISS is conclusive. If both miss, something larger is wrong and the key debugging procedure is the better starting point.
Step 2 — Find where the reordering originates
The order is usually produced by client code iterating an unordered structure. A quick way to see the spread is to extract distinct parameter orderings for one path:
grep ' /search?' access.log \
| awk '{print $7}' \
| sed 's/^[^?]*?//' \
| tr '&' '\n' | paste -sd'&' - 2>/dev/null | head
# More directly: count distinct orderings of the same parameter set
grep ' /search?' access.log | awk '{print $7}' \
| sed -E 's/=[^&]*//g' | sort | uniq -c | sort -rn | head
Several orderings of the same parameter names confirms the source is client-side serialization rather than genuinely different requests.
Step 3 — Sort during key composition
Sorting is the cheapest normalization available and has no downside for the overwhelming majority of traffic:
sub vcl_recv {
# Order matters: strip, then filter, then sort
set req.url = querystring.regfilter(req.url, "^(utm_[a-z_]+|fbclid|gclid)$");
set req.url = querystring.filter_except(req.url, "q,page,sort,size");
set req.url = querystring.sort(req.url);
}
In Nginx, naming the parameters in a fixed order in the key achieves sorting implicitly:
location /search {
proxy_cache pages;
proxy_cache_key "$scheme$host$uri?page=$arg_page&q=$arg_q&size=$arg_size&sort=$arg_sort";
proxy_pass http://origin;
}
Because the key template is written once in a fixed order, the incoming order cannot influence it at all.
Step 4 — Check for order-sensitive parameters
Repeated parameter names are the one case where sorting can change meaning:
/search?filter=colour:red&filter=size:9
If the application treats the two filter values as an ordered list, a sort that reorders them changes the request. A sort by name that preserves the relative order of equal names — a stable sort — handles this correctly, and it is worth confirming your platform’s implementation is stable rather than assuming.
Step 5 — Verify the collapse
for qs in 'q=boots&page=2&sort=date' \
'page=2&sort=date&q=boots' \
'sort=date&q=boots&page=2'; do
printf '%-32s ' "$qs"
curl -sI "https://example.com/search?$qs" | awk -F': ' '/[Aa]ge/{print "Age " $2}'
done
The first request may miss; every subsequent ordering should report a climbing Age. Confirm the opposite direction too — that page=1 and page=2 still produce different bodies — so you know the sort collapsed orderings rather than parameters.
Step 6 — Fix the source as well as the symptom
Sorting at the edge is the right immediate fix because it covers every client, including ones you do not control. It is worth also fixing the client that produced the variance, for two reasons.
The first is that a stable ordering at the source makes logs and traces far easier to read: identical requests appear as identical strings, and a diff between two captures shows genuine differences rather than serialization noise. The second is that the edge is not the only cache in the path. A browser’s own HTTP cache keys on the URL as requested, so a client that emits a different ordering on each navigation misses its own cache too — a cost no amount of edge configuration can recover.
The fix is usually one line: serialize parameters from a sorted list rather than from a map. Most HTTP client libraries expose an option for it, and where they do not, sorting the parameter names before building the string is trivial.
Expected Output / Verification
Distinct request targets for the path fall to the number of genuine parameter combinations, and the hit ratio on that path rises without any change to the origin or the directives:
before: 1240 distinct targets for /search, 61% hit ratio
after: 78 distinct targets for /search, 94% hit ratio
Edge Cases
- A signed query string. If a signature covers the query string as written, sorting invalidates it. Exclude signed routes from sorting, or sign a canonical form the edge can reproduce.
- An unstable sort with repeated names. Reordering repeated values can change an ordered filter list. Verify the platform’s sort is stable before relying on it.
- Sorting applied to the forwarded request. Only the key should be sorted. Rewriting the upstream request can break server-side code that reads the raw query string.
- Percent-encoding differences.
%20and+encode the same space but are different bytes, so encoding variance fragments the key just as ordering does and is not fixed by sorting. - Empty parameters.
?q=&page=2sorts differently from?page=2and remains a distinct key. Drop empty values during filtering if the application ignores them. - Different rules at the shield. Sorting at the edge but not the shield leaves the shield fragmented, so it cannot serve the edge’s misses and the topology stops paying for itself.
Frequently Asked Questions
Do any applications depend on parameter order?
Very few, but repeated parameters are the exception: ?filter=a&filter=b may be ordered meaningfully, and a naive sort can reorder the values. Sort by name while preserving the relative order of repeated names, or exclude those parameters from sorting.
Should sorting happen before or after stripping?
After. Sorting first orders parameters that are about to be removed, which wastes work and can leave the key dependent on what was present. Strip, then filter, then sort.
Does sorting change what the origin receives?
It should not. Sort the string used for key composition, not the request forwarded upstream, so any server-side code that inspects the raw query string is unaffected.
Why does reordering happen at all?
Almost always because a client builds the query string by iterating an unordered structure — a hash map, a JSON object, or a framework’s parameter serializer. The order is stable within a process and differs between them.
Related
- Stripping UTM parameters from the cache key removes the larger source of key variance and should be applied first.
- How CDN cache keys are generated covers the other components a key contains beyond the query string.
- Cache-Control best practices for REST APIs explains why API endpoints are where reordering is most common.