A 404 Is a Status Code, Not a Page

Open a terminal and ask your production site for a URL that has never existed:
curl -s -o /dev/null -w '%{http_code}\n' https://your-site.com/definitely-not-real-9f3a
If that prints 200, you do not have a 404 page. You have a page that says “404” on it, which is a completely different thing, and nothing except a human being will ever notice the difference.
The artwork is for people. The status line is for everyone else — and everyone else is doing the load-bearing work.
Two audiences, one response
A not-found response has two readers, and they consume different halves of it.
A person reads the body: the apology, the search box, the link back to the homepage. They will figure out what happened regardless of what the header said.
Every machine reads the first line and stops caring. Googlebot decides whether to keep a URL in the index. A CDN decides whether and how long to cache. A link checker decides whether to fail the build. An uptime monitor decides whether to page someone. curl -f decides whether to exit non-zero. None of them render your body, and none of them are going to notice that it contains a large sad robot.
The status code is the only part of a 404 that is machine-readable. Getting it wrong means every automated system that touches your site is working from a response you did not intend to send.
Where the 200 comes from
Static hosts almost never get this wrong on their own. It gets configured wrong, and there are three ways in.
The SPA fallback. Every static host has some version of “serve /index.html for any path that doesn’t match a file.” Netlify calls it a rewrite, Cloudflare Pages calls it single-page-app mode, S3 calls it setting the error document to index.html, and half the framework starter templates ship it enabled. It exists because a client-side router needs to receive control before it can decide a route is invalid.
If your site is prerendered — one HTML file per route, which is the entire point — this setting converts every unmatched path into a 200 serving your homepage shell. Typos, deleted posts, scanner probes, a bad link in someone’s newsletter: all 200 OK.
The client-side “not found” screen. The route resolves, the shell loads, JavaScript reads the path, decides there’s no match, and swaps in a not-found component. The user sees the right thing. The response headers were written and flushed long before any of that happened, and they said 200.
The redirect to /404/. Someone wires unmatched paths to redirect to a real, published /404/ page. Now the crawler follows a 301 to a page that returns 200. You have taken a missing resource and told the internet it is a permanently relocated, perfectly healthy document.
All three look identical in a browser. All three are invisible until you check.
What the soft 404 actually costs you
This is not a purity argument. Each of these is a concrete failure that shows up somewhere else, weeks later, wearing a disguise.
Deleted pages stay indexed. A URL that returns 404 or 410 gets dropped from search results. A URL that returns 200 gets re-crawled, evaluated, and kept — and now you have dozens of near-duplicate results, all serving the same shell, competing with your real pages.
Your CDN caches the wrong answer. A 200 is cacheable under whatever policy governs your HTML. A 404 is not cached as aggressively, and many CDNs won’t cache it at all without being told to. So the not-found shell gets stored at the edge under a URL that might become real next Tuesday, and then your new page is invisible in half the world until the TTL expires.
Link checkers go green. Internal link checking is the cheapest correctness gate a static site has, and it works by asking whether a URL resolves. If every URL resolves, the check is decorative. A typo’d href in a post ships and stays shipped.
Monitoring lies in the same direction. A synthetic check hitting a route that got renamed during a refactor will keep reporting healthy forever.
Analytics inflates. Every 404 is counted as a pageview of whatever the shell reports, which is usually the homepage. You lose the single most useful signal you have about broken inbound links.
The four shapes, side by side
| What the host does | Status | Body | Machines see |
|---|---|---|---|
Rewrite unmatched to /index.html | 200 | Homepage | A real page |
| Serve shell, render not-found in JS | 200 | Not-found | A real page |
Redirect unmatched to /404/ | 301→200 | Not-found | A moved page |
Serve 404.html with a 404 status | 404 | Not-found | Correctly gone |
Only the last row is a 404.
Getting it right
The near-universal convention for static hosting is a file named 404.html at the site root. S3 website endpoints, GitHub Pages, Cloudflare Pages, Netlify, Firebase Hosting, and Render all look for it, serve its contents for unmatched paths, and — critically — send a 404 status while doing so. You get a designed page and a correct header with no configuration at all.
Two things break that.
Your build might not emit 404.html. If your generator writes directory-style output, a /404 route can land at dist/404/index.html, and a host looking for dist/404.html will not find it. Most frameworks special-case this, but “most” is not “yours.” Check the artifact directly instead of trusting the framework:
npm run build
ls -la dist/404.html
If that file isn’t there, nothing downstream can work.
S3 has two endpoints and only one of them cares. The website endpoint (bucket.s3-website-region.amazonaws.com) honors the error document. The REST endpoint (bucket.s3.region.amazonaws.com) does not — it returns an XML NoSuchKey, or AccessDenied if the bucket blocks listing, which is the well-known way to make a missing file look like a permissions bug. If you front the REST endpoint with a CDN, you have to map the error response yourself in the CDN config.
And if you genuinely deleted something on purpose — a product that no longer exists, a post you retracted — 410 Gone is worth the extra minute. It tells crawlers not to come back, where 404 only means “not right now.”
The other 404 problem: the ones you can’t see
Once the status codes are honest, a second class of bug becomes visible, because a misconfigured fallback hides it perfectly.
When unmatched paths return your HTML shell, a missing asset returns HTML too. A broken image source returns a 200 full of markup. A stale hashed bundle reference returns a 200 full of markup, and the browser tries to parse it as JavaScript:
Uncaught SyntaxError: Unexpected token '<'
That error has cost the industry an enormous number of hours. It almost always means one thing: a script tag requested a file that doesn’t exist, and the server helpfully answered with a web page. Fix the fallback and the error becomes an ordinary, obvious 404 in the network tab.
While you’re in there, look at what your not-found page actually weighs. If it pulls the full application bundle to render an apology, you’re shipping a few hundred kilobytes to a request that was already a dead end — frequently to a bot. The 404 page is the one page on the site with a genuine excuse to be a single, static, dependency-free HTML file.
Make it a gate
This is a two-line check, and it has to run against the deployed site, because the bug lives in host configuration and cannot be reproduced by any local build.
#!/usr/bin/env bash
set -euo pipefail
SITE="${1:?usage: check-404.sh https://example.com}"
code() { curl -s -o /dev/null -w '%{http_code}' "$1"; }
missing=$(code "$SITE/__nope-$RANDOM/")
[ "$missing" = "404" ] || { echo "missing path returned $missing, expected 404"; exit 1; }
home=$(code "$SITE/")
[ "$home" = "200" ] || { echo "homepage returned $home, expected 200"; exit 1; }
echo "404 semantics correct"
The second assertion matters as much as the first. It is entirely possible to fix a soft 404 by breaking routing outright, and a check that only tests the failure case will happily approve a site that returns 404 for everything.
Run it as a post-deploy step. It costs two requests and catches a whole category of silent regression — the kind that only surfaces when someone notices, four months later, that Search Console has been quietly listing eleven hundred soft 404s.
The takeaway
Everyone remembers to design the 404 page. Almost nobody verifies the number.
Curl a URL that has never existed on your production site right now. If it doesn’t say 404, the page is decoration, and every crawler, cache, and checker pointed at your site has been reading a status line you never meant to write.

