SolidJS error boundaries: catching what useTransition hides

Suspense boundaries swallow errors by design. Here's how to surface them without sacrificing the UX they enable.

·
solidjstutorial

You've wired up useTransition to keep your navigation buttery smooth. A user clicks a link, the page stays on the current view while the next one fetches in the background, and the UI never flickers. Beautiful. Except this time, nothing happens. The user clicks, the button feels "alive," but the transition never completes and the new view never arrives. Under the hood a data fetch failed or a component crashed—but because it happened inside a transition, your ErrorBoundary never fired, no error reached your dashboard, and the user is left mashing a dead button.

This is the purgatory state, and it's the hidden cost of modern UX in SolidJS. The very mechanism that makes transitions feel smooth—holding onto the old state while the new one resolves—is the same mechanism that swallows failures whole. This post shows how to build a telemetry-first error strategy in SolidJS that surfaces those silent transition failures without sacrificing the responsive UX you adopted transitions for in the first place.

The ErrorBoundary Paradox in SolidJS

If you're coming from React, the first thing to internalize is that Solid's ErrorBoundary is not the same animal. React's boundary is a lifecycle wrapper that catches errors thrown during render and in lifecycle methods. Solid's ErrorBoundary is a reactive primitive—it's part of the same fine-grained reactivity system that powers everything else in the framework. That distinction is not academic; it's exactly why boundaries behave unexpectedly once async enters the picture.

Synchronous vs. asynchronous catching

A plain ErrorBoundary reliably catches synchronous errors—a null dereference during render, a thrown exception in a component body. But the moment createResource enters the mix, the rules shift. A resource fetch is asynchronous, and whether its rejection reaches the boundary depends entirely on how that rejection interacts with Suspense and transitions.

import { ErrorBoundary, createResource } from 'solid-js';

function Profile() {
  const [user] = createResource(fetchUser); // may reject
  return <div>{user()?.name}</div>;
}

// This catches a SYNCHRONOUS throw fine. Whether it catches the
// resource rejection depends on Suspense/transition context.
function App() {
  return (
    <ErrorBoundary fallback={(err) => <Failed error={err} />}>
      <Profile />
    </ErrorBoundary>
  );
}

The "swallow" effect

Here is the crux. Suspense and useTransition coordinate to keep the UI stable while new data loads. When an error occurs inside a transition branch, that coordination can suppress the error's propagation—the framework would rather keep showing the old, working UI than tear it down for a half-finished new one. The result is that the boundary never sees the error, because committing the error would mean disrupting the very stability transitions exist to protect. The error doesn't disappear; it just never gets a chance to surface.

It's worth contrasting this with the synchronous case to see why it's so disorienting. Outside a transition, a thrown error during render propagates immediately up to the nearest ErrorBoundary, which swaps in its fallback—loud, visible, exactly what you'd expect. The instant you wrap the same state change in start(), you opt into a deferred-commit model where the new tree is evaluated speculatively and only swapped in if it fully succeeds. The boundary is still there, still willing to catch—but the framework never asks it to, because it never commits the failing tree. You haven't disabled your error handling; you've moved your render into a context where the trigger that would invoke it never fires. That mental shift, from "errors always surface" to "errors surface only on commit," is the single most important thing to internalize about transitions.

How useTransition Masks Failures

To fix what you can't see, you have to understand the transition state machine.

The pending state

useTransition returns a pending signal and a start function. While the transition is in flight, pending() is true, and Solid keeps rendering the old DOM. The new branch is evaluated off to the side; only when it fully resolves does Solid "commit" it and swap the DOM. This is what gives you flicker-free navigation.

Why the UI stays old

Because the commit only happens on success, a transition that never resolves leaves the user staring at the old view indefinitely. There's no error state to render because, from the framework's perspective, the transition simply hasn't finished yet. pending() stays true forever.

The silent crash

Now combine the two. An error in the transition branch prevents the commit phase from ever completing. The old DOM stays on screen, pending() stays stuck at true, and the boundary is never triggered. The user gets zero feedback. Picture a search feature where the results component throws only for certain queries:

function Search() {
  const [query, setQuery] = createSignal('');
  const [pending, start] = useTransition();

  function onInput(value) {
    // The transition keeps the old results visible while loading.
    // If <Results> throws for this query, the transition NEVER
    // commits — pending stays true, no error surfaces, dead UI.
    start(() => setQuery(value));
  }

  return (
    <>
      <input onInput={(e) => onInput(e.currentTarget.value)} />
      {pending() && <Spinner />}
      <Results query={query()} />
    </>
  );
}

The user types a query that breaks Results, sees the spinner spin forever (or sees stale results with no update), and concludes your search is broken. Your dashboard, meanwhile, is empty.

Accessing the "Transition Branch" Errors

The fix is to stop relying on the boundary alone and instead intercept failures at their source.

Using the onError hook

Solid exposes an onError lifecycle hook that registers an error handler within the current reactive scope. Unlike the declarative ErrorBoundary, onError runs your code when an error propagates through that scope—giving you a place to report telemetry even if you choose not to alter the UI. You can use it globally near the root for a catch-all, or locally to handle a specific subtree.

Manual error promotion

The most reliable pattern is to never let a resource rejection vanish into the transition in the first place. Catch it in the resource fetcher and promote it into a signal the UI can actually read. This way you get both: the smooth transition stays intact for the happy path, and failures become visible state.

import { createResource, createSignal } from 'solid-js';
import * as Sentry from '@sentry/solid';

