skip to content

URL and URLSearchParams

You will learn to parse, build, and mutate URLs with the platform objects instead of string surgery. Interviewers ask because hand-rolled query-string concatenation is a recurring source of encoding and injection bugs.

on this pageshow

questions

5

In browser JavaScript, what is the second argument to the URL constructor for, and why does new URL('/checkout') throw while new URL('/checkout', 'https://shop.example.com/cart/items') does not?

level: juniorimportance: must knowfreq 70%

answer

  1. two arguments, not one
  2. the second one supplies the origin
  3. relative input has nothing to resolve against
  4. throws, never returns null
  5. trailing slash on the base changes the answer

basics

~20 s

The second argument is a base URL that a relative reference is resolved against. A relative string on its own has no scheme or host, so new URL('/checkout') throws a TypeError; with the base it resolves to https://shop.example.com/checkout.

solid answer

~40 s

`new URL(input, base)` runs the WHATWG URL parser: if `input` is already absolute the base is ignored, and if it is relative the base supplies the missing scheme, host and path context. `'/checkout'` is a path-absolute reference — it has no origin of its own — so calling `new URL('/checkout')` with no base throws a `TypeError`, while `new URL('/checkout', 'https://shop.example.com/cart/items')` gives `https://shop.example.com/checkout`. The resolution rules matter: a leading `/` replaces the whole path, a bare relative like `'items'` replaces only the last path segment of the base, and `'?x=1'` or `'#top'` keep the base path. In page code the base is usually `location.href` or `document.baseURI`. Because the constructor throws on anything unparseable, wrap untrusted input in `try`/`catch`, or use `URL.canParse()` where it is available.

code

javascript · 13 lines
javascript
const base = 'https://shop.example.com/cart/items';

console.log(new URL('https://other.test/x', base).href);
console.log(new URL('/checkout', base).href);
console.log(new URL('checkout', base).href);
console.log(new URL('checkout', base + '/').href);
console.log(new URL('#top', base).href);

try {
  new URL('/checkout');
} catch (err) {
  console.log(err.name);
}

go deeper

for a junior

Know that the constructor takes an optional base URL, that relative input without a base throws a TypeError, and that location.href is the base you normally pass in page code.

for a middle

Be ready to walk the resolution table out loud: absolute input ignores the base, a leading slash replaces the path, a bare segment replaces the base's last segment, and a query- or hash-only reference keeps the rest.

for a senior

Show that you treat untrusted URL input as a parse that can fail — try/catch or URL.canParse — and that you know parsing normalises case, default ports and dot segments, which matters when URLs become cache or dedupe keys.

for a principal

Frame it as a codebase policy: one URL-building helper on top of the platform parser, base URLs stored with a trailing slash, no ad-hoc concatenation anywhere, so encoding and resolution bugs cannot be reinvented per feature.

