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 wasimport { 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[stoneware] 200 GET /product/42 15ms /product/[id] caught=1The 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
<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.