Skip to content

Build1 publisher2 min readPublished

AsyncLocalStorage moves the missing-tenant bug from the call site to the request boundary

A dev.to walkthrough grows one Node.js handler to five parameters, four of them infrastructure, before reaching for node:async_hooks. The store it lands on declares every field optional, so a skipped boundary yields undefined deep in the call tree.

The Engineer · Build desk

Illustration accompanying AsyncLocalStorage moves the missing-tenant bug from the call site to the request boundary

What happened

  • A dev.to post on multi-tenant Node.js starts from the common line that reads a tenant ID off the x-tenant-id header, passes it to a service and uses it to select a database.
  • It then lists what a mature request ends up carrying alongside that tenant ID: request ID, database selection, transaction session, logging metadata, debug state, instrumentation and operation metadata.
  • The failure modes it catalogues are quiet ones: a service omits the tenant argument, a nested call loses the active session, a logger emits without the request ID, a model falls back to the default database.
  • It puts tenantId in a different category from a value like productName, because the tenant selects the tenant configuration, then the MongoClient, then the database, then the collection.
  • The proposed fix is AsyncLocalStorage from node:async_hooks, with the execution context established once at the request boundary and read deeper in the call tree.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Adopting the store converts a code-review habit, always forward the tenant, into an ownership rule about which layer may write context and which layers may only read it. Someone has to own that rule and the accessor that enforces it.
  • exposure When the database name does not reach the model, the query runs against the default database and the process still exits successfully, so the wrong tenant's storage is reachable without an error being raised.
  • constraint With all three context fields optional and the read using optional chaining, the type checker cannot tell a caller that a context is required. Enforcement has to come from a runtime accessor or a test.
  • capability Adding a new piece of runtime state, such as instrumentation, stops requiring edits to every intermediate signature between the handler and the model.

The store is installed by `storage.run(context, operation)` and read by `storage.getStore()`, which the post wraps as `getContext()` [14]. Both calls will run with no boundary in place. The sample `ExecutionContext` declares `tenantId`, `requestId` and `session` all optional, and the sample read is `context?.tenantId` [13][15]. Read it outside a `run()` boundary and you get `undefined` on a line that type-checks.

"None of these failures necessarily produce obvious syntax errors," the post says of manual forwarding, and it argues that the application can still run with the wrong execution state [9]. Moving the values into a store leaves that property intact. What it moves is the responsibility, onto whoever was supposed to open the context.

The case for moving them is visible in the signature. `handleRegistration` ends at five parameters: `tenantId`, `requestId`, `session`, `loggerMeta` and `input` [4]. Four carry execution state and one carries the request payload [5]. The same call chain started at `createUser(tenantId, input)`, so the three parameters added since are all infrastructure and none of them is a domain value [6][7].

Placement is still an open question in the post. Its sample context holds three of the eight values it lists as riding on a request, which leaves five unplaced [18]. The closing list asks who establishes the context, what belongs inside it, who is allowed to read it, how tenant identity resolves infrastructure, how transactions are added, and how context interacts with models [17]. Of AsyncLocalStorage itself the author writes: "It does not define your architecture." [16]

For the pattern to transfer, your data layer has to read state it was not handed. The `session` field here is typed `ClientSession` from the mongodb driver [19], and the resolution path runs from `tenantId` through tenant configuration to a `MongoClient`, a database and a collection [11]. If your ORM demands an explicit session argument at every call, you are still threading it; the store only changes where the caller fetches it from. In my view the accessor is where the design gets decided: one writer at the request edge, and a getter that throws when the store is missing.

The post does not report any timing for the `run()` boundary or the `getStore()` lookup [20].

What to watch

  • A follow-up that pins the accessor contract: whether reading context outside a boundary throws or returns undefined.
  • Any published timing for storage.run() and getStore() under request load on a current Node release.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories