Remix loaders, actions, and error boundaries: where errors actually surface

A map of every place a Remix request can fail, the boundary that catches it, and the breadcrumb that explains why.

·
remixtutorial

A user clicks "Submit" on a complex multi-step form. The screen flashes white and lands on your generic "Something went wrong" page. You pull the logs: a 500 in a loader. But the user never touched a loader—they submitted an action. So why is your error pointing at a read path when the user triggered a write?

This is the Remix mental-model gap. In Remix, where an error happens is routinely decoupled from where it gets displayed. An action can fail, trigger a re-validation that re-runs your loaders, and surface the crash through a loader's error path—three hops away from the button the user actually pressed. Most Remix tutorials stop at "add an ErrorBoundary and you're done." But for production monitoring, the boundary is the end of the journey, not the start. This post maps every place a Remix request can fail, the boundary that catches it, and the breadcrumb that explains why.

The Three-Tiered Error Architecture of Remix

Remix doesn't just catch errors—it routes them through your route hierarchy the same way it routes requests. Understanding that bubbling behavior is the foundation for everything else.

The server/client handshake

Remix runs your loaders and actions on the server, serializes the result, and ships it to the browser. When an error crosses that network boundary, it gets serialized too—and the serialization is deliberately lossy for security. A raw Error thrown on the server arrives at the client with its message and stack stripped in production, replaced with a generic shape, so you don't leak internals to the browser. A thrown Response, by contrast, preserves its status and any data you explicitly attached. That asymmetry matters enormously when you're trying to surface useful error codes to the UI.

Bubble-up logic

When a route throws, Remix walks up the route tree looking for the nearest ErrorBoundary. A crash in a deeply nested leaf route can be caught by a parent layout's boundary if the leaf doesn't define its own. This is powerful—you can have a single app-shell boundary as a backstop—but it's also why "the error showed up in the wrong place" is such a common complaint.

// Route tree:
//   root.tsx              <- has an ErrorBoundary (catch-all)
//     routes/dashboard.tsx
//       routes/dashboard.settings.tsx  <- throws, no boundary
//
// The settings error bubbles UP to dashboard.tsx if it has a
// boundary, otherwise to root.tsx. The user sees the PARENT's
// fallback, not the route that actually failed.

Loaders: The "Read" Failures

Loaders run before the page renders, fetching the data a route needs. Errors here happen early—the user may not have interacted with anything yet—which makes them easy to misattribute.

Expected vs. unexpected errors

The cleanest Remix code distinguishes between conditions you anticipate and bugs you don't. For anticipated conditions—a missing record, an unauthorized request—throw a Response with the right status. For genuine failures—a database that timed out—let the raw error throw. Your error tracker should treat these completely differently.

The 404 trap

Throwing a 404 Response from a loader is a feature, not a bug. It's how you tell Remix "render the not-found UI." The problem: if your error tracker captures every thrown Response indiscriminately, your dashboard fills with 404s that are working exactly as designed. Filter those out at the boundary—only report 5xx and unexpected exceptions, never the 4xx you threw on purpose.

export async function loader({ params }) {
  const post = await db.post.findUnique({ where: { id: params.id } });

  // EXPECTED: a clean 404, triggers the boundary, but should NOT
  // be reported as a bug.
  if (!post) {
    throw new Response('Not Found', { status: 404 });
  }

  return json({ post });
}

Parallel loader failures

Remix runs all of a route's loaders in parallel. If three loaders fire and one throws, the failing one triggers the boundary while the other two may have already succeeded. Your error report needs to capture which loader failed and the route it belonged to—a bare "500 on /dashboard" is nearly useless when three independent data sources feed that route.

Actions: The "Write" Failures

Actions handle POST, PUT, PATCH, and DELETE—the mutations. They have a distinct failure profile because they almost always involve user input, and user input is wrong constantly.

Validation errors vs. logic crashes

Here's the rule that keeps your error stream clean: never throw for validation, always throw for infrastructure. "Invalid email" is not an exception—it's an expected outcome you return to the form via useActionData. "Database connection timed out" is an exception—it's a real failure you re-throw so it hits your tracker and your boundary.

export async function action({ request }) {
  const form = await request.formData();
  const email = String(form.get('email'));

  // VALIDATION: return it, don't throw it. Not an error.
  if (!email.includes('@')) {
    return json({ errors: { email: 'Invalid email' } }, { status: 400 });
  }

  try {
    await createSubscriber(email);
    return redirect('/welcome');
  } catch (err) {
    // INFRASTRUCTURE: this is a real bug. Re-throw to the boundary
    // and let your tracker capture it.
    throw err;
  }
}

The re-validation loop

This is the subtle one. After an action runs—success or failure—Remix automatically re-runs the loaders for the affected routes to refresh the UI. If the action mutated state into a shape a loader can't handle, the loader throws during re-validation. The user clicked a submit button (an action), but the error surfaces from the read path that ran afterward. Without breadcrumbs tracking navigation.state and the submitted formData, this looks like a phantom loader crash with no trigger. With them, you can see the action that poisoned the well.

There's a performance dimension here too that bites at scale. Because the re-validation re-runs every loader on the affected routes, a large actionData payload or a fan-out of expensive loaders means a single mutation can trigger a burst of server work—and if one of those loaders is slow or flaky, your action's perceived latency balloons even though the action itself succeeded instantly. When you're triaging "the submit button feels broken," the breadcrumb trail showing which loaders re-ran after the action, and how long each took, is often what reveals that the real culprit is a re-validation loader rather than the action the user blamed. You can opt specific loaders out of re-validation with shouldRevalidate when they don't depend on the mutation, which both speeds things up and shrinks the surface area where re-validation errors can hide.

