Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

When data required to render a page is definitively missing, do not leave a loading spinner on screen. Decide whether the condition means not found or server failure, detect it at the data-loading or route boundary, and render the matching error or not-found response. Suspense fallbacks are for content that is still pending; they are not proof that a record has failed.

Make the decision at the data boundary

A page can be in three materially different states:

State Meaning Correct user outcome
Pending The request or component has not finished. Loading UI, often supplied by Suspense.
Not found The requested record does not exist and that is a valid product outcome. Not-found page and an appropriate 404 response.
Failure An invariant, dependency, authorization check, or server operation failed. Error boundary and an appropriate 4xx or 5xx response.

Make this classification where the data is loaded, before rendering a component that assumes the value exists. A route loader is usually the clearest location because it can select an HTTP status and hand the result to the route’s error boundary.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Not-found versus server failure

  • Use not found when a user requests a legitimate identifier but no record exists, such as an article slug that is not present.
  • Use server failure when an invariant is broken, a required upstream service fails, or the application cannot safely determine the page state.
  • Use an authorization response when the record exists but the current user is not allowed to know or view it; do not turn every permission failure into a 404 without a deliberate security reason.

React Router: throw from the loader

React Router’s documented approach is to throw response data from a loader when it cannot find what the page needs. The closest route ErrorBoundary then renders the failure UI instead of allowing a component to dereference missing data. React Router describes this as avoiding an empty page for users: “To avoid rendering an empty page to users, route modules will automatically catch errors in your code and render the closest ErrorBoundary.” See the React Router Error Boundaries guide.

Complete route example

import {
  isRouteErrorResponse,
  useRouteError,
} from "react-router-dom";

export async function loader({ params }) {
  const response = await fetch(`https://example.com/api/articles/${params.slug}`);

  if (response.status === 404) {
    throw new Response("Article not found", { status: 404 });
  }
  if (!response.ok) {
    throw new Response("Article service failed", { status: 502 });
  }

  const article = await response.json();
  if (!article || typeof article.title !== "string") {
    throw new Response("Invalid article payload", { status: 500 });
  }
  return { article };
}

export function Article() {
  const { article } = useLoaderData();
  return 

{article.title}

{article.body}

; } export function ErrorBoundary() { const error = useRouteError(); if (isRouteErrorResponse(error)) { if (error.status === 404) { return

Article not found

Check the address or return to the index.

; } return

We could not load this article

Try again later.

; } return

Unexpected error

; }

In a data router, attach loader and ErrorBoundary to the route module. The loader throws before Article receives data, so the component never has to guess whether undefined means loading, absence, or corruption.

Validate more than HTTP status

A successful HTTP response can still contain an unusable payload. Check required fields and relationships at the boundary. If a missing field violates an application invariant, throw a 500-class response (or your framework’s equivalent) rather than rendering a partially empty page. Keep the user-facing message generic while logging the payload and request identifier on the server.

Why Suspense is not a missing-content detector

Suspense displays its fallback while a child suspends, then returns to the child when it is ready. A fallback therefore represents pending work, not a confirmed missing record. The React Suspense documentation states that “If a component throws an error on the server, React will not abort the server render.” In a Suspense boundary, React may emit the fallback and retry rendering on the client.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

That behavior is useful for resilience, but it can hide a broken data decision if a permanently absent record is treated as a promise that never resolves. Resolve absence in the loader, server function, or route action; reserve Suspense for work that may still complete.

Choose boundary scope deliberately

A boundary around one widget can preserve the rest of a page when a recommendation panel fails. A route boundary is appropriate when the primary record is required. An application-level boundary is a last-resort shell for failures that leave no safe route-specific UI. React’s Component reference advises considering where an error message makes sense when choosing error-boundary granularity.

Server rendering: status codes and timing

The rendering API changes what the server can know before it commits a response.

Streaming with renderToReadableStream

With streaming SSR, the shell can be sent before every child finishes. React’s renderToReadableStream example tracks errors in onError and uses that state to select a 500 response. This only helps for failures observed before the response status is committed; errors after the shell is sent cannot retroactively change an HTTP status.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import { renderToReadableStream } from "react-dom/server";

export async function handle(request) {
  let didError = false;
  const stream = await renderToReadableStream(, {
    onError(error) {
      didError = true;
      console.error(error);
    },
  });

  return new Response(stream, {
    status: didError ? 500 : 200,
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}

Put critical data loading where the server can observe its outcome before committing a status. If a required record is absent, perform the lookup before starting the stream or have the framework’s route data layer represent the 404 explicitly.

String rendering with renderToString

renderToString does not wait for suspended content. It emits the nearest Suspense fallback. That is appropriate when a fallback is acceptable in the initial HTML, but it does not provide a “wait until required data exists” guarantee. Do not infer success from the presence of generated HTML.

Static output with prerender

For builds that must wait for suspended content before producing static HTML, use a data-loading path designed to wait, such as React’s documented static prerender API where your framework supports it. If required content is unavailable during the build, fail the build or produce an intentional not-found artifact according to the site’s publishing rules; do not silently ship a permanent loading shell.

Failure handling checklist

  1. Define which fields are required for the route’s primary UI.
  2. Fetch and validate them in the loader or server data function.
  3. Map an absent record to a deliberate 404/not-found response.
  4. Map broken invariants and dependency failures to an error response.
  5. Render the nearest boundary with an actionable, accessible message.
  6. Set the HTTP status before streaming commits headers.
  7. Log technical details server-side without exposing secrets or payloads to users.
  8. Test pending, not-found, malformed, unauthorized, timeout, and upstream-error paths separately.

Common mistakes and fixes

Showing a spinner forever

Cause: a missing record is represented as an unresolved promise or a state value that the UI treats as loading. Fix: return or throw a typed not-found result from the data boundary.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Rendering empty markup with a 200 status

Cause: the component uses optional chaining and quietly renders nothing. Fix: validate required data before rendering and select 404 or 5xx semantics explicitly.

Changing a streamed response to 404 too late

Cause: headers were committed before the loader failure surfaced. Fix: resolve route-critical data before streaming, or accept that a client-side boundary can change visible UI but not the already-sent status.

Assuming renderToString waits

Cause: confusing a server-rendered fallback with completed content. Fix: use a waiting prerender/data path when static HTML must include the required content.

One global boundary for every error

Cause: boundary placement was chosen for convenience. Fix: place boundaries where the corresponding message makes sense: widget, route, or application shell.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If you need screenshots of the resulting not-found or error pages, ScreenshotNeo provides a single HTTP request instead of maintaining browser automation. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result.

Use the API shown in the ScreenshotNeo documentation:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Equivalent Python:

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Equivalent Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Should every missing record be a 404?

No. A genuinely absent public resource is usually 404; an invariant violation or dependency failure is a server error. Choose based on product semantics and the information you can safely reveal.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can an ErrorBoundary replace server status handling?

No. It controls rendered UI. The server must still determine the HTTP status before headers are committed, especially for streamed responses.

Is a Suspense fallback appropriate for a slow API?

Yes, when the request is still expected to complete. Once the application knows the required record cannot exist or cannot be used, leave the pending state and throw or return the deliberate failure.

Frequently Asked Questions

Should every missing record be a 404?

No. A genuinely absent public resource is usually 404; an invariant violation or dependency failure is a server error.

Can an ErrorBoundary replace server status handling?

No. It controls rendered UI; the server must determine the HTTP status before headers are committed.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Is a Suspense fallback appropriate for a slow API?

Yes, while the request is expected to complete. Definitive absence should become a deliberate not-found or error result.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.