Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 How We Join React Router Errors Across Browser and Worker

TEEI in Practice

Engineering noteResearch & Engineering

Part of: Cloudflare Workers

How We Join React Router Errors Across Browser and Worker

We can capture an error and still struggle to follow the failure. We compare a shared trace with a request ID across browser and Worker.

On this page
  1. One failed request, followed end to end
  2. Installed is not the same as reported
  3. Where we catch an error
  4. What reaches handleError
  5. One release name on both sides
  6. Joining the layers
  7. The hook that never saw transactions
  8. Verify capture, correlation and scrubbing separately
  9. What we would keep
  10. Frequently asked questions

TEEI’s free programmes connect people from Ukraine, and women from Latin America and the Caribbean, with volunteers who help them practise a language and build a career. When a participant cannot finish something on one of our platforms, an error in Sentry is where we start, not where we stop. We have to follow what happened from what they pressed on the page to the Worker that answered, and to the log line that Worker wrote, or did not write.

We can render a React Router error boundary and still report nothing to Sentry. We have already caught the loader error inside React Router before it can escape to the Worker’s Sentry wrapper, so we capture it in handleError instead. Then we still have to connect that server error to the browser event and the Worker log. We use a shared trace on one platform and a request ID on another. With the trace, we can follow the failure across browser and server. With the ID, we join records across searches.

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.

One failed request, followed end to end

On 3 October 2026, a learner on TEEI Language, our language-learning platform, pressed one of the club formats under “Not finding the club you want?” at the foot of the club page, and read “That did not send. Please try again.” Each such press tells the volunteers who decide what to host which kind of club a learner would come to: under “People have asked for”, they see one line for each club format and language with the number of people who asked, and a button to host it. This one was never recorded, and this is how the records led us to the cause.

Table 1. How we followed one failed club request on TEEI Language, from the incident record of 3 and 4 October 2026.
Where we lookedWhat it established
The browser event in Sentry, reported by the form that sent the requestWhich action failed, on which page and release, that we had classed it as blocking the learner, and the server’s answer: “Invalid request”
The request ID on that eventThe Worker request that had served the page, not the request that failed
Workers Logs, searched with that IDNo line from the club-request route
The route’s codeOne check answered 400 “Invalid request” without writing a log line: the check of the request’s shape
The learner’s profile, read without changing itNo learning language, so the page had offered the press with an empty language, and our form reader had dropped the empty field before the check

The missing log line was the finding. Other refusals on that route wrote a warning with their reason; this one wrote none, so the only record of why the request failed was the browser’s report. That report carried a request ID, but it was the document’s: the ID of the request that served the page, which we put in the page’s settings, as we describe below. The failed POST had an ID of its own, and the form did not report it. So a search with the event’s ID could not have found the refusal even if the route had logged it, and we had to establish from the route’s code that this refusal logged no reason.

We committed the fix the next morning and shipped it to production the same day. A press without a language now sends nothing; the page opens the language choice and asks “Which language?”. The route now logs a refused request shape with the names of the refused fields, never their values. In our regression tests, switching off either half fails exactly the new test for that half. The form still reports only the document’s ID, which is the limit of a request-ID join that we come back to under “What each choice costs”.

Two crops of a learner's club page in TEEI Language on a phone, from the demo platform with sample data. First, with a language set: under Past clubs, a club the learner took part in, then the ask, Not finding the club you want?, with five formats to press and the learner's language, English. The press goes from the browser to the Worker's club-request route, and approved volunteers see it under People have asked for. Second, with no language: after a format was pressed, the language pill reads Which language? and its panel has opened above a search field. Since 4 October 2026 the page sends nothing in this case; on 3 October the request went out with an empty language and the route refused it without a log line.
Figure 1. One press on the ask at the foot of a learner's club page in TEEI Language, with and without a language, and where each runs. Sample data. Source: TEEI Language demo platform, 17 October 2026.

TEEI Language helps adult learners practise through one-to-one conversations with approved volunteers and through group sessions, and a club request is how a learner asks for the group they would join. In a separate film, Oksana K., a TEEI Language participant, describes that choice.

