308 Permanent Redirect
Redirection response defined by RFC 9110 §15.4.9.
Last updated July 27, 2026
Common causes at a glance
- A permanent API version migration that must not lose POST bodies
- Host or path consolidation where writes as well as reads move
- A CDN or edge rule normalising a path with method preservation enabled
- Framework routing configured with a permanent, method-preserving redirect
Reproduce it
curl -sSI -X POST -d 'id=1' https://example.com/v1/orders | grep -iE '^(HTTP|location)'What 308 Permanent Redirect means
308 completes the grid. 301 and 302 are the historical pair whose method handling browsers decided for themselves; 307 and 308 are the modern pair that say exactly what happens.
Combining both properties is worth pausing on. A 308 is cacheable and preserves the
method, so a client can remember that every request to /v1/orders, writes
included, goes to /v2/orders, and stop asking. That is powerful for an API
migration and unforgiving if the target is wrong.
What the spec says
RFC 9110 §15.4.9 defines the permanent assignment and notes the code is much younger than its siblings — June 2014 — so it might not be recognised everywhere, pointing at RFC 7538 §4 for deployment considerations. RFC 7538 was the standalone definition RFC 9110 absorbed; cite 9110 as the current reference. The advice still applies: a client that does not understand 308 treats it by class as 300 and will not follow it, so include a body with a hyperlink to the target. Like 301, a 308 is heuristically cacheable under RFC 9111 §4.2.2.
What actually causes it
The recurring surprise with 308 is age. Old clients, embedded HTTP
stacks, and some corporate proxies predate it and, by the class fallback rule, treat it
as 300 and stop. That shows up as one small slice of traffic
failing while everything modern works. The second problem is permanence: browsers and
shared caches store 308s the way they store 301s, so a mistyped Location
that reaches production is not something a deploy can undo. The third is specific to
method preservation — redirecting an authenticated write cross-origin drops credentials
in browsers and triggers a fresh CORS preflight against the new host, so a redirect
that works in curl fails in the page.
How to debug it
Check the code, the target, and how long it will be remembered, all in the first hop:
curl -sSI -X POST -d 'id=1' https://example.com/v1/orders \
| grep -iE '^(HTTP|location|cache-control)'
Then follow it and confirm the second request line is still POST. If one
client reports a failure curl cannot reproduce, check whether its version follows 308
at all — that is usually the answer, and downgrading to 307 buys
compatibility at the cost of cacheability.
Because there is no way to un-cache a permanent redirect, send
Cache-Control: max-age=3600 on the 308 for the first weeks of a migration
and lengthen it once you are confident.
/tools/http-status/ covers the neighbouring codes if you are
still choosing between them.
Working examples
curl
# Method must survive: both request lines should be POST.
curl -sSv -X POST -d 'id=1' -L https://example.com/v1/orders 2>&1 \
| grep -E '^[<>] (POST|GET|HTTP|location)'
# First hop only, including how long clients will remember the mapping:
curl -sSI -X POST -d 'id=1' https://example.com/v1/orders \
| grep -iE '^(HTTP|location|cache-control)'
Python (requests)
import requests
resp = requests.post("https://example.com/v1/orders", json={"id": 1}, timeout=10)
for hop in resp.history:
print(hop.status_code, hop.request.method, "->", hop.headers.get("Location"))
print("final:", resp.status_code, resp.request.method) # POST preserved
# Confirm the cache lifetime you are committing clients to.
raw = requests.post(
"https://example.com/v1/orders", json={"id": 1},
allow_redirects=False, timeout=10,
)
print(raw.status_code, raw.headers.get("Cache-Control"))
Node (fetch)
// fetch() follows 308 with the method and body intact.
const resp = await fetch("https://example.com/v1/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: 1 }),
});
console.log(resp.status, resp.redirected, resp.url);
// Inspect the redirect itself before trusting it in production.
const raw = await fetch("https://example.com/v1/orders", {
method: "POST",
body: JSON.stringify({ id: 1 }),
redirect: "manual",
});
console.log(raw.status, raw.headers.get("location"), raw.headers.get("cache-control"));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.