URLSearchParams Duplicate Keys: Preserve Values Before Object Conversion
Object.fromEntries(new URLSearchParams('tag=js&tag=node')) produces { tag: 'node' }. The query contains two values, but the object retains only the last one. Meanwhile, params.get('tag') returns the first value, 'js'. Switching between these representations can silently change which filter your application uses.
Keep repeated fields as arrays with getAll(). For a field that must appear at most once, check its count before converting anything. Once a query has been flattened into a string-valued object, its discarded values cannot be recovered from that object.
Locate the conversion that drops values
URLSearchParams holds an ordered list of name/value pairs. The WHATWG URL Standard defines get() as the first matching value and getAll() as all matching values in list order. A missing name returns null from get() and an empty array from getAll().
Object.fromEntries() creates properties from successive entries. Repeated string keys write the same property again, so the last value survives. The original URLSearchParams instance still has every pair; the loss happens in the new object.
For tag=js&tag=node, choose the representation according to your field’s contract:
| Operation | Result | Appropriate contract |
|---|---|---|
params.get('tag') | 'js' | Deliberately accept the first value |
params.getAll('tag') | ['js', 'node'] | Preserve every selected tag |
Object.fromEntries(params).tag | 'node' | Deliberately accept the last value |
Count with getAll() before reading | Reject when count exceeds one | Accept only a single occurrence |
These outcomes follow from the APIs and are verified below. None of them establishes what a backend framework will do with the same raw query string. Define that separately rather than assuming browser and server parsers agree.
Reproduce the loss and the round-trip boundary
Save this dependency-free fixture as query-params.mjs and run node query-params.mjs. It was executed during article preparation on macOS with Node.js 24.13.0 on October 8, 2026. Its assertions check the results rather than relying only on printed examples.
import assert from 'node:assert/strict';
function readOne(params, name) { const values = params.getAll(name); if (values.length > 1) throw new Error(`Duplicate ${name}`); return values.length === 0 ? null : values[0];}
const query = 'tag=js&tag=node&page=1&page=2&empty=';const params = new URLSearchParams(query);const flat = Object.fromEntries(params);const tags = params.getAll('tag');assert.equal(params.get('tag'), 'js');assert.equal(flat.tag, 'node');assert.deepEqual(tags, ['js', 'node']);console.log('get(tag):', params.get('tag'));console.log('object.tag:', flat.tag);console.log('getAll(tag):', JSON.stringify(tags));
assert.throws(() => readOne(params, 'page'), /Duplicate page/);try { readOne(params, 'page');} catch (error) { console.log('readOne(page):', error.message);}assert.equal(readOne(params, 'missing'), null);assert.equal(readOne(params, 'empty'), '');console.log('missing:', JSON.stringify(readOne(params, 'missing')));console.log('empty:', JSON.stringify(readOne(params, 'empty')));
const replaced = new URLSearchParams(params);replaced.set('tag', 'css');assert.deepEqual(replaced.getAll('tag'), ['css']);console.log('after set:', replaced.toString());
const kept = new URLSearchParams();for (const tag of tags) kept.append('tag', tag);assert.deepEqual(kept.getAll('tag'), tags);console.log('append round trip:', kept.toString());
const collapsed = new URLSearchParams(flat);assert.deepEqual(collapsed.getAll('tag'), ['node']);console.log('object round trip:', collapsed.toString());
const arrayRecord = new URLSearchParams({ tag: tags });assert.deepEqual(arrayRecord.getAll('tag'), ['js,node']);console.log('array record:', arrayRecord.toString());console.log('assertions: passed');Expected output from that environment:
get(tag): jsobject.tag: nodegetAll(tag): ["js","node"]readOne(page): Duplicate pagemissing: nullempty: ""after set: tag=css&page=1&page=2&empty=append round trip: tag=js&tag=nodeobject round trip: tag=node&page=2&empty=array record: tag=js%2Cnodeassertions: passedThe object round trip loses both the first tag and the first page value. The append round trip preserves both tags. Keeping an array inside a plain object is useful application state, but passing that array-valued object directly to the constructor does something different: it serializes the array as a single comma-separated value. The Node.js constructor documentation documents string coercion for object values, including arrays.
tag=js%2Cnode therefore represents one value, 'js,node', when read back through this API. It is not equivalent to tag=js&tag=node. A server might explicitly implement comma-separated lists, but that would be a separate endpoint contract.
Parse by field, then serialize deliberately
For a search page with repeated tags and one optional page parameter, keep the query instance until you have validated each field:
// Use readOne() from the fixture above.const params = new URLSearchParams('tag=js&tag=node&page=2');const filters = { tags: params.getAll('tag'), page: readOne(params, 'page'),};
const outgoing = new URLSearchParams();for (const tag of filters.tags) outgoing.append('tag', tag);if (filters.page !== null) outgoing.set('page', filters.page);console.log(outgoing.toString()); // tag=js&tag=node&page=2This example’s duplicate policy is intentional: tags may repeat; page may not. readOne() rejects even identical repeated page values. It distinguishes an absent field from an explicitly empty field, but it does not validate page numbers, tag membership, maximum counts, or input length. Add those checks to match your application before using the parsed values.
set() replaces the first matching pair and removes other pairs with that name. That makes it suitable for replacing one scalar field, but it destroys a repeated list. To replace the entire tag selection, delete the old list first and append each new value:
const params = new URLSearchParams('tag=js&tag=node&page=2');params.delete('tag');for (const tag of ['css', 'html']) params.append('tag', tag);console.log(params.toString()); // page=2&tag=css&tag=htmlThe unrelated page parameter remains. The replacement tags move to the end of the pair list; this example preserves their selection order, not the original query’s complete ordering. Avoid this rewrite when a separate system requires an exact original query representation.
Check the request boundary
Use new URL(fullUrl).searchParams when the input is a complete URL. In a browser, new URLSearchParams(location.search) accepts the query portion. The fixture exercises standard query operations in Node, not framework routing, browser navigation, a live endpoint, or a proxy’s duplicate-parameter handling.
For an affected request, compare the raw query, Array.from(params.entries()), and the object produced after conversion. That identifies whether values disappeared during parsing, flattening, or outgoing serialization. If the request reaches the server but fails later, the separate fetch status and JSON parsing checks help distinguish HTTP failure from response parsing.
Make the cardinality rule explicit at the first parsing boundary: retain arrays for repeated fields, reject duplicates for single-value fields, and use append() when the outgoing query must preserve multiple values.
References: