301
How to fix HTTP 301 Moved Permanently
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.
Why you are seeing 301, and what to do about it
The same three digits are three different problems depending on which side of the request you are on. Read the block that describes you.
Front-end developer
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.
Back-end developer
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.
End user
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.
Reproduce 301 with curl
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.
What 301 actually means
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.
Codes 301 gets mistaken for
| 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. |
- Reason phrase
- Moved Permanently
- Class
- 3xx redirection
- Defined by
- RFC 9110
- Standard
- IETF standard
Tools on this site
All of these run in your browser — nothing is uploaded.
- Certificate Decoder — when the redirect lands on HTTPS and then fails, read the certificate the destination serves and check its SAN list actually covers that host
- Port Number Reference — check what belongs on the port a redirect appended, when `port_in_redirect` sends visitors to the application's internal port
Related status codes
Codes people usually end up reading in the same session as 301.