It is tempting to turn a URL into a cache key with one line:
const key = new URL(request.url).pathname;
That is not URL normalization. It is a claim that every query parameter is irrelevant.
Sometimes that claim is right. A utm_source parameter should usually not create another cache entry. But a lang=fr, page=3, format=json, or filter=open parameter can change the resource entirely. Drop one of those fields and a cache can serve a valid response for the wrong request.
The safer question is not “how do I remove query strings?” It is “which fields are part of this endpoint’s resource contract?”
To make that failure visible, this package includes a small Node.js fault-injection fixture. It generates 576 URLs for a hypothetical /reports endpoint, then compares three cache-key strategies. The result is intentionally narrow: it tests declared URL semantics under generated inputs. It does not measure a CDN, website, cache hit rate, latency, or production traffic.

Figure 1. Original explanatory diagram. Provenance: designed for this package from the declared fixture contract; it is not a measured result.
The mistake hidden in “ignore the query string”
Suppose an endpoint treats these four fields as meaningful:
| Field | Example | Why it changes the resource |
lang | fr | The response language changes. |
page | 3 | The result window changes. |
format | json | The representation changes. |
filter | open | The selected records change. |
For this example only, utm_source, utm_campaign, gclid, fbclid, and ref are tracking fields. They are not part of the report resource. A real endpoint needs its own contract; a parameter name is not proof that it is safe to discard.
The dangerous key is the shortest one:
function pathnameOnlyKey(input: string) {
const url = new URL(input);
return `${url.origin}${url.pathname}`;
}
Every generated report URL becomes https://example.test/reports. The key is stable, but it now groups 72 different declared resources together.
The opposite extreme fragments the cache:
function rawUrlKey(input: string) {
const url = new URL(input);
return `${url.origin}${url.pathname}${url.search}`;
}
This preserves meaning, but it gives tracking variants their own cache entries. A newsletter visitor and a direct visitor receive separate keys for the same report.
The contract belongs in code
For a narrow endpoint, write the allowlist next to the cache-key function and sort the fields before serializing them:
const semanticFields = ["filter", "format", "lang", "page"];
function allowlistedKey(input: string) {
const url = new URL(input);
const params = new URLSearchParams();
for (const field of semanticFields) {
const value = url.searchParams.get(field);
if (value !== null) params.set(field, value);
}
return `${url.origin}${url.pathname}?${params.toString()}`;
}
That code has an important limitation: it is correct only when the endpoint contract is correct. If region affects the report but the allowlist omits it, the function silently creates a collision. The policy should be reviewed when the endpoint gains a filter, locale mode, view, tenant selector, or output representation.
A reproducible 576-URL fixture
The fixture combines:
- 3 languages
- 4 pages
- 2 output formats
- 3 filters
- 8 tracking/query-order variants per semantic resource
That creates 72 declared report resources and 576 total request URLs. The complete 576 generated rows and the original runner are retained in the private editorial package. The compact reader reproduction below uses the same declared dimensions and tracking-variant count. It simplifies the query-variant ordering, so it reproduces the aggregate counts rather than every archived URL byte for byte. It evaluates two useful diagnostics:
- Conflicting requests: generated requests grouped with a different declared resource.
- Redundant keys: extra keys created for tracking or ordering variants of the same declared resource.
The fixture produced these counts:
| Strategy | Cache keys | Conflicting requests | Redundant keys |
| Raw URL | 576 | 0 | 504 |
| Pathname only | 1 | 568 | 0 |
| Allowlisted normalized | 72 | 0 | 0 |
Reproduce the aggregate counts
Save this standalone Node.js code as cache-key-fixture.mjs, then run node cache-key-fixture.mjs. It makes no network calls. It checks the count logic against the same 72 declared resources and eight variants per resource. The archived 576-row file remains available to the editor for a byte-level review of the original run.
const fields = ['filter', 'format', 'lang', 'page'];
const tracking = [
'', 'utm_source=newsletter', 'gclid=click-01',
'fbclid=social-01', 'ref=sidebar', 'utm_campaign=q3',
'utm_source=newsletter&gclid=click-01', 'ref=sidebar&fbclid=social-01'
];
const requests = [];
for (const lang of ['de', 'en', 'fr'])
for (const page of [1, 2, 3, 4])
for (const format of ['html', 'json'])
for (const filter of ['all', 'mine', 'open']) {
const resource = JSON.stringify([filter, format, lang, page]);
for (const extra of tracking) {
const query = new URLSearchParams({ lang, page, format, filter });
if (extra) for (const [key, value] of new URLSearchParams(extra))
query.append(key, value);
requests.push({ resource, url: new URL(`https://example.test/reports?${query}`) });
}
}
function assess(keyFor) {
const buckets = new Map();
const keysByResource = new Map();
for (const { resource, url } of requests) {
const key = keyFor(url);
if (!buckets.has(key)) buckets.set(key, new Map());
const counts = buckets.get(key);
counts.set(resource, (counts.get(resource) ?? 0) + 1);
if (!keysByResource.has(resource)) keysByResource.set(resource, new Set());
keysByResource.get(resource).add(key);
}
return {
keys: buckets.size,
conflictingRequests: [...buckets.values()].reduce((n, counts) =>
n + [...counts.values()].reduce((a, b) => a + b, 0) - Math.max(...counts.values()), 0),
redundantKeys: [...keysByResource.values()].reduce((n, keys) => n + keys.size - 1, 0)
};
}
const raw = url => url.href;
const pathname = url => url.origin + url.pathname;
const allowlisted = url => {
const kept = new URLSearchParams();
for (const field of fields) kept.set(field, url.searchParams.get(field));
return pathname(url) + '?' + kept;
};
console.log(JSON.stringify({
requests: requests.length,
raw: assess(raw),
pathname: assess(pathname),
allowlisted: assess(allowlisted)
}, null, 2));
pathname looks efficient because it creates one key. It is actually the fixture’s worst semantic result: after keeping the largest same-resource group, 568 of 576 requests conflict with another declared report resource. The raw URL has the mirror-image problem: it avoids collisions but fragments each resource across eight keys.

