ehsan.blog
~/blog/api-client-part-8-multi-tenancy-environments — zsh
cat api-client-part-8-multi-tenancy-environments.md

One client, many tenants: doing multi-tenancy without a footgun

·4 min read

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

plaintext
per-call tenantId          (most specific)


getTenantId() callback


ambient context (AsyncLocalStorage)   (least specific, but safest default)
ts
createClient({
  tenancy: {
    getTenantId: () => currentUser.tenantId, // sync or async
  },
})
ts
// 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.

ts
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.

plaintext
GET /projects, tenant_1 → key: GET:/projects:fp_x:tenant_1
GET /projects, tenant_2 → key: GET:/projects:fp_y:tenant_2   ← isolated

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:

ts
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 env value — 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.