Soft 404s: When a 200 Response Is Treated as Missing
A soft 404 is a URL that returns HTTP 200 while its content indicates nothing is there. The server says “here is the page”; the content says “this page does not exist.” When a search engine resolves that contradiction against you, it treats the URL as missing regardless of the status code.
This matters because the reclassification is not something you configured. It is a judgement made by the crawler about your content, and it happens to URLs you believed were fine.
What triggers it
There is no published rule set, so what follows is observed behaviour rather than documented mechanics — but the triggers are consistent enough to be actionable.
Error copy served with a 200. The canonical case. A template renders “Sorry, we couldn’t find that product” and the application never sets a status code, so the response line stays 200. Any phrase that reads as an error is enough.
Genuinely empty pages. A tag archive with no posts. A search results page with zero matches. A category that has been emptied but not removed. The page is structurally valid and semantically vacant.
Thin pages that exist only as scaffolding. Auto-generated location pages, paginated shells with no items, a “coming soon” placeholder. Nothing is broken; there is simply nothing there.
Redirects to irrelevant destinations. Sending /product/discontinued-widget to the homepage is treated much like an error, because the destination does not answer the request. This is why the “redirect everything to /” cleanup does not work — see redirect chains and how to flatten them for the same mistake in a different shape.
Client-side rendering that fails. The HTML shell arrives with a 200, the JavaScript that would fill it errors or times out, and what the crawler sees is an empty container. The status code was set before anything could go wrong.
Why the status code loses
It seems backwards that content should override an explicit HTTP signal. The reasoning is that soft 404s are common enough — and damaging enough to result quality — that a crawler cannot afford to trust 200 as proof of substance. A search engine that indexed every “no results found” page would surface a lot of empty pages.
So the code is treated as a claim to be checked, not a fact. That is the durable mechanism, and it is worth internalising because it generalises: most crawler behaviour around duplication and emptiness works this way. A declared signal is evidence, and other evidence can outweigh it. The same logic is why a canonical tag is a hint rather than a command.
Finding them
In Search Console. The index coverage report has a “Soft 404” reason under excluded pages. That report is the only place you get the crawler’s own verdict, so it is the authoritative starting point even though the sample is partial.
By crawling for the pattern. A site crawler configured to flag 200 responses containing error phrases catches the template class quickly. Grep the rendered body for the strings your error templates use — not found, no results, no longer available, 0 items.
By word count. Export every 200-response URL with its word count and sort ascending. The bottom of that list is where soft 404s live. This finds thin and empty pages that contain no error phrase at all, which the string search misses.
By checking what your app actually returns:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/product/does-not-exist
If a URL you invented returns 200, your application has no 404 path and every typo in the wild is a soft 404 waiting to be discovered.
Fixing each cause
The fix depends entirely on which trigger applies, and the mistake is applying one remedy to all of them.
Error copy with a 200 → set the status code. This is a code change, not an SEO change. The framework almost always supports it; someone just did not.
# Flask: return the correct code with the error template
@app.errorhandler(404)
def not_found(e):
return render_template("404.html"), 404
The important part is the , 404. Rendering the template alone leaves the response at 200 and produces exactly the problem you are trying to fix.
Empty archives and zero-result searches → decide whether the URL should exist. If a tag has no posts, the tag page should 404 or 410 rather than render an empty list. If it is a search results page, it should not be crawlable in the first place; the query-string surface is its own problem, covered in query parameters that multiply your URL count.
Thin scaffolding pages → remove or fill. A noindex also stops the soft-404 classification, but it does not answer the underlying question of why a page with no content is being served. Prefer removing the URL to hiding it.
Irrelevant redirects → re-point or serve the error. For each redirect whose destination is not a genuine equivalent of the source, either find the real equivalent or drop the rule and serve an honest 404.
Failed client-side rendering → render server-side, or fix the failure. If the content only exists after JavaScript runs, the 200 was always a promise rather than a description. Server-rendering the meaningful content removes the whole class.
The out-of-stock case
Retail sites hit a version of this that has no clean answer: a product page for an item that is temporarily unavailable. The page has real content — description, images, specifications — but the thing it exists to sell cannot be bought.
The workable pattern is to keep the URL returning 200 with the full product content, state the availability clearly, and offer alternatives on the page. What tips it into soft-404 territory is stripping the page down to “This item is unavailable” and nothing else. The content is what gets assessed, so leave the content there.
If the product is discontinued rather than out of stock, that is a different decision and it belongs with the other end-of-life choices in 404 versus 410.
The check worth automating
Any URL your application serves with a 200 should be able to answer “what is on this page?” in a sentence. Where it cannot, the status code is doing work the content does not support.
A cheap CI test covers the biggest slice: request a URL that cannot exist and assert the response is 404. It takes one line and it catches the day someone refactors error handling and quietly turns every dead URL on the site into a soft 404.