Figure 2. Oksana K., a TEEI Language participant, on choosing individual or group meetings, at the times and on the days that suit her. Source: TEEI, a film recorded with Oksana K.’s consent; this square cut rendered on 22 September 2026.

The rest of this note is the machinery behind the table above: where we capture an error, how we name the build it came from, and how we join the browser’s record to the Worker’s.

Installed is not the same as reported

We build TEEI Language and TEEI Mentorship on one platform, and Evenhall, the workspace where a partner company’s managers see what their employees did through TEEI’s programmes and turn it into a report they can file, as a separate app. We run all three as React Router 8 apps on Cloudflare Workers, as we describe in How TEEI Runs Its Platforms on Cloudflare Workers, with @sentry/cloudflare on the Worker and @sentry/react in the browser.

We have learned from both directions that “Sentry is installed” and “our errors reach Sentry” are different claims. On Evenhall, in a comment in the server entry, we recorded zero error events in the project between its creation and 9 July 2026. We rely on the comment we wrote when we found the problem; we have not rechecked Sentry for this note. We had no handleError export, and we initialised Sentry per request with a wrapper of ours that captured events but never delivered them. We fixed both at once, by exporting handleError and moving initialisation to withSentry on the Worker’s default export, so we cannot say how much of the silence each one caused.

From our admin app, a React Router app our own staff work in, we sent the opposite: 8,584 events in 14 days, 83% of all Sentry traffic in our organisation, according to the commit in which we fixed it on 1 September 2026. People running vulnerability scanners had asked for paths that do not exist, and we reported each request as Error: [object Object].

Where we catch an error

When a loader or action fails, we let React Router catch the error and render the nearest error boundary. With withSentry we capture only errors that escape the Worker’s handler, so a team can run a Worker with withSentry and nothing else for months and get no report from its routes. For this we export handleError from entry.server.tsx, the hook described in React Router’s documentation for error reporting (error reporting, entry.server). We can also receive errors from the initial server render there.

On TEEI Language and TEEI Mentorship we explicitly capture errors on three paths outside the automatic capture. This is the first:

Adapted for publication.

ts
export const handleError: HandleErrorFunction = (error, { request, context, params }) => {
  const log = getLogger(context);
  const path = routerErrorPath(request, params); // We replace dynamic params with :name.
  // We treat 4xx ErrorResponses as client faults: we log a warning and never capture them.
  if (isRouteErrorResponse(error) && error.status < 500) {
    log.warn('router.client_error', { method: request.method, path, status: error.status });
    return;
  }
  log.error('router.error', error, { method: request.method, path });
  captureError(error, {
    tag: 'router.error',
    programme: sentryProgramme(request, context),
    extra: { method: request.method, path },
  });
};

We record the path with its recognised dynamic segments replaced by their parameter names, or a single placeholder when we cannot complete that replacement, and we scrub the whole event again before we send it. We attach the programme tag only when we can resolve it, and capture the error without the tag when we cannot. We learned that order on 23 July 2026, when we threw while resolving the programme for a scheduled job and captured that error instead of the original. A tag must never replace the error it describes: in a regression test we now assert that we capture the original Error object without a programme tag.

On the second path, we stream HTML through React’s renderToReadableStream and receive later rendering errors in its onError callback, where we log and capture them with their own tag. Before we send the Response we can still set the status to 500. After that, we still capture the failure, but we cannot change the HTTP status the browser already has. We describe how we run that stream under a strict content policy in a separate note.

On the third path, we catch errors thrown in middleware that runs before React Router, and in the request handler, in our Worker’s fetch handler, and return our own JSON 500 response. Because we return rather than rethrow, those errors never escape to withSentry, so we capture each one explicitly in that catch.

Diagram of how we handle errors in four paths through our React Router app on a Cloudflare Worker. We report loader and action errors through handleError before we render the error boundary, capture later streaming render errors in onError, catch errors that escape React Router in the Worker’s fetch handler and capture them explicitly, and only log router-generated 400, 404 and 405 errors.
Figure 3. Where we catch an error on TEEI Language and TEEI Mentorship. We use withSentry for errors that escape the Worker’s handler, and capture explicitly in handleError, onError and the fetch catch. Code as read on 15 October 2026. Source: TEEI, drawn from our platform code on 16 October 2026.

