301
The old URL is retired and the one in `Location` replaces it — and because a 301 is one of the few responses a cache may store without being told to, a 301 sent by mistake is the hardest redirect to take back.
The same three digits are three different problems depending on which side of the request you are on. Read the block that describes you.
Why you are seeing it
You will rarely see the 301 itself, because `fetch()` defaults to `redirect: "follow"` and XHR always follows, so your code is handed the response from the *other* URL with `response.redirected === true` and a `response.url` that is not the URL you asked for. Two things go wrong behind that. Your POST may have been re-issued as a GET — RFC 9110 permits exactly that on a 301 — so the server logged a GET with no body and your handler looks broken rather than un-called. And a cross-origin redirect starts a fresh request that must satisfy CORS on its own, so a redirect to a host that sends no `Access-Control-Allow-Origin` is reported by the browser as a CORS failure on the original URL, hiding the hop entirely. On top of that the browser caches the redirect, so once you have loaded the wrong one, your machine keeps taking it after the server has been fixed.
What to do
Read `response.redirected` and `response.url` before you trust the payload, and log the difference — that one line converts a mystery into a redirect you can go and delete. In DevTools, tick "Disable cache" and turn on "Preserve log", because a navigation-level redirect is erased from the panel by the navigation that follows it. To find out whether the redirect still exists at all, run the same URL through curl: curl keeps no cache, so a redirect the browser takes and curl does not is stored on your machine, not on the server. Do not rely on a redirect inside an API call — point the client at the canonical URL and spend the round trip on the request instead of on the detour. `redirect: "manual"` is a poor debugging tool in a browser: it hands you an opaque-redirect response with status 0 and no readable `Location`, which tells you a redirect happened but never where it went.
Why you are seeing it
Either you shipped a 301 you now regret, or the redirect exists but points somewhere wrong. The wrong-target family is almost always the proxy: your application builds the absolute URL out of what it believes the request was, and behind a TLS-terminating proxy it believes the scheme was `http` and the host was an internal name. In nginx the same class of bug has three named knobs — `absolute_redirect`, `server_name_in_redirect` and `port_in_redirect` — and a redirect that arrives with `:8080` on the end is the last one. The loop family has one famous cause worth checking before anything else: Cloudflare documents that its Flexible encryption mode, combined with an origin that redirects HTTP to HTTPS, produces an infinite redirect loop, because the origin only ever sees plain HTTP and keeps saying "go to HTTPS" to a request that already was.
What to do
While you are still deciding, redirect with 302 and promote to 301 only once the destination is confirmed, because a 302 is not cacheable by default and a 301 is. When you do ship the 301, send an explicit `Cache-Control: max-age=` with it so the blast radius has an end date instead of a heuristic one. Behind a proxy, forward and trust `X-Forwarded-Proto` and `X-Forwarded-Host` and check the scheme the framework thinks it is answering, or take the absolute URL out of the equation with a relative `Location` (`absolute_redirect off;`). To undo a 301 that is already out there, publish a redirect the other way — but be honest with yourself about what that buys: it only takes effect for clients whose stored copy has expired or was never made, and there is no purge for a redirect sitting in someone else's browser.
Why you are seeing it
The site has moved the page and your browser is being told to go to the new address, which is why the address bar changes to something you did not type. That is normal. It stops being normal in two shapes: the address jumps somewhere clearly wrong, or the browser gives up with "too many redirects" because two rules are pointing at each other. Both of those are the site's configuration, not your device — and the reason the wrong one can persist even after the site is repaired is that a permanent redirect is one your browser is allowed to remember and repeat without checking.
What to do
Open the same address in a private or incognito window first. That window starts without the remembered redirect, so if the page works there, the stale rule is on your machine and clearing the cached files for that site in the browser's settings will clear it. In Chrome the setting is under Privacy and security, and clearing "Cached images and files" is what removes stored redirects. If the private window loops too, nothing you can do locally will help — the loop is on the site, and the useful thing you can send them is the address you started from and the address it ends on.
Run this against the URL that failed. It prints the status without the error page, so you can see what the server said rather than what the browser rendered.
curl -sS -D - -o /dev/null -L -w '\n%{num_redirects} hops, final %{http_code} at %{url_effective}\n' http://example.com/old-page`-D -` dumps the response headers of every hop to stdout rather than only the last, `-o /dev/null` throws each body away, `-L` follows the chain, and `-w` prints how many hops were taken, the final status and the URL that produced it. Read it top down. Each status line tells you whether that hop was a 301 or a 302, which decides whether a cache is allowed to keep it. Each `location:` tells you the shape of the target — an absolute `http://` on a site that then redirects back to HTTPS is a loop; an internal hostname or a stray `:8080` is a proxy that told the application the wrong thing about the request. And on the 301 itself, look for `cache-control` or `expires`: if neither is there, the response is still heuristically cacheable under RFC 9110 §15.1, and that is the version of the redirect your visitors will keep. Two more moves make this decisive. curl holds no cache, so if the browser redirects and this command does not, the redirect only exists on your machine. And curl documents that it downgrades POST to GET when following a 301, 302 or 303 — so running the same command with `--post301` and comparing what the server logs is how you prove whether the redirect is what is eating your request body.
RFC 9110 §15.4.2 defines 301 as the target resource having been assigned a new permanent URI, with the server generating a `Location` field holding the preferred one, and it explicitly allows a user agent to rewrite references it holds for the old URI. Three properties of that definition are what people actually trip over. First, permanence is a promise to caches, not a note in your config: RFC 9110 §15.1 lists 301 among the status codes that are heuristically cacheable, so a 301 carrying no `Cache-Control` at all may still be stored and reused — by a CDN, by a proxy and by the browser itself — and reused without asking you again. Second, the method is not guaranteed to survive: §15.4.2 says that for historical reasons a user agent MAY change the request method from POST to GET on the subsequent request, and names 308 (RFC 7538) as the code to use when that behaviour is undesired. Third, `Location` is a URI-reference and RFC 9110 §10.2.2 allows it to be relative, resolved against the target URI — so a redirect that looks fine in your config can still land on the wrong scheme, host or port once whatever generated it has been told the wrong thing about the request it is answering. Nothing in the specification gives you a way to withdraw a 301 that has already been stored somewhere else, which is the single fact that should decide whether you ship one today.
| Confused with | How to tell them apart |
|---|---|
308 | 308 (RFC 7538) is the permanent redirect that guarantees the method and body are replayed unchanged, which is exactly the guarantee RFC 9110 §15.4.2 withholds from 301 when it permits a user agent to turn a POST into a GET. Both are cacheable by default, so 308 is every bit as sticky — the choice between them is about the method, not about how long the redirect lives. |
| 302 | Permanence is the visible difference and cacheability is the one that costs money. 301 is in RFC 9110 §15.1's list of heuristically cacheable status codes; 302 is not, so a 302 with no explicit `Cache-Control` is not stored and can be changed freely on the next request. That is the whole argument for redirecting with 302 until you are sure, then promoting. |
HSTS | Not every jump from `http://` to `https://` is a redirect from your server. Once a host has sent `Strict-Transport-Security`, the browser rewrites the URL before any request leaves the machine — Chrome's network panel shows this as `307 Internal Redirect` with `Non-Authoritative-Reason: HSTS`. It has no server-side counterpart to delete, so hunting your config for the redirect that causes it finds nothing. |
Cloudflare | A redirect can be issued at the edge before your origin is consulted at all — Cloudflare's Always Use HTTPS, Bulk Redirects and redirect Rules all produce one — so a 301 that is nowhere in your server config can still be real. The matching trap is the other direction: Cloudflare documents that Flexible encryption plus an origin that redirects HTTP to HTTPS is an infinite loop, because the origin never sees the HTTPS it is asking for. |
All of these run in your browser — nothing is uploaded.
Codes people usually end up reading in the same session as 301.
Read the response headers rather than the page. curl -sS -o /dev/null -D - https://example.com/path prints them without the body, and server, via and cf-ray between them name the layer that answered. A cf-ray value means Cloudflare handled the response and is the id their support will ask for. To take the edge out of the picture entirely, repeat the request with --resolve example.com:443:203.0.113.10, which connects to the origin address you name while still sending the original host name and SNI — if the answer changes, the edge and the origin disagree.
No, and you should never branch on it. RFC 9112 tells clients to ignore the reason phrase, servers are free to change it, and HTTP/2 and HTTP/3 do not carry one at all — so 404 Not Found over HTTP/1.1 arrives as a bare 404 over HTTP/2. The phrases on this site are the registered ones because they are what people search for and what appears in an HTTP/1.1 log, not because any software depends on them.
Retry 5xx and 429; do not retry 4xx, because nothing about the request will be different next time. If the response carries Retry-After, honour it — RFC 9110 defines it for exactly this, and it may be either a number of seconds or a date. Otherwise use exponential backoff with jitter and a hard cap, and only for idempotent methods: a retried POST can charge a card twice. A retry storm against a server that is already failing is how a brief incident becomes a long one.
Yes, and the only rule that matters is that the code has to be true. Anything that reads your responses automatically — search engines, monitoring, caches, client retry logic — makes decisions from the number and never from the page. Serving an error page with 200 hides the failure from your own alerting; serving 200 for a missing page gets the error indexed as content; returning 500 for a request that was simply malformed sends whoever is on call to the wrong half of the stack.
Still stuck on 301? browse every status code in the reference — or go back up to the block written for your side of the stack.