Skip to content
Control Plane Labs

301 Moved Permanently

Redirection response defined by RFC 9110 §15.4.2.

Last updated July 27, 2026

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.

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