What reaches handleError

We caused the admin flood with a guard for instanceof Response. We read in React Router’s documentation of handleError: “Note that this does not handle thrown Response instances from your loader / action functions.” We found that true for a Response you throw yourself, and not true for errors generated inside the framework.

We read the installed react-router 8 package rather than run an experiment. For document and data requests, we receive an ErrorResponse in handleError only when it carries an underlying error; in the package we found the condition !isRouteErrorResponse(err) || err.error. The 400, 404 and 405 responses generated inside the framework, for a route with no match or a route without a loader or action for the method, carry that error, so we receive them in the hook. For an unmatched route we did not get a Response instance, so we let it through our admin guard, and in the capture we turned the object into a string. We turned every request a scanner made for a path that does not exist into an error.

We now use the check in the handleError above. Routine client faults stay in Workers Logs as warnings, and application failures go to Sentry: every 5xx ErrorResponse and every other thrown error, apart from one refusal generated inside the framework that we record as a warning.

Our own throws behave differently. When we throw a Response, or a data(...) with a payload that is not an Error, we receive nothing in handleError. When we throw a data(...) carrying an Error, we receive that Error itself in document and data requests, whatever status we gave it; in a resource-route request we get a Response instead, and no call to handleError.

One release name on both sides

We can use an error only if we can tell which build it came from. In a Worker we can read its version ID through the version metadata binding, and from version 10.35.0 of @sentry/cloudflare we get that ID as the release when we set none (Sentry’s Cloudflare troubleshooting page). At build time we cannot know a version ID assigned on upload, so nothing we file under the release during the build can use it.

So in both apps we name the release at build time and use the version ID only as the last fallback. On TEEI Language and TEEI Mentorship we prefer an explicit release name, then fall back to the source commit of the build candidate we froze for deployment, and finally the checkout’s commit. We compile the same build constant into the browser bundle, so when that constant is present, we send browser and Worker events with the same Sentry release string.

Joining the layers

Once we capture an error and name its release, we still need to see that the records on each layer belong to one failure. On Evenhall we use Sentry’s distributed tracing: we render sentry-trace and baggage into the page on the server and continue that trace in the browser. On TEEI Language and TEEI Mentorship we use a request ID instead. We found no explanation for the different choices in our commits, so we describe both and say what each costs us.

Evenhall: the server’s trace in the page

On Evenhall, we return the trace data of the current request from the root loader and render it into <head> in the root layout.

Verbatim excerpt.

ts
export function browserTraceData(): Record<string, string> {
  const active = getActiveSpan();
  const data = getTraceData(active ? { span: getRootSpan(active) } : undefined);
  return {
    "sentry-trace": data["sentry-trace"] ?? "",
    baggage: data.baggage ?? "",
  };
}

Adapted for publication.

tsx
<meta name="sentry-trace" content={telemetryTrace["sentry-trace"]} />
<meta name="baggage" content={telemetryTrace.baggage} />

Note getRootSpan. We run the root loader inside two child spans of our own, and in an earlier version we read the active span instead. When we cannot render Evenhall’s public demo live, we serve its last good copy. We must replace its trace values on every request, or we put every visitor on the storing request’s trace. We find the values to replace by the ones we rendered the page with. When we read them from a child span, we put a span ID into the page that we never matched when we later read the page in the Worker, and in the words of the fix on 30 September 2026, “the sentry-trace swap hit 0 slots and every copy kept the storing request’s trace”. We now read the root span in both places, and we reject the swap if an old trace pair would remain in the page.

We test the helper inside a child span. We require the complete root trace data, and check that a direct read of the child shares the trace ID but differs from the helper’s value. If we compared trace IDs alone, we would miss this regression. We found a warning about caching such pages in Sentry’s documentation, under “Caching HTML That Contains Tracing Meta Tags”.

In Evenhall’s browser code we turn off the tracing integration’s automatic page-load and navigation spans (browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })) and start the page-load span ourselves, from values read before hydration:

