HTTP semantics: status codes, negotiation and redirects
A status code is data that fetch() hands back, not a verdict; and a redirect repeats the request unchanged only when its code promises to.
Predict first
Before you read: predict
ExamHall's extra-time request form runs await fetch('/api/requests', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }). The API team has moved the route, and /api/requests now answers 301 Moved Permanently with Location: /api/v2/requests.
What does /api/v2/requests receive?
The 30-second version
A status code's first digit is its class: 2xx success, 3xx redirect, 4xx client error, 5xx server error, and fetch() resolves for all of them. Accept headers weight formats with q; the server picks one. Browsers follow redirects: 307 and 308 repeat the method and body; 301 and 302 turn a POST into a bodyless GET; 303 fetches with GET.
A status code is data that fetch() hands back, not a verdict; and a redirect repeats the request unchanged only when its code promises to.
Step through the code
Trace of the opening question
Play options
Ready: press Play or step with Next frame
Row 1 of 3 · Hop 1
Starting row · computed by comparing each row with the one before
- Hop: 1
- Browser sends:
POST /api/requestswith the JSON body - Server replies: 301,
Location: /api/v2/requests
Rule · step 9
A redirect is a 3xx with a Location URL. Browsers and fetch() follow 301, 302, 303, 307 and 308 by themselves, up to 20 hops.
Rule · step 10
301 and 308 say "moved permanently"; 302 and 307 "temporarily"; 303 "see the result at this other URL".
- Hop
- 1
- Browser sends
POST /api/requestswith the JSON body- Server replies
- 301,
Location: /api/v2/requests - Step
- 9, 10
| Hop | Browser sends | Server replies | Step |
|---|---|---|---|
| 1 | POST /api/requests with the JSON body | 301, Location: /api/v2/requests | 9, 10 |
| 2 (changed) | GET /api/v2/requests, no body (changed) | 200 (changed) | 12 |
| page (changed) | fetch() resolves with the final response, res.redirected true (changed) | (changed) | 5, 9 |
Space plays or pauses · ←/→ one row · Home/End first/last
Rows come from the pack's own trace table.
How it works
Step 1 of 13
See it run
Not in this run
In the kitchen
- the slip's colour → the status class (step 1)
Analogy: A high-street pharmacy counter
A customer hands a prescription across the counter and gets a coloured slip back: green means done, amber go elsewhere, red your prescription is wrong, grey our fault. The slip is information, not the medicine.
| At the pharmacy | In HTTP |
|---|---|
| the slip's colour | the status class (step 1) |
| an unfamiliar shade of red, read as plain red | an unknown code treated as x00 (step 2) |
| "tablets preferred, liquid at a pinch, never capsules" | weights, with q=0 (step 6) |
| "this branch moved for good; take the same prescription to the new address" | 308 (steps 10–11) |
| "we have filed it; collect the medicine at the side window" | 303 (step 13) |
| walking to the new address empty-handed, just to ask | a POST re-sent as a bodyless GET (step 12) |
In real terms: the status says what happened; the client reads it. A redirect sends the client on, and the code decides whether the method and body go too.
Breaks down when: a customer can decide to carry the prescription along. A browser cannot: the code decides. Wrong inference: "my code can keep the POST on a 302". It cannot in a browser; the server must send 307 or 308.
Contrasting case: the pharmacist who fills the prescription, then hands the medicine straight over the counter. That is a 200 with the result in its body: nothing sends the customer anywhere, so nothing is rewritten.
Examples, run for real
Every example ran in Chromium against a local origin that logs each request it receives.
4.1 ExamHall: submissions fail after a route move
EXH-1370: the HIST-104 submit route moved to /api/v2. The old route answered 302, and the new one accepts only POST.
302 (as shipped) new route receives GET with no body -> 405; candidate sees "Submit failed (405)"
307 (contrast ) new route receives POST with body "{\"answers\":40}" -> 200; candidate sees "Submitted"
308 (MR !1570 ) new route receives POST with body "{\"answers\":40}" -> 200; candidate sees "Submitted"
The 302 turned the POST into a GET without its answers (step 12), so the new route replied 405 (step 3). MR !1570 answers 308: permanent, and the method and body are kept (steps 10–11).
4.2 ExamHall: "Saved" during the exam-start stampede
EXH-1371: at 09:00 the autosave API was overloaded and answered 503 with Retry-After: 5. The shipped code showed "Saved" whenever fetch() resolved.
503 + Retry-After: 5 shipped shows "Saved" | MR !1571 shows "Not saved (503); retry in 5 s"
404 shipped shows "Saved" | MR !1571 shows "Not saved (404)"
socket destroyed shipped shows "Offline" | MR !1571 shows "Offline"
fetch() resolved for the 503 and the 404; only the dropped connection rejected (step 5). MR !1571 checks res.ok and reads Retry-After (step 4).
4.3 Proof domain, ticketing: one seat list, two formats
/api/events/412/seats exists as JSON and CSV. The server honours weights and answers 406 when nothing listed is available.
navigation Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 -> 200, Content-Type application/json (via */*;q=0.8)
fetch Accept: */* -> 200, Content-Type application/json
fetch Accept: text/csv -> 200, Content-Type text/csv
fetch Accept: application/xml -> 406, Content-Type text/plain
A plain fetch() sends */*, so the server's first choice comes back. A named type gets that type, labelled (steps 6–7). For XML this server chose 406; HTTP also allows it to send JSON instead (step 7).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A fan's session has expired. The seat page runs const res = await fetch('/api/tickets/88'); if (res.ok) render(await res.json()); else showSignIn();. For signed-out users the API answers 302 Found with Location: /login, and /login returns the HTML sign-in page with a 200.
What happens?
Question 2 of 5
An event's calendar entry exists as JSON and as iCalendar (text/calendar); JSON is the server's own first choice. The server sends the offered type to which the request's header gives the highest weight. A fan's app asks with Accept: text/html, application/json;q=0, */*;q=0.3.
What comes back?
Question 3 of 5
A ticket-transfer page is an HTML form that POSTs to /transfer. The team wants the fan to land on a receipt page, and a reload of that page must not run the transfer again. /receipt shows the receipt on a GET.
How should /transfer answer the POST?
Question 4 of 5
A hold request is fetch('/api/holds', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }). It now passes three redirects: /api/holds answers 307 to /gw/holds, the gateway answers 302 to /edge/holds, and the edge answers 308 to /v2/holds. /v2/holds logs a GET with no body.
At which hop did the POST become that GET?
Question 5 of 5
A stadium gate scanner sends each scan with fetch('/api/scans', { method: 'POST', body: JSON.stringify(scan) }). Every scan is refused with 415 Unsupported Media Type, although the body itself is valid JSON.
Which one change gets the scans admitted?
Go deeper
Pro tips
- Read a redirect chain from Node with
redirect: 'manual'. Node'sfetchfollows redirects like a browser; withredirect: 'manual'it hands you the 3xx and itsLocation, so you can see what a route really answers. When: after moving a route (steps 9–12). Verify (RAN, Node 24.19.0,tips.mjs, 50 + 50 runs, 1 distinct):
In a page,tip 1 Node fetch redirect: 'follow' -> 200, redirected true tip 1 Node fetch redirect: 'manual' -> 301, Location /new, redirected falseredirect: 'manual'returns an opaque response you cannot read (Fetch Standard, "opaque-redirect filtered response"; not run here). - Don't pair
curl -Lwith-X POST.-Xforces the method on every hop, so curl re-POSTs even where a browser would switch to GET; plain-dlets curl follow the codes. When: reproducing a redirect bug from a terminal (steps 12–13). Verify (RAN, curl 8.17.0 against a local 303,tips.mjs):tip 2 curl -L -d x=1 -> server saw: POST /old303 | GET /new (exit 0) tip 2 curl -L -X POST -d x=1 -> server saw: POST /old303 | POST /new (exit 0)
Source
- If no 'q' parameter is present, the default weight is 1"); §12.5.1
Accept(https://www.rfc-editor.org/rfc/rfc9110#section-12.5.1): "Media ranges can be overridden by more specific media ranges or specific media types. Valid as of 2026-09-28. - Fetch Standard: https://fetch.spec.whatwg.org/ · fetched 2026-09-28. §2.2.3: "An ok status is a status in the range 200 to 299, inclusive"; "A redirect status is a status that is 301, 302, 303, 307, or 308" (steps 5, 9). HTTP-redirect fetch (https://fetch.spec.whatwg.org/#http-redirect-fetch): "If request's redirect count is 20, then return a network error"; if the status "is 301 or 302 and request's method is
POST", or "is 303 and request's method is notGETorHEAD", then "Set request's method toGETand request's body to null" and delete the request-body headers (steps 9, 12, 13). Body extraction: a string body gets typetext/plain;charset=UTF-8.fetch()rejects only when the response is a network error (step 5). Body mixin (https://fetch.spec.whatwg.org/#dom-body-json, re-read 2026-09-28): "The json() method steps are to return the result of running consume body with this and parse JSON from bytes", so HTML in the body rejects theres.json()promise. Redirects apply hop by hop: each HTTP-redirect fetch tests the request's current method, so a GET produced by one hop stays a GET through a later 307 or 308. Valid as of 2026-09-28. - MDN, Redirections in HTTP: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Redirections · permanent vs temporary codes and the POST→GET change on 301/302; 303 for the result of a POST. Supports steps 10–13. Not re-fetched this session.
- MDN, An overview of HTTP (curriculum-v2.json source): https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview · the request/response message model. Not re-fetched this session.
- curl manual,
-X: https://curl.se/docs/manpage.html#-X · the forced method is used for all requests, so with-Lcurl does not change the method on a 30x as it otherwise would. Pro tip 2 was executed; the wording is not quoted.
Checked against the tools and docs as of 2026-09-28.