## What the URL constructor is `URL` is a platform class that parses a URL string into a structured, mutable object. It implements the WHATWG URL parser — the same algorithm the browser uses for links and for the `fetch` request URL — so it is the ground truth for what a string means as a URL, far more reliable than a regex or `split('?')`. The constructor has two parameters: `new URL(input, base)`. `input` is the URL or relative reference you want to parse; `base` is optional and is itself parsed as an absolute URL first. ## How the base argument resolves a relative reference If `input` parses as an absolute URL on its own — it has a scheme like `https:` — the base is ignored entirely. Otherwise `input` is a *relative reference* and the parser fills in the missing pieces from the base: ```js const base = 'https://shop.example.com/cart/items?a=1#frag'; new URL('https://other.test/x', base).href; // 'https://other.test/x' (base ignored) new URL('/checkout', base).href; // 'https://shop.example.com/checkout' new URL('checkout', base).href; // 'https://shop.example.com/cart/checkout' new URL('../checkout', base).href; // 'https://shop.example.com/checkout' new URL('?b=2', base).href; // 'https://shop.example.com/cart/items?b=2' new URL('#top', base).href; // 'https://shop.example.com/cart/items?a=1#top' new URL('//cdn.test/x', base).href; // 'https://cdn.test/x' (scheme kept) ``` The two that trip people up are the third and fourth lines. A **bare** relative reference is resolved against the base's *directory*: the last segment of the base path is dropped and replaced. So the base `/cart/items` behaves as the directory `/cart/`, and `'checkout'` lands at `/cart/checkout`. Change the base to `/cart/items/` (trailing slash) and the same input lands at `/cart/items/checkout`. A missing or extra trailing slash on an API base is one of the most common URL-building bugs. ## Why the no-base call throws `'/checkout'` carries no scheme and no host. There is nothing to resolve it against, so the parser fails, and a failed parse in the constructor is a `TypeError` — not `null`, not an empty string: ```js try { new URL('/checkout'); } catch (e) { console.log(e.name); } // 'TypeError' ``` This is deliberate: the constructor never returns a half-parsed object. Any URL instance you hold is guaranteed to be a valid, absolute URL. In page code you almost always have a base available — `location.href` for "relative to the current page" or `document.baseURI` if the document has a `<base>` element — so `new URL(path, location.href)` is the idiomatic way to turn a path into a full URL. ## Reading the parsed parts Once parsed, the object exposes the components as properties. Note which ones include their punctuation: ```js const u = new URL('https://[email protected]:8443/a/b/../c?q=1#top'); u.protocol; // 'https:' (with colon) u.origin; // 'https://shop.example.com:8443' (read-only) u.host; // 'shop.example.com:8443' (with port) u.hostname; // 'shop.example.com' (no port) u.port; // '8443' (a string; '' when the scheme's default port is used) u.pathname; // '/a/c' (dot segments already resolved) u.search; // '?q=1' (leading '?', or '' when empty) u.hash; // '#top' (leading '#', or '' when empty) ``` Parsing also normalises: the scheme and host are lower-cased, a default port (`:443` on `https`) is dropped, and `.`/`..` segments are resolved. `origin` is read-only; every other component is writable, and assigning to one re-serialises `href`. ## Parsing without throwing When the input is untrusted — a value from a form field, a query parameter, a config file — you must handle failure. Three options: wrap the constructor in `try`/`catch`; call `URL.canParse(input, base)`, which returns a boolean and shipped across browsers in 2023; or call the static `URL.parse(input, base)`, which returns a `URL` or `null` instead of throwing and landed in 2024-era browser releases. Feature-detect the newer static methods before relying on them, and keep the `try`/`catch` fallback for older engines. ## Why interviewers ask The alternative to this API is string surgery, and string surgery gets encoding, duplicate `?`, and relative resolution wrong in ways that only show up on production data. Knowing that the constructor throws, that it needs a base for relative input, and that a trailing slash changes where a bare relative reference lands is the difference between a URL helper that works and one that quietly produces `https://api.test/v1checkout`.

  • How would you build a request URL relative to the page the user is currently on?
    Pass `location.href` as the base: `new URL('/api/items', location.href)`. If the document has a `<base>` element you may want `document.baseURI` instead, since that is what relative links in the markup resolve against. Either way you get an absolute URL back, with the current scheme, host and port already filled in.
  • What is the difference between resolving against 'https://api.test/v1' and 'https://api.test/v1/'?
    Only for *bare* relative input. With the base `/v1`, `new URL('items', base)` gives `/items` — the last segment `v1` is treated as a file name and replaced. With `/v1/`, it gives `/v1/items`. Path-absolute input like `'/items'` ignores the difference entirely. This is why API base URLs are conventionally written with a trailing slash.
  • Is url.origin writable, and how do you point an existing URL object at a different host?
    `origin` is read-only — assigning to it silently does nothing in sloppy mode and throws in strict mode. Set the individual components instead: `url.protocol`, `url.hostname`, and `url.port` are all writable, and each assignment re-serialises `url.href`. Often it is simpler to construct a fresh `URL` from the new absolute string.

saying these in an interview costs you the question

  • Claiming new URL of a bad string returns null
  • Thinking the base is used even for absolute input
  • Assuming 'items' resolves under the base's full path
  • Using split('?') or a regex instead of the parser
  • Believing url.port is a number, not a string

context

open as a page

Why does new URLSearchParams({ q: 'a b' }).toString() produce q=a+b while encodeURIComponent('a b') produces a%20b, and when does that difference cause a bug?

level: middleimportance: should knowfreq 50%

basics

~20 s

URLSearchParams serialises with the application/x-www-form-urlencoded rules, which encode a space as +, while encodeURIComponent uses generic percent-encoding and produces %20. Bugs appear when a value containing a literal + is concatenated into a query string by hand and read back as a space.

open as a page

Is the object returned by a URL instance's searchParams property a live view of that URL or a detached snapshot, and what breaks in a helper that works on new URLSearchParams(url.search) instead?

level: middleimportance: should knowfreq 45%

basics

~20 s

It is a live view: the same URLSearchParams object is returned every time, and mutating it rewrites the URL's search and href. A helper that builds new URLSearchParams(url.search) gets a detached copy, so its changes are silently lost unless it assigns the result back.

open as a page

A query string is ?tag=new&tag=sale. Using URLSearchParams, what does get('tag') return, and how do append() and set() differ when the key already exists?

level: middleimportance: should knowfreq 58%

basics

~20 s

URLSearchParams keeps every repeated key as its own entry: get('tag') returns only the first value, 'new', and getAll('tag') returns ['new','sale']. append() adds another entry, while set() collapses all existing entries for that key into one.

open as a page

Your client-side request cache stores responses keyed by URL string, and identical requests keep missing the cache. Using the URL and URLSearchParams APIs, how would you canonicalise a URL into a stable key, and what does the parser normalise for you already?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Parse with the URL constructor, which already lower-cases the scheme and host, drops a default port and resolves dot segments, then finish the job yourself: drop the fragment, sort the query with URLSearchParams.sort(), and remove parameters that do not affect the response.

open as a page