A customer sees a blank white page when one component throws while rendering and no error boundary catches it, because React removes the app’s UI. Learning how to add a frontend error boundary fixes that half. The other half is one global server handler, so an unexpected failure returns a safe 500 with a request id, not a stack trace.

What is an error boundary, and what is a global handler

An error boundary is a React component that shows a fallback in place of the part of the page that crashed while rendering. A global backend handler is the one function every unhandled server error reaches. Together they turn a crash into a contained message on both sides.

React’s reference defines an Error Boundary as “a special component that lets you display some fallback UI instead of the part that crashed”. It lives in the component tree, wrapped around the region it protects. A global backend handler is my name for the last function an unhandled server error reaches: it writes the full detail to the log and sends the client one safe response. It sits at the end of the server’s middleware chain, or, where there is no chain, around every function.

The two ship together because the customer sees one thing, a broken screen, whichever side failed. Status codes and the shape of the error body belong to a neighboring subject, API error handling best practices; here the job is the last net on each side. Both nets are one piece of hardening a SaaS application for resilience, next to timeouts, retries and degraded modes.

In the Production Hardening Sprint this is deliverable 6.2, where we “implement global backend error handling and frontend error boundaries with clear recovery states.”

What goes wrong without them

What the customer seesWhat happenedWhich half fixes it
A blank white page after a click or a page loadA component threw while rendering and no boundary sat above itFrontend: a boundary
The whole dashboard gone because one chart failedThe only boundary wraps the whole app, so its fallback replaced everythingFrontend: a boundary per region
A spinner that never endsA request failed and the code that sent it never set an error stateFrontend: an error state in that code
A raw stack trace or a framework error page in productionNo error handler of the app’s own answered the failureBackend: the global handler
The host’s own error pageThe platform got no usable response from the appBackend: the handler, and a process that stays up

The table is my own grouping of the symptoms, not a vendor list. The third row matters because an error boundary does not catch a failed request on its own, which the section on what a boundary does not catch takes apart.

Across the 21 third-party apps in my June and July 2026 audits, the Reliability & Correctness pillar averages 31.4 out of 100, scored on 21 of the 21, and ranks worst of the 12 pillars I score. In 17 of those 21 apps there was no error tracking or alerting: when a user hits an error, nothing records it. Those 21 are 11 public vibe-coded apps I audited exhaustively across all 12 pillars plus 10 disjoint held-out apps I audited blind. They are a selected set, not a random sample, and the numbers are not a rate for AI-built apps in general. My reading of the second number: with nothing recording errors, the first report of a blank page is a customer’s screenshot.

The app shows a blank white screen on error

An app shows a blank white screen on error because React removes its UI when a render error is not caught by any boundary. Nothing is left to display. The browser console still holds the error, which makes it the first place to look.

React’s reference puts the default plainly: “By default, if your application throws an error during rendering, React will remove its UI from the screen.” That is why the page goes fully empty instead of half broken. React also logs every error to the console unless you supply your own reporting options, so the cause is waiting there.

Start in the browser, before any terminal. Open the blank page, open the browser’s developer tools, choose the Console tab and read the first red error. On a production build the component names are minified, so read the message itself before the names around it. Then check that the blank page is a render error at all:

CauseHow to tellFirst check
A render errorThe console shows an error thrown from your own code as the page rendersFix the throw, then put a boundary around that region
A script that failed to load after a deploy, or a wrong base pathThe console shows a failed request for a JavaScript file (a 404, or HTML served where a script was expected); the Network tab shows the same requestThe deploy’s base path, and whether the built files were uploaded
An environment variable missing in productionThe console shows an error at start-up where a config value reads as undefinedThe production environment variables against the ones the build expects

The causes are my list. The second and third rows are deploy faults, and no error boundary fixes them.

One app I audited, an AI coding workspace, had written a proper error boundary component with a “try again” card and never used it anywhere. It had no route-level error page either, so one render error blanked the whole app. The lesson I take from it: a boundary protects only the tree it is mounted around. If Lovable, Bolt or v0 generated your frontend, search the code for the boundary component, then for every place it actually wraps something.

