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.
The programme
On this page
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.
// 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.
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.
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.
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.
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.
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.
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:
- 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. - A fetcher with a
clientLoaderwrapper, so that we turn a transport failure into a declared body infetcher.datainstead of a throw. - 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.
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.
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
- React Router 8.4.0 (
react-router,@react-router/devand@react-router/cloudflare), on Cloudflare Workers. We read the code and settings described here on 15 October 2026; earlier changes carry the date of their commit. - We hold the live socket in the
LivePresenceDurable Object, bound asLIVE_PRESENCE. - TEEI Language and TEEI Mentorship share one React Router codebase built into two Workers, which Two Workers, One React Router 8 App: Decide at the Boundary describes. We describe the Durable Object that sends the ping in Durable Objects for Rings, SQL for Outcomes, and how we keep open tabs working through a deploy in Old Tabs, New Workers: A Guarded Reload.
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.