Figure 2. Original evidence chart. Provenance: rendered from the deterministic 576-URL fixture; the saved summary was regenerated on 30 October 2026. The standalone reproduction above produces the same aggregate counts.
Here is generated row R522 from the archived 576-row file:
Input
https://example.test/reports?filter=open&format=json&page=3&lang=fr&utm_source=newsletter
Raw URL key
https://example.test/reports?filter=open&format=json&page=3&lang=fr&utm_source=newsletter
Pathname-only key
https://example.test/reports
Allowlisted normalized key
https://example.test/reports?filter=open&format=json&lang=fr&page=3
The output is deliberately unsurprising. The test is not trying to find a clever hashing trick. It is checking that the code retains the declared resource dimensions and removes only fields declared non-semantic.
Failure cases to test before changing a cache key
Before applying this pattern to a route, add endpoint-specific cases for:
- A new filter that should generate a different response.
- Reordered query fields that should generate the same response.
- Tracking parameters that should not change the response.
- Repeated parameters, especially when their order can be semantic.
- Absent fields versus explicit defaults.
- Encoded values, case rules, locale fallbacks, tenant selectors, and authorization boundaries.
The authorization point deserves emphasis. A cache key cannot safely omit an identity or tenant dimension merely because it appears in a header instead of the URL. The right key is determined by response semantics and access control, not by string shape.
What this test does not establish
This is a controlled teaching fixture. It does not tell you which query parameters matter on a real API. It does not establish a production cache-hit rate, CDN behavior, latency gain, crawler behavior, SEO outcome, or security posture. It also does not cover a route whose response depends on cookies, headers, authentication, time, geographic region, or feature flags.
Use the fixture as a way to make a cache-key decision reviewable. First name the fields that select the resource. Then encode that contract. Then write a test that fails when a future change silently drops a meaningful field.
Sources and implementation notes
- The WHATWG URL Standard defines the URL parsing model used by modern platform implementations.
- Node.js documents its
URL API, including URLSearchParams behavior used in the example.
- RFC 3986 describes the generic URI syntax and query component. It does not tell an application which query fields are semantically meaningful.
Disclosure: AI assisted with the fixture code, article draft and figures. Daniel Martin co-founded AgentSEO. The comparison uses generated URLs under a declared example endpoint; it includes no AgentSEO customer or production cache data.