Verbatim excerpt.

ts
function startPageLoadSpan(config: BrowserTelemetryConfig): void {
  const client = Sentry.getClient();
  if (!client) return;
  Sentry.startBrowserTracingPageLoadSpan(
    client,
    {
      name: location.pathname,
      startTime: performance.timeOrigin / 1e3,
      attributes: {
        "sentry.source": "url",
        "sentry.origin": "auto.pageload.browser",
      },
    },
    { sentryTrace: config.sentryTrace, baggage: config.baggage },
  );
}

We give the reason in the comment above it: if we read the tags from the live document after rendering a root error boundary before load, we have already replaced <head>, and we would start the page-load trace on its own in exactly the case where we most want it joined to the server’s. We configure browser tracing to propagate only to requests to the page’s own origin (tracePropagationTargets); we have read that in the configuration, not observed the headers on the wire.

Sequence diagram of how we read the request’s root trace data in Evenhall’s Worker, render it into meta tags in the page head and start the browser’s page-load span from those tags. For a stored copy of the demo we swap the values on every request. We show propagation on the browser’s same-origin requests as configured, not observed.
Figure 4. How we carry one trace from Evenhall’s Worker into the browser. We show placeholders only, no real trace values. Code and configuration as read on 15 October 2026. Source: TEEI, drawn from our platform code on 16 October 2026.

Anyone can see the tags from outside, in the source of Evenhall’s public demo. Our measurement did not start at that link. At 13:09 UTC on 15 October 2026 we requested https://cockpit.theeducationalequalityinstitute.org/demo/ twice, four seconds apart, with curl -L, which follows redirects; we did not record the address that finally answered. In each load we found one sentry-trace and one baggage tag, with different trace IDs and the sampled flag set. The two responses carried different trace metadata. That demonstrates replacement for those observed responses, not that the HTML bypassed all caching. On your own project, compare two loads on each cache path you use.

TEEI Language and TEEI Mentorship: one request ID on every layer

On the language and mentoring platform we join the layers with an ID we create in the Worker for every request, and render no trace into the page.

With withSentry, we run each fetch inside its own isolation scope, so a tag we set there belongs to that request alone. We set the request ID as a tag on that scope, write it into every log line of the request, and return it as a response header. In the root loader we put it into the page’s settings, under a name we chose to mark it as the document’s ID, because we send each later navigation as a request of its own, with its own ID. When the browser SDK starts and the document request ID is present in the page’s settings, we attach it as a tag to the browser’s events.

Where that tag is present, we can join a browser event in Sentry to the server events of the request in which we served the document, and then find that request in Workers Logs by the same ID. You can see the arrangement from outside: in the front page’s source, which we fetched at 13:09 UTC on 15 October 2026, we found no sentry-trace and no baggage meta tag, and we found the document’s request ID and commit identity in the page settings. In the browser we add no tracing integration and set no propagation targets, so from reading the code we send no sentry-trace or baggage headers on the browser’s requests; we have not observed that on the wire.

We still trace inside the Worker. With React Router 8’s instrumentation API we open a span for the request and every loader, action and middleware, named by route pattern, never by the URL: we rename the request span to its method and pattern once the handler returns, and name the others by operation and pattern. When we are recording a span, we write its IDs onto the matching log lines:

Verbatim excerpt.

ts
function traceFields(span: Sentry.Span): Record<string, string> {
  if (!span.isRecording()) return {};
  const context = span.spanContext();
  return {
    sentryTraceId: context.traceId,
    sentrySpanId: context.spanId,
  };
}

So from the log line for a completed route we can reach its server trace when we recorded one. We set one trace sample rate per environment, for the Worker and the browser alike. We report these as configured sampling rates, not measured coverage; we have not measured the share of requests for which we obtain complete traces. We sample error events separately, so with a lower trace rate we get fewer complete traces, not fewer errors, and the request ID is on every request either way.

