Summary
A direct (SSR-time) server-function call derives its request event with a shallow copy, so locals is the same object as the enclosing render's. Two server functions running concurrently in one render overwrite each other's locals, and both overwrite the render's.
Tested against next @ 320f1f5c, built from source, Node 24.19.
packages/web/server-functions/src/server.ts, in createServerReference's apply trap:
const ogEvt = getRequestEvent();
if (!ogEvt) throw new Error("Cannot call server function outside of a request");
const evt = { ...ogEvt };
Reproduction
Runnable as-is:
import { AsyncLocalStorage } from "node:async_hooks";
const als = new AsyncLocalStorage();
globalThis[Symbol.for("solid.RequestContext")] = als; // without this: "Cannot call server function outside of a request"
import { getRequestEvent, createResponseStub } from "@solidjs/web";
import {
registerServerReference, createServerReference
} from "@solidjs/web/server-functions/server";
const make = tenant => createServerReference(
registerServerReference("fn-" + tenant, async () => { // (id, fn, name?)
const event = getRequestEvent();
event.locals.tenant = tenant;
await new Promise(r => setTimeout(r, 10));
return { wrote: tenant, readBack: event.locals.tenant };
})
);
const acme = make("acme"), globex = make("globex");
const outer = {
request: new Request("http://localhost/page"),
locals: { tenant: "OUTER" },
response: createResponseStub()
};
await als.run(outer, async () => {
console.log(await Promise.all([acme(), globex()]));
console.log("enclosing render now:", outer.locals);
});
[{"wrote":"acme","readBack":"globex"},{"wrote":"globex","readBack":"globex"}]
enclosing render now: { tenant: "globex" }
The acme call wrote acme, awaited, and read back globex. The render's own locals.tenant was overwritten too.
This is known internally, and defended internally only
Three places in the runtime record the sharing:
INVOCATIONS:
Deliberately NOT event.locals: locals is user/integration space, and derived events shallow-copy the event while SHARING locals, so a locals write from a nested or concurrent call would leak into (and overwrite) the outer scope's state.
the apply trap itself:
Keyed on the derived event (locals is shared with the outer event — see INVOCATIONS)
and getServerFunctionInvocation's docblock:
The state lives in a module-private WeakMap keyed by the per-call request event (never in event.locals, which derived events share with their outer event).
So core keeps its own invocation state out of locals because of this exact problem, three times over — and application code is left on the sharp edge. Nothing on the public createServerReference surface says so, and documentation/solid-2.0/12-ssr-http.md, which is the only doc file covering locals and middleware, has no caveat.
Why it matters in practice
locals is the conventional place for per-request context: the resolved tenant, the authenticated user, a DB handle, a request-scoped cache. The failure is silent (no warning, correct-looking values), non-deterministic (it depends on the interleaving of two createAsync-driven calls), and cross-tenant / cross-user in exactly the applications that have a tenant to confuse. Concurrent server functions in one render is the normal SolidStart data-loading shape.
Options
- Give each derived call its own
locals, prototype-linked to the outer one — Object.create(ogEvt.locals || null). Reads still see the render's context; writes stay local.
Trade-offs, measured: inherited keys vanish from enumeration — Object.keys(derived), {...derived} and JSON.stringify(derived) all show only own keys. Nothing in packages/ enumerates locals, but middleware and integrations downstream routinely do {...event.locals}, so this is a real behaviour change for them. The || null matters: Object.create(undefined) throws, so a custom createEvent that omits locals would turn a working direct call into a crash.
- Shallow-copy
locals — { ...ogEvt.locals }. No enumeration surprise, simpler to explain. Diverges from the outer scope for writes made on the render side after the call started, which is a different surprise in the other direction.
- Keep the sharing and document it loudly — on
createServerReference and in 12-ssr-http.md's locals section, ideally with a dev-mode warning when a direct call overwrites a key another in-flight call wrote. Cheapest; leaves a silent cross-tenant failure available to every app that uses locals the obvious way.
- Freeze
locals on derived events in dev — turns the silent overwrite into a loud throw during development, production unchanged. Pairs well with (3).
I lean to (2) plus a doc line: it removes the cross-call write channel without the enumeration change that (1) brings, and the divergence it introduces is visible rather than silent. But this is a semantics call — if the sharing is intended as a feature, (3)+(4) is a coherent answer, and the fact that it's recorded in three comments suggests it may be.
Not in scope: the response stub
The same shallow copy shares event.response. That one looks deliberate and useful — a cookie set by a server function during SSR reaching the page head is what you'd want, and the handler comment says as much. Sharing the stub is a feature; sharing locals is the footgun. I'd keep them separate.
Happy to send a PR with a regression test for whichever direction you pick — the reproduction above, asserting each call reads back what it wrote.
Summary
A direct (SSR-time) server-function call derives its request event with a shallow copy, so
localsis the same object as the enclosing render's. Two server functions running concurrently in one render overwrite each other'slocals, and both overwrite the render's.Tested against
next@320f1f5c, built from source, Node 24.19.packages/web/server-functions/src/server.ts, increateServerReference'sapplytrap:Reproduction
Runnable as-is:
The
acmecall wroteacme, awaited, and read backglobex. The render's ownlocals.tenantwas overwritten too.This is known internally, and defended internally only
Three places in the runtime record the sharing:
INVOCATIONS:the
applytrap itself:and
getServerFunctionInvocation's docblock:So core keeps its own invocation state out of
localsbecause of this exact problem, three times over — and application code is left on the sharp edge. Nothing on the publiccreateServerReferencesurface says so, anddocumentation/solid-2.0/12-ssr-http.md, which is the only doc file coveringlocalsand middleware, has no caveat.Why it matters in practice
localsis the conventional place for per-request context: the resolved tenant, the authenticated user, a DB handle, a request-scoped cache. The failure is silent (no warning, correct-looking values), non-deterministic (it depends on the interleaving of twocreateAsync-driven calls), and cross-tenant / cross-user in exactly the applications that have a tenant to confuse. Concurrent server functions in one render is the normal SolidStart data-loading shape.Options
locals, prototype-linked to the outer one —Object.create(ogEvt.locals || null). Reads still see the render's context; writes stay local.Trade-offs, measured: inherited keys vanish from enumeration —
Object.keys(derived),{...derived}andJSON.stringify(derived)all show only own keys. Nothing inpackages/enumerateslocals, but middleware and integrations downstream routinely do{...event.locals}, so this is a real behaviour change for them. The|| nullmatters:Object.create(undefined)throws, so a customcreateEventthat omitslocalswould turn a working direct call into a crash.locals—{ ...ogEvt.locals }. No enumeration surprise, simpler to explain. Diverges from the outer scope for writes made on the render side after the call started, which is a different surprise in the other direction.createServerReferenceand in12-ssr-http.md'slocalssection, ideally with a dev-mode warning when a direct call overwrites a key another in-flight call wrote. Cheapest; leaves a silent cross-tenant failure available to every app that useslocalsthe obvious way.localson derived events in dev — turns the silent overwrite into a loud throw during development, production unchanged. Pairs well with (3).I lean to (2) plus a doc line: it removes the cross-call write channel without the enumeration change that (1) brings, and the divergence it introduces is visible rather than silent. But this is a semantics call — if the sharing is intended as a feature, (3)+(4) is a coherent answer, and the fact that it's recorded in three comments suggests it may be.
Not in scope: the response stub
The same shallow copy shares
event.response. That one looks deliberate and useful — a cookie set by a server function during SSR reaching the page head is what you'd want, and the handler comment says as much. Sharing the stub is a feature; sharinglocalsis the footgun. I'd keep them separate.Happy to send a PR with a regression test for whichever direction you pick — the reproduction above, asserting each call reads back what it wrote.