function useReportedResource(fetcher) {
  const [error, setError] = createSignal(null);

  const [data] = createResource(async (...args) => {
    try {
      setError(null);
      return await fetcher(...args);
    } catch (err) {
      // Promote the failure into visible state AND telemetry,
      // instead of letting the transition swallow it.
      setError(err);
      Sentry.captureException(err);
      throw err; // still let Suspense/boundary react if mounted
    }
  });

  return [data, error];
}

Now your component can render a toast or an inline message off the error signal—the user gets feedback—and the exception is reported regardless of whether the boundary ever fires.

Bridging the Gap with Telemetry

Capturing the error is necessary but not sufficient. A transition failure reported without context is hard to act on, because the whole problem is that it happened in a state your logs don't normally describe.

Context-aware error reporting

Always attach the transition state to the report. Knowing that isPending was true when the error fired immediately tells you this was a swallowed transition failure rather than an ordinary render crash—a completely different debugging path.

Sentry.captureException(err, {
  tags: { transition: true },
  extra: {
    isPending: pending(),
    currentRoute: location.pathname,
    targetRoute: intendedPath,
  },
});

Recording currentRoute versus targetRoute is what reveals the "stuck" nature of the failure: the user was on A, tried to reach B, and never got there. Grouping these correctly matters too—our guide on fingerprinting and deduplication covers how to keep a hundred near-identical transition failures collapsed into one actionable issue.

A quick word on performance, since the obvious objection to all this instrumentation is overhead. The good news is that Solid's reactivity makes the cost negligible. An onError handler is just a registered callback—it does nothing until an error actually propagates, so the happy path pays zero. A wrapped fetcher adds one try/catch per request, which is noise next to the network round-trip it surrounds. Even the watchdog effect is cheap: a single setTimeout that gets cleared the moment the transition resolves. Compared to the alternative—shipping a UX that silently strands users with no diagnostic trail—the few microseconds of bookkeeping are not a trade-off worth agonizing over. Nested ErrorBoundary components do carry a slightly higher cost than a single global onError because each one establishes its own reactive scope, so reserve granular boundaries for the subtrees where a localized fallback genuinely improves the experience, and let the global handler backstop everything else.

Capturing the stuck state with a watchdog

Some transition failures don't throw at all—they just hang. To catch those, build a watchdog that fires if isPending stays true longer than a threshold.

import { createEffect, onCleanup } from 'solid-js';

function useTransitionWatchdog(pending, timeoutMs = 5000) {
  createEffect(() => {
    if (!pending()) return;
    const timer = setTimeout(() => {
      Sentry.captureMessage('Transition stuck > 5s', {
        level: 'warning',
        tags: { transition: 'abandoned' },
      });
    }, timeoutMs);
    onCleanup(() => clearTimeout(timer));
  });
}

If the transition resolves, the effect re-runs and the cleanup clears the timer. If it hangs past five seconds, you get a warning in your dashboard—a breadcrumb pointing straight at the purgatory state.

Why Session Replay is the "Black Box" for Transitions

Even with perfect telemetry, a log line tells you an error happened. It does not tell you the user was staring at a frozen screen, clicking a checkout button over and over, getting increasingly frustrated. For transition failures, that visible behavior is the bug report.

Visualizing the dead click

Session replay shows the dead click directly: the user clicks, the UI doesn't transition, they click again, they wait, they leave. Watching that sequence is often more diagnostic than any stack trace, because it confirms the failure was silent from the user's side—the precise thing your error stream missed. Run replay at a full session sample rate so the frames around the failed transition aren't dropped; the replay docs walk through the setup.

Reconstructing the reactive state

Solid's fine-grained reactivity is wonderful to write and miserable to observe—state mutates through signal updates that never touch a log file. Replay paired with a deminified stack trace lets you map the crash back to the exact component that failed to mount, and see the sequence of interactions that drove the signals into the failing state. Make sure your source maps are uploaded so those traces resolve to your real component files; the source map docs have the recipe.

Best Practices for Robust SolidJS Apps

A short checklist to keep transitions smooth without going blind:

  • Always provide a global error fallback. A root-level ErrorBoundary plus a root onError for telemetry ensures nothing escapes entirely, even when a local transition swallows the UI-level error.
  • Promote resource rejections into signals. Don't trust the transition to surface them. Catch in the fetcher, set an error signal, report, and let the UI render real feedback.
  • Be careful with optimistic UI. Optimistic updates that fail mid-transition leave the user looking at a state that was never actually committed. Always reconcile—and report—when the underlying mutation rejects.
  • Monitor transition abandonment. Track how often isPending stays true past a threshold. A rising abandonment rate is an early warning of a transition bug long before users start complaining.

UX Shouldn't Come at the Cost of Observability

The whole appeal of useTransition is that it hides the messy in-between state from your users. The danger is that it hides that same state from you. Smooth transitions and clear error reporting are not in conflict—but you have to engineer for both deliberately, because the default behavior optimizes for UX smoothness at the expense of error visibility.

Lean into Solid's fine-grained reactivity instead of fighting it: the same primitives that make your UI fast (signals, effects, onError) are exactly the tools you need to make failures observable. Catch at the source, attach transition context, watchdog the hangs, and let replay show you the dead clicks your logs can't.

Stop guessing why your users are refreshing the page. Catch every silent transition failure with GlitchReplay—flat-rate session replay and Sentry-compatible error tracking, built so even the errors your ErrorBoundary swallows still reach your dashboard.

Stop watching your error bill spike.

GlitchReplay is Sentry-SDK compatible, includes session replay and security signals, and never charges per event. Free to start, five minutes to first event.