One client, many tenants: doing multi-tenancy without a footgun
This is a short one compared to the last few, but it covers a problem that’s easy to get wrong in exactly the environment where getting it wrong is most dangerous: a shared server-side client handling requests for multiple tenants at once.
The problem: one client instance, many tenants, one server
Problem: in a client-side SPA, you usually have one user, one tenant, one client instance — tenancy barely matters. On the server, in an SSR app, it’s the opposite: one long-lived client instance (created once, reused across requests for efficiency) needs to serve request A for Tenant 1 and request B for Tenant 2, possibly concurrently, without ever crossing the streams.
The naive fix — a module-level let currentTenantId variable you set at the
start of each request — is a real bug waiting to happen under concurrency.
Request A sets it to tenant_1, then before A finishes, request B comes in
and sets it to tenant_2. A’s in-flight requests, still running, now read
tenant_2. That’s not a hypothetical race, that’s just how a mutable
module-level variable behaves under concurrent async requests.
Three ways to resolve a tenant ID, in order of precedence
per-call tenantId (most specific)
│
▼
getTenantId() callback
│
▼
ambient context (AsyncLocalStorage) (least specific, but safest default)createClient({
tenancy: {
getTenantId: () => currentUser.tenantId, // sync or async
},
})// per-call override — rare, for genuine cross-tenant admin operations
await api.users.list({ tenantId: 'tenant_override' })Ambient context — the one that actually solves the SSR problem
Solution: for SSR apps, tenancy resolution can hook into
AsyncLocalStorage (Node’s built-in mechanism for per-async-context state).
Instead of a mutable module-level variable, each incoming request gets its
own isolated context that automatically threads through every await inside
that request’s handling — including nested calls, without you manually
passing tenantId through every function signature.
import { AsyncLocalStorage } from 'node:async_hooks'
const tenantContext = new AsyncLocalStorage<string>()
createClient({
tenancy: { getTenantId: () => tenantContext.getStore() },
})
// in your request handler
app.use((req, res, next) => {
tenantContext.run(req.headers['x-tenant-id'] as string, next)
})Now every api.* call made anywhere during that request’s handling —
however deeply nested — automatically resolves the correct tenant, without
tenantId being threaded as an explicit parameter through every function in
between. Request A and request B, running concurrently on the same shared
client instance, each see their own isolated tenant context. No shared
mutable state, no race.
How tenancy feeds into the rest of the pipeline
This connects directly back to caching from a couple posts ago — the
resolved tenant ID is one of the inputs to the cache key, alongside the auth
fingerprint. So GET /projects for Tenant 1 and GET /projects for
Tenant 2 can never collide in the cache, for the same reason two different
users’ auth tokens can’t.
GET /projects, tenant_1 → key: GET:/projects:fp_x:tenant_1
GET /projects, tenant_2 → key: GET:/projects:fp_y:tenant_2 ← isolatedMulti-environment — the simpler, related problem
Problem: you want dev, staging, and prod to point at different base
URLs, ideally without three near-duplicate client files.
Solution: environment config is a named map, resolved by an env value
you control:
createClient({
environments: {
dev: { baseURL: 'http://localhost:3000' },
staging: { baseURL: 'https://staging.api.example.com' },
prod: { baseURL: 'https://api.example.com' },
},
env: process.env.NODE_ENV === 'production' ? 'prod' : 'dev',
})One client definition, one source of truth for what each environment points
at — instead of an if (process.env.NODE_ENV === 'production') scattered
across a config file, or worse, three separate api.ts files that drift out
of sync with each other over time.
Real talk: I almost shipped the module-level variable version
On an early internal-tools project — before this library existed in any
form — I genuinely wrote a let activeTenant at module scope and set it per
request. It worked in every test I ran, because my tests never actually hit
it concurrently. It would have been a very bad day in production the first
time two tenants’ requests overlapped. AsyncLocalStorage isn’t an exotic
choice here — for a server handling concurrent requests across tenants, it’s
close to the only correct one.
What sticks from this post
- A mutable module-level tenant variable is a race condition waiting for concurrent requests — don’t reach for it in an SSR context.
AsyncLocalStorage-backed ambient context solves this cleanly: each request gets its own isolated tenant context that threads through nested calls automatically.- Resolved tenant ID feeds into the cache key, same as the auth fingerprint — two tenants can never share a cached response.
- Multi-environment config is a named map plus one
envvalue — one client definition instead of environment-specific files that drift.
Next: error handling — the typed error classes, and what safeMode actually
changes about how your code handles failure.
- developerehsan