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

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.

Identical requests, two stored entriesThe application reads the same three parameters in both cases; the edge sees two different byte sequences and stores the response twice.?q=boots&page=2key AOrigin fetch, response stored under key ASubsequent identically-ordered requests hitAny other ordering misses?page=2&q=bootskey BSecond origin fetch for the identical bodyA second cold entry consuming store capacityBoth entries expire and refetch independently
The application sees one request; the cache sees two

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.

The order the normalization steps must run inEach step assumes the previous one has already run; reversing any pair leaves the key dependent on what was present rather than on what matters.FIRST1Strip tracking parametersremove what never matters2Filter to the allow-listkeep what the application reads3Sort by namestable ordering4Compose and hashthe key the edge looks upLAST
Strip, filter, sort — in that order

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.

Which parameters are safe to sortAlmost everything is safe; the exceptions are repeated names whose relative order carries meaning and signed strings whose byte form is part of a signature.Safe to sortReasonDistinct named parametersorder carries no meaningRepeated names, unordered seta stable sort preserves themRepeated names, ordered listwith a stable sortrelative order must surviveSigned query stringsthe signature covers the byte form
Three categories, one of which needs care

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. %20 and + 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=2 sorts differently from ?page=2 and 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.


Back to Cache Key Normalization and Query Strings