Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 Typed Pings and Failure as Data in React Router

TEEI in Practice

Engineering noteResearch & Engineering

Part of: React Router 8

Typed Pings and Failure as Data in React Router

We refresh one unread count without rerunning the page's loaders. To keep the page after a rejected background panel load, we return failure as data.

On this page
  1. What a message used to cost
  2. How we mark what changed
  3. One number, one route
  4. How we lost the page to a background read
  5. Why a fetcher cannot fail locally
  6. How we return failure as data
  7. Choosing between a fetcher and a plain fetch
  8. Verify this
  9. Appendix: versions and settings
  10. Frequently asked questions

We used to answer an incoming message by asking every matched React Router route for fresh data. We added typed pings and used a fetcher for the unread count, and away from the messages page we now refresh the count and the bell panel without rerunning the page’s loaders. We still had to contain failure: after a rejected background read of the bell panel, we showed an error screen in place of the whole page. For the failures we intend to contain, we now retry once in a clientLoader and return an unavailable body if the retry fails. We keep the page and show that we could not load the panel.

We can refresh one unread count without rerunning the page’s loaders and still lose the page to a failed background load. We follow that gap, between the scope of a refresh and the scope of its failure, through the shared React Router 8 codebase that runs TEEI Language and TEEI Mentorship on our Cloudflare Workers platform.

Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.

What a message used to cost

In the app shell’s loader we read, for every page, the person, their role and programme, their timezone, the two unread counts, review and waiting-list flags and the next session; in each child route we load more. When we notify a person of an incoming chat message, we write a row for the bell and send a ping over the person’s live socket. Until a change we committed on 31 July 2026, we answered that ping in the shell with revalidate(), so we re-ran the shell loader and the page’s loaders in every open tab of the recipient. In that commit we estimated about 30 database reads per incoming message, per open tab on the home page. That is our estimate, not a measurement: we did not measure database reads in production before or after the change, and we have no figure for what the fix saved.

When we call revalidate(), we request fresh data from every matched route unless we decline through shouldRevalidate. When we call fetcher.load(href), we run the loader of the one route at href, without a navigation and without a page revalidation; by default React Router revalidates that fetcher again only when we complete a successful action or call revalidate(). We could not choose the narrow load from the ping, because the ping did not say what had changed.

How we mark what changed

We had deliberately kept the ping for a new bell row close to empty, with no title, body or account id, because in the browser we re-read the content through its own loader. We added one field, the notification’s type code, and kept it optional.

Adapted for publication.

ts
// A notification of any type was created for this account. No title, no body, no
// account id. The type code (for example 'message_received') is optional: from an
// older server we receive none, and we then fall back to a global revalidate.
| { type: 'notification'; notificationType?: string }

Optional is the important word. During a deploy we do not switch every server and every open tab at the same instant, so we can receive a ping without a type from an older server version. We read a ping without a type as “something changed, I will not say what”, and we revalidate everything. In our protocol tests we keep both shapes valid: the bare ping, and a typed one that carries the type code and nothing else.

We handle two event types for a message. When we save a message, we send a msg_activity event with the activity message to the other person in the conversation. In a later delivery step we decide whether to notify that person; when we do, we write the bell row, send notification with the type message_received, and send msg_activity again. We load the count for both message event types. For other notifications, such as a booking being accepted or a reminder, we keep global revalidation because they can change the page.

Adapted for publication.

tsx
const shellRevalidator = useRevalidator();

useLiveSocket(true, (message) => {
  if (message.type === 'notification') {
    if (message.notificationType === 'message_received') {
      refreshUnread();
      return;
    }
    // Any other type, or a ping without one: deliberately global.
    shellRevalidator.revalidate();
    return;
  }
  if (message.type === 'msg_activity' && message.activity === 'message') {
    refreshUnread();
  }
});

We omit how refreshUnread loads the count, the checks on each fetcher’s state and the error reporting around each call.

Sequence diagram of one chat message. We send an activity ping to the recipient’s tab when we save the message, and if we notify the recipient later, a notification ping and a second activity ping. For both event types we load a small unread-count route through a fetcher and a debounced bell panel route; we do not rerun the page’s loaders, and how many loads we make depends on when the events arrive. A dashed side path shows that for a ping without a type, or with any other type, we request fresh data from every matched route, and we may skip individual routes through shouldRevalidate.
Figure 1. What we load in a recipient’s tab for one chat message, away from the messages page. Drawn from our code, 15 October 2026. Source: TEEI, drawn from our platform code, read on 15 October 2026.

One number, one route