Diagram of one request ID that we create in the Worker and attach to its Sentry scope, its log lines, its response header and the page’s settings, and that we then attach to the browser’s own Sentry events when it is present in those settings. We join the events in Sentry by that ID and look the request up in the logs. We send no trace header from the browser to the Worker.
Figure 5. On TEEI Language and TEEI Mentorship we join browser and Worker events by one request ID instead of trace headers. Source: TEEI, drawn from our platform code on 16 October 2026.

What each choice costs

With a request ID we need no header on the browser’s requests, no CORS consideration and no tracing SDK in the browser at page load. That suits the language platform, where we load browser telemetry separately from the page and correlate the reports it delivers with server logs through the request ID. In return we get no single trace from click to loader in Sentry’s trace view: we follow an ID across searches instead. And an ID names only the request we gave it to. In the club request at the start, the browser’s report named the page’s request, so the event could not take us to the refused request; we had to establish separately, from the code, that the route logged no reason for refusing it.

On Evenhall our code continues the server’s trace in the browser, so a page load can join one trace; we have not read such a trace back from Sentry for this note. We pay for it by having to make sure that we never serve HTML containing trace tags twice unchanged. A request ID in cached HTML has a related limit: it still names the request in which we rendered the cached document, so refresh it if you need the request that served it now. If you need a joined page-load trace, read the metadata before hydration and refresh it whenever you serve stored HTML.

The hook that never saw transactions

Before we join records, we have to know what each one carries. We can carry data from a person’s visit in our telemetry: in URLs and query strings, and in the names and messages we show on our pages. On 30 September 2026 we learned from independent QA that our error scrub on TEEI Language and TEEI Mentorship did not cover sampled transactions from either the Worker or the browser, so a sampled transaction could carry such data to Sentry. We put the cause in one line in the commit message: “beforeSend only ever sees error events, so scrubErrorEvent never ran on it, and neither the Worker nor the browser had a beforeSendTransaction. Measured by independent QA with the real SDKs”. In the browser it is worse than it sounds, because there we send the full page URL as the transaction’s request.url.

So on TEEI Language and TEEI Mentorship we now give each of four kinds of data its own hook. We pass errors through beforeSend, transactions through beforeSendTransaction, logs, which we send only from the Worker, through beforeSendLog, and spans through beforeSendSpan. We send errors, transactions and logs through our shared redaction of strings. For spans we remove attributes by name, those whose names mark them as request metadata such as a URL, cookies or headers, and for a router span with a recognised route pattern we rebuild the description from that pattern and the method or operation. We set sendDefaultPii to false on both sides.

Diagram of four kinds of Sentry data on TEEI Language and TEEI Mentorship and the hook we pass each through. We send errors, transactions and logs through one shared redaction of strings, remove transaction fields of an unknown or unsupported shape, and drop a transaction only when scrubbing fails. For spans we remove attributes by name, those whose names mark them as request metadata such as a URL, and, for a router span with a recognised route pattern, rebuild the description from that pattern and the method or operation.
Figure 6. On TEEI Language and TEEI Mentorship we give every kind of event its own hook. We added the transaction hook on 30 September 2026, after we learned from independent QA that we could not scrub transactions in beforeSend. Code as read on 15 October 2026. Source: TEEI, drawn from our platform code on 16 October 2026.

With the fix we gave transactions their own scrub. We send the transaction name, the request URL, the query string, every span description and every string in span data through structural rules and then the shared redaction. We replace every query value except a few known-safe keys with [redacted], and a fragment with #[redacted]. In the transaction’s request object we keep only url, method and query_string. We remove fields of an unknown or unsupported shape and keep the rest of the transaction; if scrubbing fails, we drop the whole transaction.

We did not catch the leak with unit tests of a scrub function; we had missed which kind of event we pass to which hook. So we now test at the transport boundary, through the real browser SDK in jsdom and through the Worker’s own options with the real withSentry: credentials must be absent, removal must hold for our actual route table, and any unreviewed top-level transaction field fails the test. On 30 September 2026, with the Worker hook removed, two of the four Worker tests we had then failed.

On 15 October 2026 we read the commit identity we return in a response header from production, and we found the fix in its source commit. We have not verified the uploaded bundle or measured the fix’s effect in Sentry.

