Ch. 20 · Networking

Networking: HTTP Redirects

Test 301, 302, 303, 307 and 308 against a local Node server with curl and fetch, and see which codes turn a POST into a GET and why.

~6 min readintermediateupdated Oct 19, 2026

A redirect is a response that says “not here, try this URL instead” through a 3xx status and a Location header. That part is simple. The bugs come from two details people mix up: whether the client may change the request method when it follows the redirect, and how long clients remember a redirect they’ve seen. Both depend entirely on which of the five common codes you send.

Rather than repeat the spec, this note runs each code against a small Node server on localhost and shows what curl and Node’s fetch actually do.

Five codes, two questions

Every redirect answers two questions: is this move permanent, and must the client repeat the same method and body?

Code Name Permanent? Method kept?
301 Moved Permanently yes not guaranteed (POST becomes GET in practice)
302 Found no not guaranteed (POST becomes GET in practice)
303 See Other no no, always follow with GET
307 Temporary Redirect no yes
308 Permanent Redirect yes yes

The odd rows are history. The original HTTP/1.0 spec said a client following a 302 shouldn’t change the method, but browsers changed POST to GET anyway, and so many sites depended on that behaviour that it couldn’t be undone. HTTP/1.1 then added 303 to name the “switch to GET” behaviour explicitly and 307 to name the “really keep the method” behaviour. 308 came later (RFC 7538, now folded into RFC 9110) as the permanent twin of 307. So 301 and 302 are effectively “the old ambiguous ones”, and 307 and 308 are their strict versions.

Running every code against a local server

Here’s a server that answers /old-301, /old-302 and so on with that status and Location: /target, and echoes the method and body it receives at /target:

// redirects.mjs
import http from 'node:http';

http.createServer((req, res) => {
  let body = '';
  req.on('data', (c) => (body += c));
  req.on('end', () => {
    const m = req.url.match(/^\/old-(\d{3})$/);
    if (m) { res.writeHead(Number(m[1]), { Location: '/target' }); return res.end(); }
    if (req.url === '/loop-a') { res.writeHead(302, { Location: '/loop-b' }); return res.end(); }
    if (req.url === '/loop-b') { res.writeHead(302, { Location: '/loop-a' }); return res.end(); }
    if (req.url === '/target') return res.end(`${req.method} /target body=${JSON.stringify(body)}\n`);
    res.writeHead(404); res.end();
  });
}).listen(8080, '127.0.0.1');
JavaScript

Send a form POST to each one with curl, following redirects (-L):

$ for c in 301 302 303 307 308; do printf "$c: "; curl -s -L -d 'qty=2' http://127.0.0.1:8080/old-$c; done
301: GET /target body=""
302: GET /target body=""
303: GET /target body=""
307: POST /target body="qty=2"
308: POST /target body="qty=2"
Terminal

Node’s fetch (undici, in Node 22) behaves the same way, because it follows the Fetch standard, which codifies what browsers do:

for (const c of [301, 302, 303, 307, 308]) {
  const r = await fetch(`http://127.0.0.1:8080/old-${c}`, { method: 'POST', body: 'qty=2' });
  process.stdout.write(`${c}: ${await r.text()}`);
}
// 301: GET /target body=""
// 302: GET /target body=""
// 303: GET /target body=""
// 307: POST /target body="qty=2"
// 308: POST /target body="qty=2"
JavaScript

The verbose curl output shows the switch happening. For the 302 it logs * Switch from POST to GET before the second request; for the 307 there’s no such line and the second request is another POST with the body.

One curl quirk is worth knowing because it misleads people while debugging. If you force the method with -X POST and follow a 302, curl still applies the switch internally, so it drops the body, but it prints POST on the request line because you told it to:

> POST /old-302 HTTP/1.1
< HTTP/1.1 302 Found
* Switch from POST to GET
> POST /target HTTP/1.1
Text

The server receives POST /target body="", an empty POST that matches neither behaviour. If you genuinely want curl to resend the POST after a 301 or 302, use --post301 or --post302 instead of -X.

Permanent means cached

