Skip to content
Control Plane Labs

404 Not Found

Client error response defined by RFC 9110 §15.5.5.

Last updated July 27, 2026

Common causes at a glance

  • Trailing-slash mismatch between the router and the deployed path
  • Case sensitivity differing between a local filesystem and the production one
  • A rewrite or base-path rule that does not match the URL the CDN forwards
  • Content deleted or unpublished while inbound links and sitemaps still point at it

Reproduce it

curl -i https://www.example.com/old-page

What 404 Not Found means

404 is a statement about a URL, not about a file. The resource may never have existed, may have moved, may be hidden deliberately, or may exist while your routing table fails to reach it. That last case costs the most engineering time: the content is fine and the request never arrives at the handler that serves it.

404 is also heuristically cacheable, so a bad deploy that 404s a live URL persists in CDN and browser caches after the fix ships. And a page returning 200 with “sorry, nothing here” — a soft 404 — is worse than an honest one: crawlers index it, monitoring never alerts, and link checkers report the site as healthy.

What the spec says

RFC 9110 §15.5.5 defines 404 as no current representation found, explicitly not distinguishing temporary from permanent, and states that 410 is preferred when the origin knows the condition is likely permanent. A 404 is heuristically cacheable unless explicit cache controls say otherwise, per RFC 9111 §4.2.2 — a detail people rediscover when a stale 404 survives a fix. The permission to lie is elsewhere: §15.5.4 allows an origin to return 404 in place of 403 to hide a resource’s existence, so a 404 from a hardened endpoint proves nothing about the path.

What actually causes it

Routing beats missing content as a cause. Trailing slashes are the classic: a static host serving /about/index.html at /about/ will 404 /about, and the two diverge again once a CDN normalises paths. Case sensitivity is second — a macOS developer machine happily serves /Assets/Logo.png and the Linux container does not. Base paths are third: an app mounted at /app behind a prefix-stripping proxy generates links nobody can follow. All three appear only in production.

For public sites the expensive variant is the soft 404: 200 with an error page. Google treats those as low-quality duplicates, as its 404 guidance describes. Client-rendered apps produce them by default, because the shell returns 200 before the router decides the route does not exist.

How to debug it

Confirm the exact bytes the server saw, since the status is a function of the target string. Compare the obvious variants in one pass:

for p in /about /about/ /About; do
printf '%s ' "$p"
curl -o /dev/null -s -w '%{http_code}\n' "https://www.example.com$p"
done

Different results across those three localise the bug to routing, normalisation, or case handling immediately. Next, ask the origin directly with --resolve; if it serves the page and the CDN 404s it, an edge rewrite is misrouting the path. For soft 404s, check the status rather than the rendered page — a URL you know is dead should print 404, not 200. Fix that first, because monitoring and search engines depend on it.

Working examples

curl

# Trailing slash, case, and normalisation in one sweep.
for p in /about /about/ /About; do
  printf '%s ' "$p"
  curl -o /dev/null -s -w '%{http_code}\n' "https://www.example.com$p"
done

# Is the edge or the origin producing it?
curl -o /dev/null -s -w '%{http_code}\n' \
  --resolve www.example.com:443:203.0.113.10 https://www.example.com/about

# Follow the redirect chain that may be dropping you on a dead URL.
curl -sIL https://www.example.com/old-page | grep -iE '^(HTTP/|location)'

Python (requests)

import requests

paths = ["/about", "/about/", "/About", "/app/about"]
for p in paths:
    r = requests.head(f"https://www.example.com{p}", allow_redirects=False, timeout=10)
    print(f"{p:12} {r.status_code} {r.headers.get('Location', '')}")

# Soft-404 detector: a 200 that looks like an error page is worse than a 404.
r = requests.get("https://www.example.com/definitely-not-a-real-page", timeout=10)
if r.status_code == 200 and "not found" in r.text.lower():
    print("SOFT 404 — fix the status code, not the copy")

Node (fetch)

const base = "https://www.example.com";

for (const p of ["/about", "/about/", "/About"]) {
  const res = await fetch(base + p, { method: "HEAD", redirect: "manual" });
  console.log(p.padEnd(10), res.status, res.headers.get("location") ?? "");
}

// Confirm the 404 is not being cached longer than you expect.
const dead = await fetch(base + "/old-page");
console.log(dead.status, dead.headers.get("cache-control"), dead.headers.get("age"));

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 4xx codes