URL encoding diagnostics · Updated November 1, 2026
URL Encode Repeated Query Parameters Without Losing Values
Keep repeated query parameters as an ordered list of pairs. Build the query with new URLSearchParams(pairs) or append(), then read a repeated name with getAll(). A successful encoding can still lose values later: get() reads only the first, while conversion through Object.fromEntries() keeps the last value for each name.
Paste raw values, not already encoded text. The checker runs locally. It shows the preserved sequence alongside two deliberately lossy alternatives; it does not choose your API's repeated-key policy.
Trace the value that disappeared
- Keep the input as pairs. An object with one property named
tagcannot store three separate entries under that same name. - Serialize that sequence and record the query string before sending it. In the example, the value containing an ampersand stays inside one pair as
a%26b. - Parse the string and inspect
getAll('tag'). Compare its length and order with the original entries, including empty strings. - Find each conversion into an object, a scalar read, or a loop that calls
set(). Those steps can reduce a list without reporting an encoding error. - Decide whether your receiving API expects repeated names at all. Test its documented contract separately; this browser experiment does not establish any server framework's interpretation.
Ten fixed cases: preserved pairs versus reduced values
Executed November 1, 2026 with Node v24.19.0. Each row starts with raw string pairs. The first three reading columns inspect the first name in that fixture; the last column serializes the result of calling set() once for every original pair. A separate Python 3.13.15 run matched all ten serialized strings and ten ordered-pair round trips, using urlencode on tuples and parse_qsl(..., keep_blank_values=True).
| Fixture | Preserved query | get: first value | getAll: every value | Object.fromEntries: last value | Repeated set loop |
|---|---|---|---|---|---|
reserved-and-empty | tag=a%26b&tag=c%2Bd&tag= | "a&b" | ["a&b", "c+d", ""] | "" | tag= |
identical-values | tag=x&tag=x | "x" | ["x", "x"] | "x" | tag=x |
interleaved | tag=a&page=2&tag=b | "a" | ["a", "b"] | "b" | tag=b&page=2 |
empty-first | q=&q=next | "" | ["", "next"] | "next" | q=next |
unicode-values | q=caf%C3%A9&q=cafe%CC%81 | "caf\u00e9" | ["caf\u00e9", "cafe\u0301"] | "cafe\u0301" | q=cafe%CC%81 |
case-sensitive-keys | Tag=a&tag=b | "a" | ["a"] | "a" | Tag=a&tag=b |
empty-name | =a&=b | "a" | ["a", "b"] | "b" | =b |
literal-percent-name | %2574ag=a&tag=b | "a" | ["a"] | "a" | %2574ag=a&tag=b |
proto-name | __proto__=first&__proto__=last | "first" | ["first", "last"] | "last" | __proto__=last |
numeric-key-order | 2=b&1=a&2=c | "b" | ["b", "c"] | "c" | 2=c&1=a |
Three losses that are easy to mistake for encoding failures
The empty last value: our reserved-and-empty fixture contains three entries: a&b, c+d, and an empty string. Its preserved query is tag=a%26b&tag=c%2Bd&tag=. The object view returns an empty string for tag. That does not mean decoding failed or no value arrived; the conversion selected the final entry and discarded the earlier two.
Identical values still have multiplicity: the identical-values case returns two copies of x from getAll(). The object view and set loop retain one. Checking only whether x survived would miss this loss. A regression for a multivalue field needs to assert the entire array, including repeated identical items.
Names can change the evidence: Tag and tag remain distinct in our case-sensitive fixture. The literal raw name %74ag is serialized as %2574ag, so it stays distinct from tag after parsing. But parsing the already encoded query tag=a&%74ag=b produces two values under tag. One input is raw data; the other is an encoded representation. Do not apply a duplicate-name check at one boundary and assume it checked the other.
Preserving values is different from preserving the original query bytes
The numeric-key-order fixture is intentionally ordered 2, 1, 2. The pairs round-trip in that order. Moving through an object loses the first value for key 2 and, when reserialized in this experiment, places key 1 before key 2. The result is 1=a&2=c, not the original 2=b&1=a&2=c. Keep the list representation if order is part of your comparison.
A separate control parses both tag and tag= as an empty value. Serializing either gives an equals sign. This checker promises the raw string-pair sequence survives its encode/parse round trip; it does not promise to preserve every spelling of an existing URL. Do not rebuild signed or otherwise byte-sensitive URLs through it without checking the signing contract.
A minimal sequence that keeps all three entries
const pairs = [["tag", "a&b"], ["tag", "c+d"], ["tag", ""]];
const params = new URLSearchParams();
for (const [key, value] of pairs) params.append(key, value);
const query = params.toString();
// tag=a%26b&tag=c%2Bd&tag=
const received = new URLSearchParams(query).getAll("tag");
// ["a&b", "c+d", ""]
The shared checker rejects non-string pairs and lone surrogates rather than silently coercing them. That is our input policy, not a statement that every URLSearchParams constructor rejects those inputs. See the separate lone-surrogate experiment for malformed UTF-16. For ordinary escaping, use the URL encoder; for plus and percent-decoding conventions, use the URL decoder.
Download and rerun the evidence
Download the Node regression script, ten input fixtures, recorded Node results and shared checker core. Keep the first three in downloads/ and the core in assets/ under one parent directory. Run:
node downloads/url-repeated-query-regression.cjs
Expected summary: PASS 10 fixtures, ordered round trips and alias/empty-input controls; v24.19.0. Your runtime version may differ. The script asserts the exact encoded strings, every ordered pair, first/all/last reads, the set loop's reduction, and the separate alias and empty-input controls. It writes a fresh results JSON; a failed assertion exits nonzero.
Should I reject repeated query names?
That depends on the endpoint contract. A multiple-choice filter may need all values; a scalar setting may require exactly one. For a scalar contract, inspect the complete parsed list and reject an unexpected count explicitly. Do not treat whichever value a helper returned as proof that only one value existed.
Does set() always break a query?
No. Replacing a single-valued field is a valid use. The problem demonstrated here is using replacement while expecting append behavior. Make that choice explicit, and keep a fixture with an empty final value so accidental reduction is visible.
API references checked November 1, 2026: WHATWG URLSearchParams, MDN append, set, and getAll. The ten-fixture data-loss matrix, controls and reusable checker are this project's reproducible work; standard API behavior itself is not claimed as an invention.