An API that answers 200 with nothing in it is a different failure, and when your app fails silently and says 200 OK sets that case apart from render errors. A white screen on a phone, in a React Native app or on a WordPress site has other causes and is not covered here.

One broken widget breaks the whole page

One broken widget breaks the whole page when the nearest error boundary wraps the whole app, or there is none. A boundary around each independent region keeps the damage to that region, and the rest of the page keeps working.

Take a dashboard whose chart component maps over a list that the API returned as null. The chart throws while rendering, and because the only error boundary wraps the app shell, the whole page is replaced by the full-page fallback instead of one empty chart. A boundary replaces exactly the region it wraps, and in this case that region was the entire shell. The lesson I take from the case: a boundary around each independent region keeps one failure in its own box.

“Widget” here means a component of your own app, such as a chart, a feed or an embed, not a page-builder plugin. The fix is placement, which the first subsection under the next heading lays out level by level.

If you are the application owner, check the logs for more information

A page telling the application owner to check the logs is a stock error page that nobody on your team designed. Ruby on Rails put that exact sentence in its default 500 page up to version 7.2, and Heroku’s own error page says nearly the same for system-level errors. Either way, the cause is in the logs.

The sentence customers paste, “If you are the application owner check the logs for more information.”, is the line in the public/500.html file that Rails 4.0 through 7.2 generate for a new app; Rails 8 shortens it to “If you’re the application owner”. My reading: if your customer saw that page, the request reached your Rails code and failed there, so your app’s log holds the exception.

Heroku’s own page is different. Its router “serves unstyled HTML with HTTP status code 503 (Service Unavailable) when your app encounters a system-level error, or while maintenance mode is enabled”, and application errors such as a 404 or a 500 show your app’s own error page instead. Only system-level errors that end in no response, or a malformed one, show Heroku’s page, whose default text says “If you are the application owner, check your logs for details” and names the command heroku logs --tail. Heroku’s error pages documentation is direct about the order: “Logs are the first place to look when your users report seeing the Heroku error pages.”

My reading: a customer should see a host’s page only when the process is truly down, because a global handler answers every ordinary bug with your own safe error response. A process that is truly down is an outage, and what to do first when your app is down starts there.

How to add a frontend error boundary: the component, the placement, the fallback

A frontend error boundary is added in one of two ways in React: a class component that implements static getDerivedStateFromError, optionally with componentDidCatch for reporting, or the open-source react-error-boundary package. Either way it wraps a region, renders a fallback when a child throws while rendering, and can report the error.

React’s error boundary reference sets the rule for the class: you provide static getDerivedStateFromError to switch to an error message, and you can add componentDidCatch to send the error to a reporting service. A functional component cannot do this on its own: “There is currently no way to write an Error Boundary as a function component.” There is no direct equivalent of getDerivedStateFromError in function components yet either, so React’s reference points to writing one class and reusing it, or to the package. React’s own example class, trimmed to its working parts:

// ErrorBoundary.jsx
import { Component } from "react";

export class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true }; // the next render shows the fallback
  }

  componentDidCatch(error, info) {
    logError(error, info.componentStack); // your reporting call
  }

  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

You then wrap a region in it, as <ErrorBoundary fallback={<p>Something went wrong</p>}> around the component it protects.

The package is react-error-boundary, an MIT-licensed open-source repository whose component renders a fallback through one of three props, fallback, fallbackRender or FallbackComponent, and whose fallback can call resetErrorBoundary to clear the error and retry rendering. Its README marks it as a client component: in a Next.js App Router file, pass it only serializable props, or use it in a file under a "use client" directive.

React 19 did not change the class-only rule. What it changed is reporting: its release notes say it improved error handling “to remove duplication and provide options for handling caught and uncaught errors”, through the root options onCaughtError and onUncaughtError next to onRecoverableError. onUncaughtError is the one that sees a render error no boundary caught, which is exactly the blank-page case.