A 301 or 308 tells clients and caches the old URL is gone for good. Browsers take that literally: Chrome and Firefox store permanent redirects in their HTTP cache, and with no Cache-Control header they can keep them for a very long time. The next time a user types the old URL, the browser jumps to the new one without contacting your server at all.

That’s fine when the move is real. It’s painful when it isn’t. Suppose a deploy accidentally sends 301 Location: /maintenance from your home page for ten minutes. You fix the server, but every browser that saw the 301 keeps going to /maintenance, and your server never sees those requests, so you can’t redirect them back. Your options are poor: users can clear their cache, or you make /maintenance itself redirect to /.

Two habits avoid this. Use 302 or 307 for anything you might revert (A/B tests, maintenance pages, geo-routing, login bounces). And when you do send a permanent redirect for the first time, consider an explicit Cache-Control: max-age=3600 until you’re sure, so a mistake expires within an hour.

Temporary redirects aren’t cached unless you send cache headers that say otherwise, which is why login flows use them.

Loops, chains and Location details

Clients cap how many redirects they’ll follow. Against the /loop-a and /loop-b pair:

$ curl -sS -L --max-redirs 5 http://127.0.0.1:8080/loop-a
curl: (47) Maximum (5) redirects followed
Terminal

Node’s fetch gives up after 20 with TypeError: fetch failed whose cause is redirect count exceeded, and Chrome shows ERR_TOO_MANY_REDIRECTS. A classic way to build a loop by accident: the app redirects HTTP to HTTPS, but TLS terminates at a load balancer that forwards plain HTTP to the app. The app sees every request as HTTP and redirects forever. The fix is to trust the proxy’s X-Forwarded-Proto header rather than the connection the app received.

Chains are the slower cousin of loops. http://example.com → https://example.com → https://www.example.com → https://www.example.com/en/ costs a full round trip per hop, and on a fresh connection each hop to a new origin adds DNS, TCP and TLS too. Collapse chains so every old URL points straight at the final one.

Location can be relative (/target); the client resolves it against the URL it requested. Also, a Location value built from user input (/login?next=https://evil.example) is an open redirect, which phishing pages use to borrow your domain’s credibility. Only redirect to paths on your own site, or to an allow-list of hosts.

303 and the Post/Redirect/Get pattern

The one place you want the method to change is after a form submission. If a checkout form POSTs to /orders and the server answers 200 with a confirmation page, pressing refresh asks “resubmit the form?”, and a user who clicks yes orders twice. Answer the POST with 303 See Other and Location: /orders/8812 instead. The browser loads the confirmation with GET, refresh repeats only the GET, and the back button behaves normally. 302 happens to work too, but 303 says what you mean.

Interview exercise: moving an API endpoint

Your public API is renaming POST /v1/payments to POST /v2/payments with the same request body. Partners’ integrations still call v1. Which redirect would you send, and would you rely on it at all?

Answer and the reasoning behind it

If I redirect, it’s 308. The move is permanent, and the method and body must survive; 301 would let a client follow with a GET and no body, which either fails confusingly or, worse, hits a GET handler. 307 would also keep the method but implies the old URL might come back.

But I wouldn’t rely on a redirect for writes. Many HTTP clients don’t follow redirects for non-GET requests by default, some won’t resend a body, and a redirect doubles round trips on every payment call. I’d serve both paths from the same handler during a deprecation window, announce a cutoff date, add a Deprecation or Sunset header on v1 responses so partners notice, and watch v1 traffic until it drops to zero. The redirect is a fallback for stragglers, not the migration plan. An interviewer will also appreciate a mention that permanent redirects get cached, so you’d test the 308 on a staging host before shipping it.

Further reading

More in Networking

read ✓Networking · mid

HTTP ETags and Conditional Requests

Use ETags for cheap 304 revalidation and to stop lost updates with If-Match, shown with curl against a small server that two editors share.

~6 min readread →
read ✓Networking · mid

HTTP Cache-Control and Response Freshness

Who may store a response, how long it stays fresh and when it must be revalidated, shown with a toy CDN that leaks one user's page to another.

~6 min readread →
read ✓Networking · mid

HTTP Status Codes and Client Decisions

Read HTTP status codes as instructions: retry, fix the request, re-authenticate or check first, with a client decision function and its output.

~7 min readread →
esc