We serve the count from a resource route, a loader with no component, from which we return the unread count for each of the person’s two roles, as a learner and as a volunteer, and their sum. We use the same queries in the shell loader and resource route, including how we fold group chats into each role’s count. That keeps the counting rules aligned, but the two are separate reads at different moments, and they can differ. We let the fetcher result override the loader’s values when it holds both counts as numbers. We clear the override when loader data changes. We replace it when fetcher data changes and contains both counts. Where the badge and the page must show the same reading, we would use one loader result for that part of the screen rather than a narrow refresh.

We refresh the bell on the same events, through a fetcher of its own and its own panel route, and we debounce those reads, restarting the wait when another ping arrives. Away from the messages page, we refresh the count and bell panel without rerunning the page’s loaders. How many requests that makes depends on when the events arrive. On the messages page itself we still revalidate the open conversation through its own socket, the one place where a message does change the page.

In the commit of 31 July 2026 we recorded one development run: “three messages, three scoped unread calls, zero page revalidations, badge 1-2-3.” That run counts the unread calls only, and we have no measured baseline from before the change.

How we lost the page to a background read

The bell is where the narrow refresh went wrong. Until a change we committed on 1 August 2026, we managed its panel with raw fetch() calls. In that change we moved the panel to a loader on its own route, read through a fetcher. From then on, when we failed to load the panel in the browser, we went down the router’s fetcher error path. We load the panel on focus, on visibility, on every navigation and on socket pings, so a person sitting on any page generated background loads they never asked for, and one network blip on one of them was enough. In the words of the fix’s commit message: “the whole page turned into the error box over a background read.”

By 5 October 2026 our error tracking had 153 events of this kind, and in every event we read, the failing request was the panel’s data request, one to twenty seconds after a successful one. We committed the fix that day. We do not know when the first event occurred. We have code history for the move to a fetcher, not a measured cause.

We had already handled the server side: when a database read fails there, we return a declared “could not read” body with a 503 status, and in the bell we render it as such. What remained was the load that fails in the browser, where we have no body from our server to show and see only a rejection.

Why a fetcher cannot fail locally

We narrowed the refresh, but still reached the page’s error boundary when a fetcher load threw. To us a fetcher looked like a local request with state attached. Its failure path is not local. In React Router 8.4.0, an error from an explicit fetcher.load() call reaches this function in the router:

Verbatim excerpt.

js
	function setFetcherError(key, routeId, error, opts = {}) {
		let boundaryMatch = findNearestBoundary(state.matches, routeId);
		let workingFetchers = new Map(state.fetchers);
		deleteFetcher(workingFetchers, key);
		updateState({
			errors: { [boundaryMatch.route.id]: error },
			fetchers: workingFetchers
		}, { flushSync: (opts && opts.flushSync) === true });
	}

For this explicit load, routeId is the route that owns the fetcher, not the route being loaded. In findNearestBoundary React Router searches from that route itself up to the root and takes the first route with an ErrorBoundary, or the root if none has one. We receive no data where we called load(): React Router deletes the fetcher and puts the error on that boundary. This happens when a request rejects or when we throw from the loader. When React Router later revalidates that fetcher automatically, it searches the page’s matches from the loaded route’s id instead; for a resource route outside those matches, findIndex returns -1, the search covers no routes and it falls back to the root, where we lose the fetcher too. We read that branch from the router source; in our reproduction below we exercise explicit loads.

When we return an error body from the loader, even with a 503 status, we receive it in fetcher.data, which is how we get the server’s own “could not read” body into the bell. With single fetch, React Router keeps the HTTP status apart from the loader result. When a response carries the framework’s own marker, React Router decodes it even with a 503 status: it returns a result under data and throws one under error. It throws a status of 400 or more without that marker, such as an error page from the edge, as an error response.

One more consequence surprised us. In our reproduction, when we awaited the router’s fetch, we received a resolved promise even when the error went to the boundary. When we await load() inside a try/catch, we catch a rejected call, but not an error that has already been put on a route’s boundary. When we wrap the call in error reporting, we do not make a fetcher safe.

Three columns showing the same failed explicit background load. With an unwrapped fetcher, we reach the nearest error boundary, searched from the route that owns the fetcher upwards, and we lose the fetcher; in our panel incident, we saw the error screen in place of the page. With a clientLoader wrapper, we retry once and then return a declared unavailable body for the rejections we intend to contain, and the page stays. With a plain fetch, we keep the failure with the caller and nothing reaches the router.
Figure 2. Where a failed explicit background load ends up in React Router 8.4.0, by how we make the request. From the router source and our reproduction, 15 October 2026. Source: TEEI, drawn from the React Router 8.4.0 source and our platform code, read on 15 October 2026.

How we return failure as data

In the fix we gave the background routes a clientLoader. In a clientLoader, we call serverLoader() from the browser and catch the rejections we intend to contain before we reach an error boundary. In each of those routes we call one function; here is the bell’s:

Adapted for publication.

ts
export async function loadBackgroundPanel<T, F>({
  serverLoader,
  signal,
  tag,
  unreachable,
  retryDelayMs = BACKGROUND_PANEL_RETRY_DELAY_MS,
}: BackgroundPanelLoadOptions<T, F>): Promise<T | F> {
  for (let attempt = 1; ; attempt += 1) {
    try {
      return await serverLoader();
    } catch (error) {
      if (error instanceof Response || signal.aborted) throw error;
      if (attempt === 1) {
        await pause(retryDelayMs, signal);
        if (signal.aborted) throw error;
        continue;
      }
      if (isRouteErrorResponse(error)) {
        // A router error response (for example a 5xx from the edge): we keep the error
        // level we do not give a transport blip.
        reportClientError(tag, new Error(`response_${error.status}`), { status: error.status, attempts: attempt });
      } else {
        reportClientWarning(tag, error, { attempts: attempt });
      }
      return unreachable();
    }
  }
}

We made three decisions in it.

What we retry. We retry rejected loads, not every unsuccessful HTTP status: when serverLoader() resolves with the server’s declared unavailable body, we return it unchanged. We keep return await serverLoader() inside the try, because if we returned its promise without awaiting it, we would not catch a later rejection here. In React Router 8.4.0, each time we call serverLoader() from a fetcher load, we go through single-fetch decoding to a new fetch, so with our second attempt we send another request. We retry once, after a pause that follows the abort signal, because a phone coming back from the background often has no network for a moment. The default pause is 1.5 seconds; that is a choice, not a measured value.

What we pass through. We pass on a rejection that is a Response instance in the browser, which is the router’s own control flow, such as a redirect from serverLoader(). A response we throw on the server can instead arrive in the browser as a decoded router error object; we retry that rejection like any other. We also pass an aborted load through unchanged: we check the signal rather than infer why the request was cancelled, and we do not turn a cancellation into an unavailable panel.

What we return. After the second failure we report once and return the route’s own declared “unavailable” body, which we receive in fetcher.data. We report a router error response, such as a 5xx from the edge, as an error, and any other rejection, such as a dropped connection, as a warning. Returning failure as data works only because we give the bell a contract for that data. We read its response as pending before a response, ready after a valid success, unavailable after a declared failure and unreadable for a body outside the contract; we show both failure readings as “could not load”, and we report the unreadable one. We do not infer an empty inbox from missing fields, which is how a failed read once looked like an empty inbox.

In the route itself we only connect the pieces:

Adapted for publication.

ts
export async function clientLoader({ request, serverLoader }: Route.ClientLoaderArgs) {
  return loadBackgroundPanel({
    serverLoader,
    signal: request.signal,
    tag: 'bell.panel_unreachable',
    unreachable: bellPanelUnreachable,
  });
}

We write the wrapper per route, and we protect only the routes we give it to. It is not a property of fetchers.

Choosing between a fetcher and a plain fetch

In our conventions we give a background request one of three homes:

  1. A plain fetch(), marked with a reason in the code, when a transport failure must stay with the caller: background polls, fire-and-forget pings, and writes the person can retry on the same screen. With this choice we give up the router’s cancellation, request sharing and revalidation.
  2. A fetcher with a clientLoader wrapper, so that we turn a transport failure into a declared body in fetcher.data instead of a throw.
  3. A plain fetcher where we accept the takeover, because an error screen is an acceptable outcome there. In our conventions we put it this way: “a fetcher is right only where that takeover is acceptable”.

Writes have their own broad refresh. After a successful action, React Router revalidates every matched loader by default. In the shell we decline through shouldRevalidate only for a named write that provably touches none of the shell’s data, and we end the function in defaultShouldRevalidate. We avoid rules shaped around the URL: with a fetcher write we can change a parent route’s list while the URL stays the same, so a rule that returns false for an unchanged URL would skip exactly the refresh that write needs.

With the narrow refresh we decide what we load; with the wrapper we decide what a failure costs.

Verify this

We test with a real router to check where a rejected fetcher load ends up. In our own suite, without the wrapper, we see the rejection on the root boundary and receive no fetcher data; with the wrapper, router.state.errors stays null and we receive the declared unavailable body in fetcher.data. We check that transition in the router’s state, without rendering the page. In the wrapper’s unit tests we also cover recovery after one rejection, a thrown Response passed on without a retry, an aborted load passed on without a report, and an abort during the pause. We would catch an upgrade that stopped routing this rejected explicit load to the root boundary.

That suite is internal, but you can reproduce where a fetcher error lands in your own project, with nothing but react-router installed: no server, no network, no rendering. Save this as fetcher-error-repro.mjs and run it with node.

