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');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"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"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.1The 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 followedNode’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.