The APIs and code in this section come from the frameworks’ own documentation, not from a crash I ran for this page. Here is the package version around one region, with a fallback, a reset and a report call:

"use client"; // only for Next.js App Router files
import { ErrorBoundary } from "react-error-boundary";

export function ChartRegion({ children }) {
  return (
    <ErrorBoundary
      fallbackRender={({ resetErrorBoundary }) => (
        <div role="alert">
          <p>This chart could not load. The rest of the page still works.</p>
          <button onClick={resetErrorBoundary}>Try again</button>
        </div>
      )}
      onError={(error, info) => logError(error, info)} // your reporting call
    >
      {children}
    </ErrorBoundary>
  );
}

Where to place boundaries: the shell, each route, each independent widget

Error boundaries belong at three levels: one around the app shell as a last resort, one per route so navigation survives, and one around each widget that renders outside data such as charts, embeds or AI output. The fallback at each level matches what was lost.

React’s own advice is that you don’t need to wrap every component; ask where an error message makes sense: in a messaging app, around the list of conversations and around each message, but not around every avatar. My placement map turns that into three levels:

LevelWhat it protectsWhat the fallback says
App shellEverything, as the last resortSomething went wrong, a reload button, a way to reach support, the reference id
Each routeNavigation and every other routeThis page could not load, a link home, Try again
Each independent widget (chart, embed, rich text, AI output)The rest of the pageThis chart could not load, Try again in place

The route level depends on your router. In the Next.js App Router, an error.js file in a route segment is that segment’s boundary, errors bubble up to the nearest parent one, and global-error.js in the root app directory covers the root layout and must define its own <html> and <body> tags. Each of these fallbacks gets a retry() function that tries to re-fetch and re-render what the boundary wraps. The details are in Next.js error handling.

In React Router’s Framework and Data modes, each route can declare an ErrorBoundary, and when an error is thrown the closest one renders; Data mode reads the error with useRouteError. React Router’s error boundaries page marks the feature “Not available with Declarative” mode, the <BrowserRouter> setup. My rule there: wrap each route’s element in your own boundary.

Vue has the same two layers under other names: a component’s errorCaptured hook is “Called when an error propagating from a descendant component has been captured”, and Vue’s app-level error handler, app.config.errorHandler, is “a global handler for uncaught errors propagating from within the application”.

A fallback the customer can recover from

Good error boundary page design is mostly about what the fallback carries. My list has five lines:

  • Say what failed in plain words, and say the customer’s data is safe only when it is.
  • Offer a Try again button that resets the boundary. With react-error-boundary that is resetErrorBoundary; pass the id or query key that fed the region as resetKeys so a change to it resets the boundary too.
  • Give a way out: a link home or back.
  • Show a short reference id the customer can quote: the id from a failed request’s error response, or one your reporting call returns.
  • Never show the raw error message or the stack.

Keep the fallback inside the region’s own frame, the same size and position, so it reads as one region failing and not as the app failing. When an outside provider is down, show a reduced version of the feature instead of an error; that is what graceful degradation is.

What an error boundary does not catch

An error boundary does not catch errors in event handlers, during server-side rendering, in the boundary itself, or in most asynchronous code such as timer callbacks. Those need try and catch, the data library’s error state, and a window-level listener that reports what slipped through.

Where the error happensCaught by a boundary?What to use
Event handlersNoTry and catch inside the handler, then a visible error state
Server-side renderingNoThe server’s own error handling, so the request ends in a safe error page
The error boundary itself (rather than its children)NoA boundary higher up, and a fallback simple enough not to throw
Asynchronous code (for example setTimeout or requestAnimationFrame callbacks)No, except errors thrown inside the transition function from useTransition’s startTransitionThe data library’s error state, or react-error-boundary’s useErrorBoundary hook to pass a caught error to the nearest boundary
Anything that slipped past the rows aboveNoA window listener for error and unhandledrejection events that reports it