Adapted for publication.

js
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { createMemoryRouter } = require('react-router');
const { version } = require('react-router/package.json');

const Boundary = () => null;
const fail = () => Promise.reject(new TypeError('Load failed'));

async function run(label, routes, callerRouteId) {
  const router = createMemoryRouter(routes, { initialEntries: ['/app/home'] });
  for (let i = 0; i < 50 && !router.state.initialized; i += 1) await new Promise((r) => setTimeout(r, 0));
  let handed;
  const un = router.subscribe((s) => { const f = s.fetchers.get('badge'); if (f?.state === 'idle') handed = f.data; });
  await router.fetch('badge', callerRouteId, '/api/count');
  un();
  console.log(label, JSON.stringify({
    errorsOn: router.state.errors ? Object.keys(router.state.errors) : null,
    message: router.state.errors ? Object.values(router.state.errors)[0]?.message : null,
    fetcherHandedData: handed ?? null,
  }));
  router.dispose();
}

console.log('react-router', version);
const tree = (countLoader, shellBoundary) => [{
  id: 'root', path: '/', ErrorBoundary: Boundary, loader: () => null,
  children: [{
    id: 'shell', path: 'app', loader: () => null, ...(shellBoundary ? { ErrorBoundary: Boundary } : {}),
    children: [{ id: 'home', path: 'home', loader: () => ({ page: 'home' }) }],
  }, { id: 'count', path: 'api/count', loader: countLoader }],
}];

await run('1 throws, no boundary below the root:', tree(fail, false), 'home');
await run('2 throws, the shell has a boundary (home inherits it):', tree(fail, true), 'home');
// 3: the same failure; we catch it in a wrapper and return a declared body
const safe = async () => { try { return await fail(); } catch { return { ok: false, code: 'unavailable' }; } };
await run('3 a wrapper returns a declared body:', tree(safe, true), 'home');
// 4: we return an error-shaped object from the loader instead of throwing
await run('4 an error body returned, not thrown:', tree(() => ({ ok: false, status: 503 }), true), 'home');

When we ran the original of this script against React Router 8.4.0 on 15 October 2026, we got this output (labels translated):

Adapted for publication.

code
react-router 8.4.0
1 throws, no boundary below the root: {"errorsOn":["root"],"message":"Load failed","fetcherHandedData":null}
2 throws, the shell has a boundary (home inherits it): {"errorsOn":["shell"],"message":"Load failed","fetcherHandedData":null}
3 a wrapper returns a declared body: {"errorsOn":null,"message":null,"fetcherHandedData":{"ok":false,"code":"unavailable"}}
4 an error body returned, not thrown: {"errorsOn":null,"message":null,"fetcherHandedData":{"ok":false,"status":503}}

We capture the fetcher’s data in a subscriber, because idle entries are removed from router.state.fetchers right after subscribers are notified; with useFetcher, we read the copy React keeps. In lines 1 and 2 we see the error on the nearest boundary and nothing handed to the fetcher; in line 3 we keep the page with a declared body. Know the limits of the script: in case 3 we use a plain try/catch in the loader rather than a clientLoader, in case 4 we return an ordinary object with a status field rather than an HTTP 503 response, and we inspect the router’s state without rendering anything. In cases 1 and 2 we also see a resolved promise from await router.fetch(...), not a rejection.

If you are building with React Router or Cloudflare, or with TEEI, talk to us and we will walk you through the code.

Appendix: versions and settings

Further reading in the official documentation: useFetcher, fetchers, revalidation, shouldRevalidate, error boundaries and error reporting.

Frequently asked questions

Do we revalidate every page loader when we call fetcher.load in React Router 8?

No. With fetcher.load we request one route's loader without a navigation, and we start no page revalidation. When we call revalidate(), or complete a successful action, React Router asks every matched route for fresh data by default, unless we decline in a route's shouldRevalidate.

What happens when we throw from a fetcher loader in React Router 8?

We do not receive the error as fetcher data. On an explicit load(), React Router puts the error on the nearest error boundary, searched from the route that owns the fetcher upwards, and deletes the fetcher. On a later automatic revalidation of that fetcher, it searches from the loaded route instead and can reach the root boundary. When we await load(), we receive a resolved promise, so a try/catch around it does not see the error.

How do I keep the page when a background fetcher load fails?

In the target route's clientLoader, we catch the rejections we intend to contain, retry once and return a declared unavailable body into fetcher.data. We pass a thrown Response and an aborted load through untouched. Where the failure must stay entirely local, we use a plain fetch outside the router.

More in Research & Engineering

Partnerships

Talk to us about building with TEEI

We use these details only to reply.