Skip to content
Control Plane Labs

301 Moved Permanently

Redirection response defined by RFC 9110 §15.4.2.

Written and maintained by Ben Ennis

Last reviewed July 27, 2026 · How we verify this

Common causes at a glance

  • Canonical host normalisation such as apex to www, or the reverse
  • http to https upgrade at the edge, usually the first hop of a redirect chain
  • Trailing-slash or lowercase-path normalisation by a framework router
  • A CMS slug change writing a permanent redirect from the old URL

Reproduce it

curl -sSI https://example.com/old-page | grep -iE '^(HTTP|location)'

What 301 Moved Permanently means

A 301 is a promise. You are telling every cache, crawler, and browser on the internet that this URL is finished and the new one replaces it. Search engines consolidate ranking signals onto the target, browsers store the mapping in their own redirect cache, and shared caches store the response because 301 is heuristically cacheable with no explicit freshness lifetime.

The consequence people discover too late is that reversing a 301 does not happen on your schedule. A browser that cached /a → /b will not ask your server again before jumping to /b. Point /a back at real content and those users still land on /b until their cache is cleared. There is no purge API for the internet.

What the spec says

RFC 9110 §15.4.2 requires a Location (§10.2.2) carrying the new permanent URI, and notes a user agent MAY rewrite POST to GET on the subsequent request. That “MAY” is not theoretical: every mainstream browser does it, which is why 308 exists for cases where the method must survive. The response is heuristically cacheable under RFC 9111 §4.2.2, so a 301 with no Cache-Control still gets stored. Send an explicit max-age if you want control over how long the mapping lives.

What actually causes it

Most 301s are deliberate infrastructure: one host redirects to the canonical host, cleartext redirects to TLS, a router normalises a trailing slash. The trouble starts when those layers stack and a single request pays four redirects before reaching content. The other recurring problem is HSTS. Once a host has sent Strict-Transport-Security, the browser upgrades http:// internally and reports a synthetic 307, so the 301 you configured is never hit and cannot be debugged from the browser. Permanent redirects written during a migration also have a habit of outliving it: nothing expires them, and years later the chain is still costing every visitor a round trip.

How to debug it

Follow the whole chain and print every hop rather than looking at the final status:

curl -sSIL -o /dev/null -w '%{http_code} %{url_effective}\n' https://example.com/old-page

Use curl -I without -L first to see the raw 301 and its Cache-Control. If a browser behaves differently from curl, suspect the browser’s own redirect cache — reproduce in a private window — and check for HSTS, which preempts the request entirely.

Before shipping a permanent redirect, ship it as a 302 for a week. If the mapping turns out wrong, a 302 costs nothing to reverse. The curl builder assembles the chain-tracing invocation.

Why Chrome’s redirect cache survives even Cache-Control: no-store

The HTTP cache this page describes above is not the whole story in Chromium. Chrome maintains a separate internal redirect mapping, independent of its regular disk cache, and in practice it is far stickier than a normal cached response. Developers who send Cache-Control: no-store, no-cache, must-revalidate specifically to stop a 301 from being remembered routinely find Chrome keeps redirecting anyway, and the fix that actually works is not a cache header at all: reloading with DevTools open and Disable cache checked, using Empty Cache and Hard Reload, or clearing site data for that origin specifically — a plain reload or a normal Cache-Control change does not reliably clear it. This is a long-documented, widely reported browser behaviour rather than a corner case; see the accumulated Stack Overflow thread on the subject, which spans years of browser versions all showing the same symptom.

The practical trap is that this makes 301 uniquely dangerous to test in a normal browser tab: a redirect you configure once, even briefly, on a misconfigured route can outlive the fix in your own working browser, not just your visitors’. The advice elsewhere on this page to ship a change as 302 first is partly a defense against this exact behaviour: a 302 does not get pinned into Chrome’s redirect mapping the way a 301 does, so a wrong 302 disappears the moment you fix the server, while a wrong 301 can require you to manually clear site data before you can even confirm your own fix worked. When you are debugging a 301 that appears to ignore a server-side change, rule out this cache before assuming the deploy did not take effect — test from an incognito window or with curl, both of which bypass it entirely.

Working examples

curl

# -I is HEAD-only; -L follows the chain; -w prints each hop's status and URL.
curl -sSIL -o /dev/null \
  -w '%{http_code} %{url_effective}\n' \
  https://example.com/old-page

# Raw first hop, including how long the mapping is cacheable:
curl -sSI https://example.com/old-page | grep -iE '^(HTTP|location|cache-control)'

Python (requests)

import requests

resp = requests.get("https://example.com/old-page", timeout=10)

# resp.history holds every redirect response in order.
for hop in resp.history:
    print(hop.status_code, hop.url, "->", hop.headers.get("Location"))
print("final:", resp.status_code, resp.url)

# Inspect the 301 itself, including its cache lifetime.
raw = requests.get("https://example.com/old-page", allow_redirects=False, timeout=10)
print(raw.status_code, raw.headers.get("Cache-Control"))

Node (fetch)

// fetch() follows redirects silently; "manual" shows you the 301 itself.
const raw = await fetch("https://example.com/old-page", { redirect: "manual" });
console.log(raw.status, raw.headers.get("location"), raw.headers.get("cache-control"));

// Walk the chain by hand to count hops.
let url = "https://example.com/old-page";
for (let i = 0; i < 10; i++) {
  const r = await fetch(url, { redirect: "manual" });
  console.log(r.status, url);
  if (r.status < 300 || r.status >= 400) break;
  url = new URL(r.headers.get("location"), url).href;
}

Reference and tooling

The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.

Other 3xx codes