The Hydration "Heisenbugs"

Because Remix server-renders and then hydrates in the browser, it inherits React's entire catalog of hydration mismatches.

Why "window is not defined" is the most common Remix error

Loaders and component bodies run on the server, where window, document, and localStorage don't exist. The instant a developer references one of those at module scope or during the initial render, the server build crashes with ReferenceError: window is not defined. It's the rite of passage of Remix development.

Suppressing vs. fixing

The right fix is usually a ClientOnly component that defers browser-dependent rendering until after hydration, or moving the access into a useEffect that only ever runs client-side. What you should not do is reach for suppressHydrationWarning as a blanket fix—that silences the symptom while the underlying mismatch keeps degrading interactivity. These hydration failures frequently never appear in server logs at all, because the server render succeeded; the break happens in the browser. The React-side diagnostics here are identical to what we cover in fixing React hydration error 418.

Decoding isRouteErrorResponse

The modern Remix way to handle errors inside a component is useRouteError paired with isRouteErrorResponse. This is where you turn a caught error into a sensible UI.

Type-safe error handling

The key insight: your boundary might receive either a thrown Response (with a status and data) or a raw Error (a real exception). isRouteErrorResponse is the type guard that tells them apart, so you can render a tailored 404 page versus a generic crash screen.

import { useRouteError, isRouteErrorResponse } from '@remix-run/react';
import * as Sentry from '@sentry/remix';

export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    // A thrown Response. Switch UI by status.
    if (error.status === 404) return <NotFound />;
    if (error.status === 403) return <Forbidden />;
    // Report 5xx, ignore expected 4xx.
    if (error.status >= 500) Sentry.captureException(error);
    return <GenericError status={error.status} />;
  }

  // A real, unexpected Error. Always report it.
  Sentry.captureException(error);
  return <GenericError status={500} />;
}

Accessing the data property

A thrown Response can carry a data payload—an error code, a user-facing message—that you can surface in the UI without exposing a raw stack trace. This is how you tell the user "your session expired" instead of leaking TypeError: cannot read properties of undefined to anyone who hits the page.

This serialization asymmetry is also a security feature you should lean into deliberately. In production, Remix strips the message and stack from a raw thrown Error before it crosses to the client—the browser receives a sanitized stand-in—precisely so an unhandled exception can't leak your database schema or internal file paths to an attacker probing your forms. A thrown Response, by contrast, sends exactly what you put in it. The practical rule: anything you want the user to see goes in a Response with a deliberate data shape; anything that's a real bug stays a raw Error, gets sanitized on the wire, and travels to your error tracker instead—where you have the full stack because you captured it server-side before serialization ever happened.

The Cloudflare Context: Timeouts and CPU Limits

Remix runs beautifully on Cloudflare Workers and Pages, and GlitchReplay is built on the same infrastructure—so it's worth knowing the runtime-level failures that no ErrorBoundary can catch.

The 128MB memory wall

When a Worker hits its memory ceiling—say a loader pulls a multi-megabyte payload and tries to transform it—the isolate is killed by the runtime. Your ErrorBoundary never fires because the JavaScript context that would render it no longer exists. The crash happens below your application code.

D1 and Durable Object time limits

A slow D1 query or a Durable Object operation that exceeds its time budget surfaces as a Time Limit Exceeded, not a normal exception you can try/catch in a loader. To capture these, you need telemetry at the Worker level—wrapping the fetch handler and using ctx.waitUntil so the error report survives the response being sent. We go deep on this in debugging Workers in production; the short version is that ephemeral isolates drop in-flight telemetry unless you explicitly keep them alive.

Observability: Reconstructing the Crime Scene

Catching the error is half the job. Fixing it requires knowing the state that led there.

Breadcrumbs are king

The single most valuable thing you can capture for a Remix app is the trail of navigation.state transitions and the formData the user submitted before the crash. When a loader throws during re-validation, the breadcrumb showing the prior action submission is what connects the dots. Without it, you're staring at a loader error with no apparent cause; with it, the story writes itself: user submitted form X, action mutated state, loader Y choked on the new shape.

Session replay for Remix

For hydration mismatches especially—the ones that leave no server log—session replay is the black box. You watch the exact DOM state at the moment the mismatch occurred, see which interactive element went dead, and correlate it with the captured exception. Seeing the 30 seconds before a loader error reveals the precise user input that produced the 500. Run replay at a full session sample rate so first-frame errors aren't dropped, and mask form inputs by default; the replay docs cover the setup.

Source maps

Finally, make sure your Cloudflare Worker stack traces point at your .tsx files, not the bundled .js. Generate hidden source maps, upload them to your tracker during CI, and strip them from the deployed bundle. The full recipe is in the source map documentation—without it, every production trace is just server.js:1:40221, and you're back to guessing.

Remix's error model rewards understanding over reflex. Map the three tiers—loaders, actions, the runtime beneath them—respect the expected-versus-unexpected line so your stream stays clean, and instrument breadcrumbs and replay so a phantom loader crash tells you the action that caused it. Stop guessing why your loaders are failing in production. Use GlitchReplay to get Sentry-compatible tracking and full session replays on a flat-rate plan.

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.