Location header: relative vs absolute URLs (tested)
The Location response header tells the client where to go next (on a 3xx redirect) or where a new resource lives (on 201 Created). A relative value is allowed and works: we sent seven kinds of relative Location on all five redirect codes and Chromium, curl and Node.js fetch all ended at the same URL. The differences that bite are elsewhere: method changes, 201, and redirect: 'manual'.
1. Relative Location values: how they resolve
The request URL was http://localhost:8981/a/b/start?.... Each Location value resolves against that URL like a link in an HTML page at the same address. Results were identical for 301, 302, 303, 307 and 308, and identical in all three clients:
Location: sent | Final URL (Chromium 153, curl -L, Node fetch) |
|---|---|
next | /a/b/next (replaces the last path segment) |
/next | /next (same scheme, host and port) |
../x | /a/x |
//127.0.0.1:8982/x | http://127.0.0.1:8982/x (protocol-relative: a different host) |
?q=1 | /a/b/start?q=1 (same path, query replaced) |
/a b (raw space) | /a%20b: all three percent-encoded it and followed |
/né (raw UTF-8 bytes) | /n%C3%A9 in Chromium and Node; curl requested /n%c3%a9 (lowercase hex) |
So "relative Location does not work" is almost never the client. The usual bugs are a missing leading slash (next from /a/b/start lands on /a/b/next, not /next), a // prefix that turns your path into a host name, or a proxy in front of the app that rewrites the host or scheme. Absolute URLs avoid the first two, but then the app must know its public scheme and host. Send percent-encoded values yourself rather than relying on clients to clean up spaces and non-ASCII: we saw them cope, but the header is meant to carry an encoded URI.
Without -L curl just prints the raw value, which is the quickest check:
$ curl -si 'http://localhost:8981/a/b/start?code=302&to=next' | head -2
HTTP/1.1 302 Found
Location: next
2. Fragments
Location: /next#f: Chromium ended at/next#f; curl reported/next#fas the effective URL; Node fetch'sres.urlwas/next(fragment dropped). None of them sent the fragment to the server.- Fragment inheritance: in Chromium, opening
start?...#origthat redirects to/nextended at/next#orig. If the Location has its own fragment (/next#new) that one wins.
3. Which method arrives after the redirect
We sent POST with body a=1 and logged what reached /next:
| Code | Chromium form submit and fetch() | Node fetch | curl -L -d a=1 | curl -L -X POST -d a=1 |
|---|---|---|---|---|
| 301 | GET, no body | GET, no body | GET, no body | POST, no body |
| 302 | GET, no body | GET, no body | GET, no body | POST, no body |
| 303 | GET, no body | GET, no body | GET, no body | POST, no body |
| 307 | POST a=1 | POST a=1 | POST a=1 | POST a=1 |
| 308 | POST a=1 | POST a=1 | POST a=1 | POST a=1 |
Use 303 after a successful form POST (post/redirect/get), and 307/308 when the method and body must be kept, for example when moving an API endpoint. With curl, -X POST forces the method name on every hop but the body is still dropped on 301-303; use --post301 --post302 --post303 instead, which kept both POST and a=1 in our test.
4. Location on 201 Created (and 200) is not followed
With Location: /next on a 200, 201 or 202, no client moved: Chromium page navigation stayed on the original URL, fetch() in Chromium and Node returned the 201 itself, and curl -L stopped at 201. On a 204 navigation Chromium aborted (net::ERR_ABORTED) and stayed on the previous page. For a 201, Location is information for the client: the URL of the new resource. Read it and resolve it yourself:
const res = await fetch('/api/items', { method: 'POST', body });
if (res.status === 201) {
const loc = res.headers.get('location'); // "/next": the raw, possibly relative value
const url = new URL(loc, res.url).href; // resolve against the response URL
}
In the browser, a cross-origin API must list it in Access-Control-Expose-Headers: Location, or headers.get('location') returns null (our test server exposed it).
5. Reading Location of a redirect from JavaScript
| Call | Chromium 153 | Node.js 24 fetch |
|---|---|---|
fetch(u, { redirect: 'manual' }) | type: "opaqueredirect", status: 0, ok: false, headers.get('location') = null | type: "basic", real status (301/302/307), headers.get('location') = "/next" |
fetch(u, { redirect: 'error' }) | rejects: TypeError: Failed to fetch | rejects: fetch failed, cause unexpected redirect |
| XMLHttpRequest | always follows; status 200, responseURL = final URL | n/a |
Page JavaScript cannot see where a redirect points, even same-origin and even with Access-Control-Expose-Headers: Location. If the frontend needs the target, have the API return 200 or 201 with the URL in the body or in a non-redirect Location. On the server (Node), redirect: 'manual' does give you the status and raw header. To see where any URL redirects without code, open it with the HTTP headers checker; for multi-hop chains see how to check a redirect chain, and for PHP's header('Location: ...') pitfalls see PHP header Location redirect not working.
Frequently asked questions
Can the Location header be a relative URL?
Yes. In Chromium 153, curl 8.14.1 -L and Node.js 24 fetch, Location values next, /next, ../x, //host/x and ?q=1 all resolved against the request URL, the same way on 301, 302, 303, 307 and 308.
Why does Location: next go to the wrong path?
Without a leading slash it replaces only the last path segment: from /a/b/start it went to /a/b/next. Use /next for a path from the site root.
Is Location followed on a 201 Created?
No. Chromium navigation, fetch() in Chromium and Node, and curl -L all stopped at the 201 and returned it. Read res.headers.get('location') and resolve it with new URL(loc, res.url).
Does a 302 redirect turn POST into GET?
Yes in practice: browsers, Node fetch and curl -L -d all re-requested with GET and no body after 301, 302 and 303. 307 and 308 kept POST and the body in every client.
Why is the Location header null with fetch redirect: 'manual'?
In the browser, redirect: 'manual' returns an opaqueredirect response with status 0 and no readable headers, by design. Node.js fetch returns the real 3xx status and a readable Location.
Is the URL fragment kept after a redirect?
In Chromium, yes if the Location has no fragment of its own: start#orig redirected to /next ended at /next#orig. A fragment in the Location replaces it. The fragment is never sent to the server.
Written with AI assistance. Measured on 2026-10-02 with Node.js 24.21.0 (plain http.createServer on localhost:8981-8982, built-in fetch as the client too), curl 8.14.1 (-L) and headless Chromium 153.0.8010.12 (Playwright). The server answered /a/b/start with 301, 302, 303, 307 or 308 and a chosen Location value; we recorded the final URL and the method and body each client arrived with. PlainHeaders is our extension; Node.js, curl and Chromium are free.