Verify capture, correlation and scrubbing separately

A rendered error boundary, an accepted Sentry event and a joined trace are three different results. Test them separately in a project you control, with synthetic data. Our code readings and dated public-page checks do not establish end-to-end delivery for every failure path.

Table 2. A verification matrix for the mechanisms in this note.It is a proposed procedure for your test project, not a new production run at TEEI.
Deliberate inputInspect at the boundaryWhat should be established
Loader throws an ErrorRouter hook and SDK transportThe original error reaches explicit capture despite the rendered boundary
Framework-generated unmatched routeHook and log outcomeThe 4xx object is classified as a routine client fault
Late stream render fails after response headersRender callback and captured eventThe failure is reported even though the sent HTTP status cannot change
Cached HTML served twiceComplete trace metadata in both responsesA storing request’s metadata is replaced on each serving path
Browser navigates after the initial documentDocument ID and navigation request IDThe document tag is not mistaken for the failing navigation’s ID
Synthetic query value in each supported event kindActual SDK transport payloadsEach event kind reaches its own scrub hook

Use an in-memory transport for the capture and scrubbing checks before sending any event. Removing the relevant hook should make the same assertion fail; otherwise the test may only prove that no telemetry was sent. A transport test still does not prove ingestion, indexing or source-map resolution in Sentry. Those need a separate, dated readback of the synthetic event after an authorised send.

What we would keep

If we started again, we would keep three habits. We capture errors where we let React Router catch them, not only where they escape the Worker. We name the release at build time and compile the same string into both sides. And we decide before an incident how we will connect the browser’s record to the Worker’s: a shared trace if we can keep it fresh in every page we serve, a request ID if we would rather follow one value across searches.

On both platforms we use @sentry/cloudflare and @sentry/react directly and wire handleError, the instrumentation and the meta tags ourselves, not @sentry/react-router. We write more code that way, and we can read that code when we miss an event. If you start fresh, look at Sentry’s React Router guide first.

Session Replay is separate from this error and trace comparison. In the configuration we read on 15 October 2026, both programmes set session replay sampling to zero everywhere and error-triggered replay sampling to zero in production. The presence of replay code does not mean that these production sessions are being recorded.

We read the code and configuration in this note on 15 October 2026, on React Router 8 and the version 10 Sentry SDKs.

Frequently asked questions

Why do I need handleError as well as withSentry for React Router loaders?

When a loader or action fails, we let React Router catch the error and render the error boundary, so the error never escapes to the Worker fetch handler we wrap with withSentry. We capture it in handleError, exported from entry.server, and capture later streaming-render errors in the onError callback of renderToReadableStream.

Which React Router 8 errors do we receive in handleError?

We do not receive a Response we throw in a loader, or a data() we throw with a payload that is not an Error. We do receive the 400, 404 and 405 responses generated inside the framework, so we filter them with isRouteErrorResponse(error) and error.status below 500. We receive the Error carried by data() in document and data requests, not in resource-route requests.

How can I connect a browser error to the Worker request in which we served the page?

Either render the server trace into sentry-trace and baggage meta tags and start the browser page-load span from them, or put one request ID on the server scope, in the HTML and on the browser scope. We use both, on different platforms, and compare them here.

Can I scrub Sentry transactions in beforeSend?

No. We can use beforeSend only for error events. On TEEI Language and TEEI Mentorship we scrub transactions in beforeSendTransaction and spans in beforeSendSpan, in the Worker and in the browser, and we send Sentry Logs only from the Worker, through beforeSendLog.

Should the Sentry release on a Worker be the Cloudflare version ID?

Only as a fallback. At build time we cannot know the version ID, so nothing we file under the release during the build can use it. We name the release at build time and compile the same string into the Worker and the browser bundle.

How do we follow a failed participant action from the browser to the Worker?

We start from the browser event, read the request ID it carries and look that request up in Workers Logs. On TEEI Language the browser event carries the ID of the request that served the page, not of a later request that failed, so for a failed form submission we also read the route for refusals that log no reason. That is how we found why a club request failed in October 2026.

More in Research & Engineering