Engineering noteResearch & Engineering
Part of: React Router 8
How We Share One Session Lookup Across React Router Loaders
We found a session lookup in the language helper, outside our list of guards. We moved resolution into layout middleware and gave the context three states.
The programme
On this page
We expected two session lookups per request. We found three.
Most signed-in pages on TEEI Language and TEEI Mentorship, one React Router 8 app built into two Workers, sit under one layout route, which we call the portal shell. In our plan of 31 July 2026 we listed the two places that looked up the viewer’s session on such a page: the shell’s guard and the child page’s guard. When we counted, the root loader looked it up as well, to choose the language of the page. It was not on our list because it did not look like a guard.
Each lookup made network calls to the one database our Cloudflare Workers platform runs on, a Turso primary in AWS Europe (Ireland). So we moved the lookup into a React Router middleware on the shell and shared the result through a typed router context. We resolve the session in the child layout’s middleware before even the root loader starts, so one lookup can serve all three readers. On three signed-in pages in a development build, we counted three lookups before the change and one after. We did not measure latency.
To reuse the result, the context has to tell “we have not checked” apart from “we checked and nobody is signed in”. We describe where the lookup happens and how the rest of the request reads it, not what a session is or how it is checked.
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 we counted
We instrumented the function that resolves the session and counted how often each request resolved it, recording nothing about who was signed in. On 31 July 2026 we opened three signed-in pages of TEEI Language: the learner home, the list of volunteers and the bookings page. Before the change, each of those requests resolved the session three times. After it, each resolved it once, in the middleware.
We counted lookups, not database statements; our commit message records the count, and we found no raw log lines in our repository. We have not repeated the count, counted production requests or measured latency. We cannot isolate this change’s effect on page time from later changes to how our Workers reach the database without a new measurement, and we have not made one.
Middleware runs before every loader
We resolve the session before any loader starts. React Router’s middleware chain runs “from parent routes to child routes on the way “down” to your route handlers”, and the route module reference says where the loaders come in: “on the leaf route the next function executes the loaders/actions for the navigation.” So the downward part of the chain, every middleware on the matched branch up to its next(), has run before any loader on that branch starts.
The root loader is one of those loaders. Our root route has no middleware of its own, but by the time its loader runs, the shell’s middleware below it has already resolved the session for this request. The loaders do not wait for each other: in React Router 8.4.0 the default data strategy starts the loaders it has selected together with Promise.all. We rely on middleware order, not on a parent loader finishing first.
Middleware runs per server request, not per navigation. Server middleware runs “for HTML Document requests and .data requests for subsequent navigations and fetcher calls”, and “Client-side navigations will only run server middleware if a .data request is made to the server”. So the shell’s middleware resolves the session once for each document or .data request that matches the shell branch, and a client-side navigation that needs no server data costs no lookup. We infer one run per request from the middleware chain and the rule that “You can only call next() once per middleware.”
The cache lives in the router context, and at the Worker entry we create a new RouterContextProvider for every request we pass to React Router. Nothing about the session is kept in module scope. Cloudflare’s Workers best practices say why that matters: “Workers reuse isolates across requests. A variable set during one request is still present during the next.” Keeping the current viewer in module scope could let one request reuse another person’s session.
We turned middleware on through React Router 7’s future flag on the evening of this change; it has been always on since we moved to React Router 8.3.0 the next day, and the code we show runs on 8.4.0.
A context with three states
We declare the session cache once, in a small module that both the Worker entry and the application import:
Adapted for publication.
// Default: no middleware has resolved the session for this request.
export const SESSION_UNRESOLVED: unique symbol = Symbol('session-unresolved');
export const sessionContext = createContext<Session | null | typeof SESSION_UNRESOLVED>(
SESSION_UNRESOLVED,
);
We import the same context object everywhere because the provider uses object identity as its key. A second createContext call creates another slot, even with the same type and default value.
We needed a distinct, defined default, and we chose a symbol. The createContext reference says that without a default, “reading this context when no value has been set will throw an error.” It does not say what an explicit undefined does. In React Router 8.4.0, get returns the default only when it is not undefined, and otherwise throws “No value found for context”. We read that in the source, and we had already met it in a failing test: our first cache test used an undefined default and threw as soon as it read a provider the middleware had not touched.
Neither context.get nor our fallback lookups store a result, so the context stays unresolved, and a second reader that falls back repeats the lookup. For a guard, the states mean:
SESSION_UNRESOLVED: no session result has been stored in this provider. The route is outside the shell, or the caller is a test. Look it up yourself.null: the middleware ran, and nobody is signed in. Do not look again; send the person to sign in.- a session: use it.
The provider checks for a stored value before it reads the default, so a stored null stays a result. With the obvious type, Session | null with a null default, an unset context reads as null too. A guard could not tell “nobody looked” from “we looked and nobody is there”, so it would either repeat the lookup we were removing or redirect a request that was never checked.
The middleware fills the cache, the guards decide
Adapted for publication.
// Resolve the session once per server request for the whole matched branch.
// Redirect decisions stay in the guards.
export const middleware: Route.MiddlewareFunction[] = [
async ({ request, context }) => {
const env = getRuntimeEnv(context);
const session = await resolveSession(request, env);
context.set(sessionContext, session ?? null);
},
];
We fill the context without calling next() or returning a response; the documentation’s own example notes that “next() is called automatically”. Our middleware does other work too, which we left out of the excerpt. It never redirects.
Every guard takes the router context as an optional argument and reads it first:
Adapted for publication.
export async function requireSession(request, env, context?) {
if (context && isRouterContextProvider(context)) {
const cached = context.get(sessionContext);
if (cached !== SESSION_UNRESOLVED) {
if (!cached) throw signInRedirect(request);
return cached;
}
}
return lookUpSessionOrRedirect(request, env);
}
We shortened the example without changing its branches. The role guards built on requireSession pass the same context on. Without a usable context, or with SESSION_UNRESOLVED, a guard looks the session up itself. A cached null goes straight to the redirect, without a second lookup. Because the argument is optional, we could adopt the cache route by route.
The third lookup needed the same read. In the root loader, we choose the language of the page from four sources in order: a language in the address, a language the browser already stored for us, the language saved on the signed-in account, and the browser’s Accept-Language header. Only the third needs the session, so the root lookup happens only on requests where neither the address nor the stored language has decided. Our record of the count does not say which case it ran with, only that the root lookup was observed. In the root loader, we pass context to the language helper. There, we read sessionContext first, look up the session only for SESSION_UNRESOLVED, and skip the account language for null. Callers that pass no context, such as several API routes outside the shell, keep their own lookup.
Opt-in makes the gain depend on coverage. When we went through our route modules on 15 October 2026, all but four of the calls to the session and role guards passed context, and those four sit in API routes outside the shell, where the context would hold SESSION_UNRESOLVED anyway. That says how many calls are wired to the cache. It does not say how many lookups a page makes.
The tests that guard it
We test the cache without a database or mocks. Every request in the test carries nothing that could identify anyone, so the real lookup can only end in a redirect. If the guard returns a session anyway, the only place it can have come from is the context:
Adapted for publication.
it('returns the middleware-resolved session without any lookup', async () => {
const ctx = new RouterContextProvider();
ctx.set(sessionContext, SESSION);
await expect(requireSession(request(), undefined, ctx)).resolves.toBe(SESSION);
});
SESSION is a synthetic fixture. Other tests in the same file check that a context holding null throws a redirect to the sign-in page, and that a provider the middleware never touched falls through to the real lookup. The null test checks the redirect; that no second lookup happens is something the code shows, not something that test proves. A test of the language helper puts a session with a saved language in the context and expects that language with zero session reads. Its name says what it proves: “uses the portal session cache without another database lookup”.
In a targeted search on 15 October 2026, we found no test that counts session lookups for a whole request. The tests prove that the cache is used when it is there. If a new route forgot to pass context to its guard, or a new helper in the root loader looked the session up on its own, they would still pass. Our only check for that is a count like the one on 31 July.
When this is the wrong pattern
When your lookup is cheap. The pattern earns its keep because each lookup makes network calls to one database in one region. If resolving the viewer makes no network call in your application, the cache saves little, and every guard still has to follow the contract. We resolve the session eagerly for every request through this layout; placing the middleware higher in the route tree makes the lookup run for more requests, including ones that never needed it.
When you cannot keep coverage up. We kept existing calls working by making context optional. A guard called without it repeats the lookup and returns the same type, so typechecking cannot catch that regression, and we have found no test that would.
When you want the middleware to decide. We left redirects out of this step on purpose and kept that move for a separate decision, which we have not made. A middleware that redirects decides for every request that passes through that layout. We keep redirect decisions in the existing guards; for middleware errors we rely on React Router, which handles an error a middleware throws “at the appropriate ErrorBoundary”.
When you expect fewer lookups across requests. The cache is per request by design. A document request followed by three fetcher requests to routes under the shell runs this middleware four times. A document POST, a form submitted without JavaScript, is one request: whichever loaders run after its action run inside the same middleware pass and share this context and its one lookup, even though React Router hands them a Request rebuilt from the URL, headers and signal. That is why we key the cache to the context and not to the Request. When a client-side submission reaches a server action, the action travels in its own .data request; if server loaders then revalidate, that is a separate .data request, and under the shell it gets a new context and another lookup.
When you cannot afford two context shapes. Hundreds of our route tests pass an older, loose context object, so our helpers recognise a RouterContextProvider by its get method and otherwise read the old shape. Maintaining both is a continuing cost.
Verify this
You can reproduce the mechanism in your own project. We have not run this exact recipe; we base it on the documentation and the behaviour described here. In a React Router 8 app, add a layout route with two child routes and one route outside the layout, each with a server loader. Declare createContext<Session | null | typeof UNRESOLVED>(UNRESOLVED), where UNRESOLVED is a symbol. In the layout’s middleware, increment a counter, then context.set a fake session. In the root loader and all three route loaders, read it with context.get.
- Load the first child route in a fresh tab. The counter goes up by one, and both loaders see the session.
- Load the route outside the layout. Its loader reads
UNRESOLVEDand does not throw. - Change the default to
createContext<Session | null>(undefined)and load the route outside the layout again. In React Router 8.4.0,context.getshould now throw. - Restore the symbol default and load the first child route again. Follow a client-side link to the second child route under the same layout, and watch its
.datarequest in your browser’s network panel. The counter goes up by one for that request. A client-side navigation that makes no.datarequest leaves it where it was.
The code we show is the code in our repository on 15 October 2026 unless we give another date.
Frequently asked questions
Can a root loader read a value set by a child layout middleware?
Yes, on the server. The downward part of the middleware chain reaches the leaf before any loader starts, and the leaf runs the loaders, so the root loader sees what a layout middleware below it set for that request.
Why use a symbol as the default value of a React Router context?
In React Router 8.4.0, context.get() throws when nothing has been stored and the default is undefined. A symbol default lets a route outside the middleware read the context safely, and keeps “nobody resolved this” apart from null, which means “resolved, and nobody is signed in”.
Did the change make pages faster?
We do not know. We counted session lookups, three before and one after, on three pages on a development build on 31 July 2026. We did not measure latency for this change.