stoneware

Documentation

Error boundaries

Lose one subtree instead of the whole page, without losing the error.

Without a boundary, a component that throws anywhere in a page loses the page: the request unwinds to routes/_500.tsx and a malformed row in one widget costs the article around it. On a site whose pages are mostly content, that is the wrong blast radius.

  without a boundary              with one
  ──────────────────────────      ──────────────────────────
  500, the whole page gone        200, the page intact

  the reader gets an error        the reader gets the
  page instead of the article     article and a short note
                                  where the widget was
one widget throws
routes/product/[id].tsxtsx
import { Boundary } from "stoneware";

export default async function Product({ params }: PageProps) {
  const product = await getProduct(params.id);
  if (!product) notFound();

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>

      <Boundary fallback={<p>Reviews are unavailable right now.</p>}>
        <Reviews productId={product.id} />
      </Boundary>
    </article>
  );
}

That is the whole API. No registration, no error state to hold, nothing to reset. Rendering is a single synchronous walk to a string, so catching is a try around one subtree — which is also why there is no client-side equivalent and no second pass.

notFound() is not caught

notFound() is a routing decision travelling as an exception, not a failure. A boundary lets it through, so it still renders your _404 page with a 404 status even when thrown from deep inside a boundary's children.

If a boundary caught it, a missing post would render the fallback with a 200 — a soft 404, which search engines index and which tells a client the request succeeded. That is the bug 0.1.3 removed, and a boundary must not put it back.

The error is never lost

A boundary that swallows errors quietly is worse than no boundary: the page looks fine and the failure is invisible. So a caught error goes two places, and neither is optional.

  console      the error and its stack, always —
               with no observe hook configured this
               is the only thing between a caught
               error and complete silence

  observe      event.caught carries the thrown values
               on the request they belong to, so a
               reporting backend gets the real error
               rather than a formatted line
one widget failing on every request
the request line, with the built-in observertxt
[stoneware] 200 GET  /product/42  15ms  /product/[id]  caught=1

The status is 200 because the request genuinely succeeded — the reader got a usable page. caught=1 is what tells you it was degraded. See observability for wiring event.caught into Sentry or a metrics backend.

Showing the error while developing

a fallback that reads the errortsx
<Boundary fallback={({ error }) => <pre>{String(error)}</pre>}>
  <Reviews productId={product.id} />
</Boundary>

error is populated in development and undefined in production — the same contract routes/_500.tsx already has, decided by the framework rather than left to each fallback to handle responsibly. An exception message routinely carries a file path, a query or a connection string, and a fallback renders into a page a visitor reads.

What a discarded subtree leaves behind

Nothing. A child that rendered part of itself before throwing contributes none of that markup, and an island it had already registered is removed from the hydration payload — otherwise the page would name an island with no element on it, and the client would hunt for a marker that is not there while everything looked correct.

  • Boundaries nest. The innermost one that can handle the error does.
  • A fallback that itself throws is not caught by its own boundary — that would recurse. It escalates to _500.
  • Inside an islands/ component a boundary does nothing: the client renders its children unguarded. Islands are the one place code runs twice, and a boundary that caught on first paint but not on later updates would be worse than one that never claimed to.
Use one where a section can fail independently and the page is still worth serving — a reviews block, a recommendation strip, a third-party embed. Wrapping a whole page in one only moves your _500 page inside your layout.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.