The first four rows are React’s list, exception included. The third column is my rule set. One React 19 detail makes the window listener honest: an error a boundary caught is reported to console.error and not re-thrown, while an error no boundary caught goes to window.reportError, so the listener hears only what no boundary handled.

The silent-failure article lists the same gaps in one line; the column above adds what to use for each. Retrying a failed request is a separate decision that rests on what exponential backoff is. Sending these errors to a tracker, with private source maps so the stack is readable, is its own job, and what each event should contain is set out in error logging best practices.

A global error handler for a backend API

A global error handler in a backend API is one function, registered last, that every unhandled error reaches. It logs the full error with a request id and answers with a generic 500 carrying the same id, unless the error already carries its own 4xx status. No stack trace leaves the server.

Express’s error-handling guide gives the two mechanical rules: error-handling functions “have four arguments instead of three”, and “You define error-handling middleware last, after other app.use() and routes calls.” The version matters for async code. In Express 5, route handlers and middleware that return a Promise call next(value) on their own when they reject or throw. The Express 4 guide says the opposite for async errors, “you must pass them to the next() function”, and warns that an unhandled rejection in an async route “crashes the process on current Node.js versions”. On Express 4, every async route needs a try and catch, or a wrapper, that hands the error to next().

These are my working rules for the handler:

  1. 01 One handler, registered after every route and every other app.use() call, with the four arguments err, req, res and next, so every unhandled error reaches it.
  2. 02 It logs the full error with the request id, then returns a generic message with the same id in the app's one error shape, status 500.
  3. 03 No stack trace, SQL or file path leaves the server, whatever the environment is set to.
  4. 04 Known errors (validation, not found, forbidden) are answered before it with their own status, so they never reach it as 500s.
  5. 05 Process-level listeners catch what escapes the framework: they log and then shut the process down, never carry on.
  6. 06 Serverless and edge functions get the same thing as a wrapper around every function, because there is no chain to end.

The first two rules look like this in Express. The request id comes from Node’s crypto.randomUUID(), which generates a random version 4 UUID.

import { randomUUID } from "node:crypto";

app.use((req, res, next) => { req.id = randomUUID(); next(); }); // first

// ... every route and app.use() call ...

app.use((err, req, res, next) => { // last: four arguments
  if (res.headersSent) return next(err);
  const status = err.status >= 400 && err.status < 500 ? err.status : 500;
  console.error({ requestId: req.id, status, err }); // full detail stays in the log
  res.status(status).json({
    error: { message: status === 500 ? "Something went wrong." : "The request could not be processed.", requestId: req.id },
  });
});

The res.headersSent check comes from Express’s own example handler, and the err.status test keeps a 4xx status that your own code or a library attached to the error. Express’s built-in fallback handler and its production switch belong with the error-shape topic from the first section. Log the id inside your own log line, because a host’s log may stamp a request id of its own that will not match.

The process-level listeners are easy to misuse. Node’s docs call uncaughtException “a crude mechanism for exception handling intended to be used only as a last resort”, and say “The correct use of ‘uncaughtException’ is to perform synchronous cleanup of allocated resources (e.g. file descriptors, handles, etc) before shutting down the process. It is not safe to resume normal operation after ‘uncaughtException’.” The restart belongs to an external monitor in a separate process that can “recover or restart as needed”, and an unhandledRejection nobody handles is raised as an uncaught exception under Node’s default mode. The details are on Node.js process events.

Serverless code has no chain to end. In a Next.js App Router app, route handlers are separate functions; Next.js can report their errors through an onRequestError export in its instrumentation file, which runs “when the Next.js server captures the error” and tags route handler errors as 'route'. That reports the error; the safe response still comes from a wrapper you put around each handler. FastAPI registers the same kind of hook with @app.exception_handler(), and Django sends an exception from any view to its 500 view unless DEBUG is on, in which case it shows the traceback.

