Introduction
Your server's answer matters as much as your content. A page that looks like a 404 to visitors but returns a 200 status code can silently damage your SEO, confusing search engines about which pages are actually available. This is the core problem we address in the context of status codes and soft 404s: patterns, pitfalls and fixes — and it's surprisingly common in modern web applications.
HTTP status codes are the first signal search engines receive before they even parse your HTML. Get them wrong, and Google may index pages you never wanted indexed, or fail to crawl pages that matter. In this guide, we'll walk through the patterns that cause these issues, show you where they hide, and share the fixes we've applied across production systems handling millions of requests.
200 means real content — A 'not found' page returning 200 is a soft 404
A soft 404 is a URL that returns HTTP 200 (success) but displays content that indicates the resource doesn't exist. Google's crawlers are smart enough to detect these in some cases, but many soft 404s slip through and get indexed, wasting crawl budget and muddying your site's signal.
Common scenarios where soft 404s appear:
- A product page where the item is out of stock or delisted, but the page still returns 200 with a "sorry, unavailable" message
- A user profile that's been deleted but the route still resolves to a generic "user not found" page
- Search results with zero matches that load successfully and show "no results found"
- Blog posts moved or archived that return a friendly "this content has moved" page
In a recent project building an e-commerce platform, we discovered that our legacy product pages were returning 200 for discontinued items. The canonical tag pointed to the main category, but Google still spent crawl budget indexing thousands of these soft 404 variants. Fixing this single issue reclaimed roughly 15% of our crawl budget within two weeks.
The fix depends on your page type:
- Permanently gone content: Return 410 Gone (see next section)
- Temporarily unavailable: Return 503 Service Unavailable with a Retry-After header, or 200 if the content structure exists (e.g., an out-of-stock product you'll restock)
- Moved content: Return 301 or 308 redirect to the new location
- Search result pages with no matches: This is nuanced. A zero-result search is technically successful (the server executed the query), so 200 is correct — but add a canonical tag and consider noindex if you generate thousands of unique URLs
Here's a Node.js/Express pattern that handles different content states:
app.get('/products/:id', async (req, res) => {
const product = await db.products.findById(req.params.id);
if (!product) {
return res.status(404).send('Product not found');
}
if (product.status === 'discontinued') {
res.status(410).send('This product is permanently discontinued');
return;
}
if (product.status === 'out_of_stock') {
res.status(200)
.set('Cache-Control', 'public, max-age=300')
.render('product', { product, message: 'Currently unavailable' });
return;
}
res.status(200).render('product', { product });
});The key insight: a 200 response commits you to the idea that the resource exists and is valid. Don't use it as a catch-all for "page loaded successfully."
404 and 410 — Return real error codes for missing content
HTTP 404 (Not Found) and 410 (Gone) are your primary tools for signaling that content doesn't exist. They tell Google not to index the page, not to follow links from it, and not to waste crawl budget returning to it.
Use 404 when:
- A resource was never found or never existed
- A URL is temporarily incorrect or the resource might return later
- You're unsure of the resource's permanent status
Use 410 when:
- You're absolutely certain the resource will never come back
- You've explicitly deleted or archived content and want Google to stop trying
- A product line, user account, or blog post is permanently retired
Google treats 410 more aggressively than 404 — it deindexes the page faster and removes it from search results sooner. In a trading platform we worked on, returning 410 for closed accounts (rather than 404) cut the time for those pages to disappear from search results from weeks to days.
A practical example from a CRM system:
async function getContactPage(req: Request, res: Response) {
const { contactId } = req.params;
const contact = await Contact.findById(contactId);
if (!contact) {
return res.status(404).json({ error: 'Contact not found' });
}
if (contact.deletedAt && Date.now() - contact.deletedAt.getTime() > 90 * 24 * 60 * 60 * 1000) {
// Deleted more than 90 days ago — permanent
return res.status(410).json({ error: 'Contact permanently deleted' });
}
if (contact.deletedAt) {
// Recent deletion — might be recoverable
return res.status(404).json({ error: 'Contact not found' });
}
return res.status(200).json(contact);
}Don't forget: returning 404 or 410 doesn't require an empty response. Include a friendly HTML page, JSON error object, or redirects. Search engines care about the status code, not the body.
5xx hurts crawling — Persistent server errors slow crawling and can drop pages
HTTP 5xx errors (500 Internal Server Error, 503 Service Unavailable, etc.) signal to Google that something is temporarily or permanently broken on your server. Unlike 404s, which are actionable ("delete this URL"), 5xx errors make Google cautious — it will retry later, but if you keep returning 5xx, it will eventually back off and stop crawling your site.
We've seen this firsthand with microservices architectures. A single downstream service timeout cascading to 500s across your main domain can tank crawlability within hours. Google's crawler gets polite but gives up, and your crawl frequency drops.
Key patterns to avoid:
- Unhandled exceptions in route handlers: Always catch errors and return appropriate 4xx or 5xx with a message
- Timeout cascades: If a database or API call times out, decide fast: return 503 and let Google retry later, or return 4xx if the request is malformed
- Partial outages: If your data layer is degraded but not down, return 503 with a Retry-After header rather than 500
- Silent failures: A route that returns 200 with no content because an upstream service failed is worse than a 503 — you've created a soft 404
Here's a defensive middleware pattern we use in production APIs:
const errorHandler = (err, req, res, next) => {
const isDevelopment = process.env.NODE_ENV === 'development';
// Log the error for debugging
console.error('Request error:', {
url: req.url,
method: req.method,
error: err.message,
stack: isDevelopment ? err.stack : undefined
});
// Distinguish between client errors (4xx) and server errors (5xx)
if (err instanceof ValidationError) {
return res.status(400).json({ error: 'Invalid request', details: err.details });
}
if (err instanceof NotFoundError) {
return res.status(404).json({ error: 'Resource not found' });
}
if (err instanceof TimeoutError || err instanceof ServiceUnavailableError) {
return res.status(503)
.set('Retry-After', '60')
.json({ error: 'Service temporarily unavailable' });
}
// Default: unhandled 5xx
return res.status(500).json({
error: 'Internal server error',
requestId: req.id // For debugging
});
};
app.use(errorHandler);For critical SEO pages (homepage, main category pages), consider implementing a circuit breaker or fallback. If a widget fails to load, serve a 200 with cached content rather than a 503. But for dynamic, user-specific content, a 503 with Retry-After is more honest and better for crawling.
Check SPA routes — Client-rendered 404 pages often return 200
Single-page applications (SPAs) built with React, Vue, or Next.js introduce a unique challenge. The server serves a 200 response with a bundle of JavaScript, and the client-side router determines what to display. A non-existent product ID might render a "not found" component, but the server never knew — it returned 200.
This is one of the most common soft 404 issues we see in modern applications. Google's JavaScript rendering understands the difference between a 404 component and real content in some cases, but it's unreliable. Better to handle it server-side.
We've fixed this across several Next.js projects by moving route validation into the server layer:
// pages/products/[id].tsx (Next.js)
export async function getServerSideProps(context) {
const { id } = context.params;
const product = await fetchProduct(id);
if (!product) {
return {
notFound: true // Returns 404 automatically
};
}
if (product.status === 'archived') {
return {
redirect: {
destination: '/products/archived/' + id,
permanent: true // 301
}
};
}
return {
props: { product },
revalidate: 3600
};
}
export default function ProductPage({ product }) {
return <h1>{product.name}</h1>;
}If you're using client-side routing (e.g., React Router), fetch the resource in a useEffect and conditionally render 404 or redirect. But send the correct status code from your server during the initial render:
// React component with server awareness
function ProductPage() {
const { id } = useParams();
const [product, setProduct] = useState(null);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
fetchProduct(id)
.then(p => {
if (!p) setNotFound(true);
else setProduct(p);
})
.catch(() => setNotFound(true));
}, [id]);
if (notFound) {
// On the server, this should trigger a 404 response
return ;
}
if (!product) return ;
return ;
}For truly dynamic SPAs, use a meta tag or JavaScript API to signal the status code. Some crawlers (including Googlebot) check for meta tags like <meta name="prerender-status-code" content="404">, though this is less reliable than server-side handling.
The best approach: whenever possible, validate routes and content availability on the server before rendering. It's simpler, faster, and gives crawlers unambiguous signals.
Common mistakes
- Mixing 404 and 410: Using 410 for temporarily unavailable content (you might bring it back) or 404 for permanently deleted content. Both work, but 410 is more aggressive — reserve it for truly permanent deletions.
- Serving 200 with a noindex tag: This is a crutch. The page is still indexed (crawlers see 200 first), and the noindex tag may or may not be respected. Return 404 or 410 instead.
- Ignoring 503 Retry-After: If your site experiences temporary outages, return 503 with a Retry-After header. Omitting Retry-After tells Google to check back immediately and frequently, hammering your struggling server.
- Assuming Google's JavaScript rendering fixes soft 404s: Don't rely on Googlebot rendering your SPA to detect client-side 404 pages. Always validate and return the correct status code server-side.
- Not monitoring status code distributions: Set up alerts on your 5xx rate and periodically audit your 2xx responses for content that shouldn't be indexed. Use Google Search Console to find 404s and 5xx errors in crawl logs.
Key takeaways
- HTTP status codes are the primary signal for content existence; use 200 for real content, 404 for not found, and 410 for permanently gone
- Soft 404s (returning 200 for missing content) waste crawl budget and confuse search engines — audit your site for pages that look like errors but return 200
- 5xx errors signal to Google that your site is unstable; use them sparingly and always pair with Retry-After headers for temporary issues
- In SPAs and client-rendered applications, validate routes and fetch content server-side before responding — don't rely on client-side components to signal 404s to crawlers
- Monitor your status code distribution in server logs and Google Search Console; soft 404s and persistent 5xx patterns are SEO red flags that require immediate attention






Comments
Loading comments…