One more connection joins the two halves. The frontend reads the requestId from the error response and shows it in its fallback, so the customer’s screenshot carries the same id as your log line.

How to verify it: test what happens when a component crashes, and when a route does

Error containment is verified with two deliberate crashes on a production build. A component that throws must show its fallback while the rest of the page works and Try again recovers. A server route that throws must return a 500 with a request id that appears in the server log.

To test what happens when a component crashes, use staging or a preview deployment built for production, not the dev server. That is my rule: development overlays change what you see.

  1. 01 Add a temporary component that throws while rendering when a flag in sessionStorage is set, reading the flag in an effect and not during render so a server-rendered page still loads, and mount it inside one widget's boundary. Evidence: the commit.
  2. 02 Set the flag from the browser console and reload. The widget shows its fallback while the rest of the page and the navigation keep working. Evidence: a screenshot.
  3. 03 Clear the flag in the console and press Try again. The widget comes back without a page reload. Evidence: a screenshot.
  4. 04 Move the throwing component to route level and confirm the route's fallback appears while the shell and navigation stay (in React Router's Declarative mode, the boundary you wrapped the route in). Evidence: a screenshot.
  5. 05 Add a temporary async server route that awaits something and then throws, written the same way as your real routes, and call it. The response is a 500 in the app's error shape with a request id and no stack. Evidence: the response body.
  6. 06 Find that request id in the server log and, if you run an error tracker, the matching event. Evidence: the log line.
  7. 07 Throw inside the try block of a click handler, or point the request it sends at the route from check 5. The page stays up and the handler's error state appears. Evidence: a screenshot.

Check 5 is where Express 4 apps fail quietly: a route that throws synchronously passes there, while an async route without its error passed to next() crashes the process instead of returning a 500. In check 7, a throw placed outside the handler’s try block shows no error state even in a correct build; only the window listener’s report proves it was seen. Remove the test code when you are done.

For a version that runs on every commit, keep two automated tests: one component test that renders a throwing child inside the boundary and asserts the fallback, and one API test that asserts the status and shape of the error response. Keep the screenshots, the 500 response, the matching log line, the date and the commit id. Timeouts on the calls themselves are a separate control.

In the sprint, deliverable 6.2 is verified this way: “trigger server and component errors and confirm safe responses and contained UI failures.”

Where the sprint does this

The 6.2 result goes into the production readiness report, deliverable 13.1, which delivers the result for every scope item, the work completed and its verification evidence, accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Building new product features or modules, or completing unfinished core features or business workflows, is separate work outside the sprint. The other error-handling deliverables sit in the reliability area of the published scope.

Common questions about blank screens and 500 errors

Why is my app showing a blank screen?

A React app usually goes blank for one of three reasons: a component threw while rendering and no error boundary caught it, so React removed the whole UI; a JavaScript file failed to load after a release; or a setting is missing from the production environment. The console tells them apart: an error thrown from your code, a failed request for a script, or an undefined value at start-up.

How do I fix the white screen of death?

In a React app, open the console on the blank page, fix the error it shows, then add boundaries so the next render error replaces one region and not the whole page. My reading is that an uncaught render error is the most common cause in React, but the phrase covers any page that renders blank, and on a WordPress site or a phone app the causes are different.

How do you handle global error handling?

With one net on each side: a boundary around the app shell in the browser, one last error-handling middleware on the server, and process-level listeners that only log and shut down. For an uncaught exception, Node’s docs describe the correct use as synchronous cleanup before shutting down, because resuming normal operation is not safe.

What does a 500 error mean in API?

A 500 means the server failed on its side: RFC 9110 says “the server encountered an unexpected condition that prevented it from fulfilling the request.” The 4xx class is for requests where “the client seems to have erred”, and how the error body is shaped is a separate design choice.

How to handle 500 internal server error in rest API?

Log the full error on the server with a request id and return a generic body that carries the same id. On the client, show an error state the user can recover from, and retry only requests that are safe to repeat.