---
url: https://masyaka.github.io/injecute/guide/request-context.md
description: >-
  Give every service the trace id, tenant and user of the current request with a
  context accessor backed by AsyncLocalStorage.
---

# Request context

Services often need to know about the current request: a logger adds the trace id, a repository
filters by tenant, an audit log records the user. The recommended way is a **context accessor**:

1. The application declares the context it needs and depends on an accessor for it.
2. The host (an HTTP server, a queue worker, a CLI) registers the accessor first, in the root container,
   and keeps the current context in [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html).
3. Services stay singletons and read the context when they use it.

For state that a unit of work owns, such as a transaction or a per-request cache, see
[Scoped state](./request-state.md).

## A context accessor or a scope

A service registered in the root container runs in the root (see [Forks](./containers.md#forks)), so it
can't depend on a `request` that a [scope](./containers.md#scopes) registers: in the root, the key is a
type error and [`INJECUTE_NOT_REGISTERED`](../errors/not-registered.md) at runtime. Both options work;
they differ in where the services live:

* **A context accessor** keeps every service in the root, built once. Services read the request's data
  when they run, so a logger, a repository or a domain service registered in the root can use it.
* **A scope** gives the services that need the request a home of their own: they are registered in the
  scope, take the request as an ordinary dependency, and are created again for every request.

Data that many layers read, like the trace id, the tenant or the user, usually fits an accessor. A group
of services that belong to the request fits a scope. [Scoped state](./request-state.md#choosing) has a
table for choosing.

## The application declares what it needs

```ts
/** What the application needs to know about the current request, job or command. */
export interface RequestContext {
  readonly traceId: string;
  readonly tenantId?: string;
}

/** Reads the current context; `undefined` outside a request (at startup, in timers). */
export interface ContextAccessor<T> {
  current(): T | undefined;
}

class Logger {
  constructor(private readonly context: ContextAccessor<RequestContext>) {}

  info(message: string): void {
    // read the context when it is used, never in the constructor
    const { traceId = '-', tenantId = '-' } = this.context.current() ?? {};
    lines.push(`[${traceId} ${tenantId}] ${message}`);
  }
}

class Orders {
  constructor(
    private readonly logger: Logger,
    private readonly context: ContextAccessor<RequestContext>,
  ) {}

  async place(item: string): Promise<string> {
    await new Promise((resolve) => setTimeout(resolve, 1)); // the context survives awaits
    this.logger.info(`order placed: ${item}`);
    return `${this.context.current()?.tenantId ?? 'public'}: ${item}`;
  }
}

// The module requires a `context` service: `extend()` checks that the host registered one.
export const addOrders = (
  c: ServiceRegistry<{ context: ContextAccessor<RequestContext> }>,
) =>
  c
    .addSingleton('logger', Logger, ['context'])
    .addSingleton('orders', Orders, ['logger', 'context']);
```

* The application owns the shape of `RequestContext`. Put in what your domain needs: tenant, user,
  locale, a deadline. injecute has no context type of its own.
* The application depends on the accessor, not on `AsyncLocalStorage`: it imports neither
  `node:async_hooks` nor HTTP types, and runs the same under any host.
* Services read the context **when they use it**. Reading it in a factory or constructor would keep the
  context of whichever request created the singleton (see [Pitfalls](#pitfalls)).

## The host provides it

```ts
export const storage = new AsyncLocalStorage<RequestContext>();
const context: ContextAccessor<RequestContext> = {
  current: () => storage.getStore(),
};

// The first layer is the context; the application is added on top of it.
export const app = new DIContainer()
  .addInstance('context', context)
  .extend(addOrders);

await preload(app); // create the singletons at startup, outside any request
```

The context is the host's first layer. `extend(addOrders)` is a type error when `context` is missing, so
an application module can't be added to a host that doesn't provide it (see
[Modules](./containers.md#modules)).

Each request runs inside `storage.run()`:

```ts
export function handle(request: Request): Promise<string> {
  const context: RequestContext = {
    traceId: request.headers['x-trace-id'] ?? crypto.randomUUID(),
    tenantId: request.headers['x-tenant-id'],
  };
  // everything the request calls sees `context`, including code after `await`
  return storage.run(context, () => app.get('orders').place('book'));
}
```

Everything the request calls sees its context, including code after `await` and in parallel promises.
Concurrent requests don't see each other's context.

A whole app on this pattern, with a logger and a tracer that read the context and feature modules in
namespaces: [open it in the playground](../playground?example=real-world).

### Queue consumers, cron jobs, CLI commands

Every entry point runs its work in `storage.run()`, so the application code is the same everywhere:

```ts
// A queue consumer provides the same context, so the application code does not change.
export function consume(job: {
  id: string;
  tenantId: string;
  item: string;
}): Promise<string> {
  return storage.run({ traceId: `job-${job.id}`, tenantId: job.tenantId }, () =>
    app.get('orders').place(job.item),
  );
}
```

### Frameworks

Start the context in a middleware that runs before your handlers, and wrap the rest of the request with
`storage.run(context, next)`. The [framework pages](../frameworks/index.md) show where: [Express](../frameworks/express.md), [Fastify](../frameworks/fastify.md#request-context) (after
the body is parsed), [Hono](../frameworks/hono.md) and [Next.js](../frameworks/nextjs.md#request-context).

## Logging

[Pino](https://github.com/pinojs/pino/blob/main/docs/api.md#mixin-function)'s `mixin` runs on every log
line, so a singleton logger can add the current context:

```ts
import pino from 'pino';

const addLogger = (
  c: ServiceRegistry<{ context: ContextAccessor<RequestContext> }>,
) =>
  c.addSingleton(
    'logger',
    (context) =>
      pino({
        // return a new object on every call: pino merges into it
        mixin: () => ({ ...context.current() }),
      }),
    ['context'],
  );
```

A framework's request logger (Fastify's `request.log`) carries the request id, but only where you pass
the request. A logger that reads the context works in every service, including code that doesn't know it
runs in a request.

## Create long-lived services at startup

Timers, sockets and event listeners run in the context they were created in. A connection pool created
by the first request that needs it keeps that request's context (and keeps it in memory) for as long as
it lives:

```ts
class Pool {
  readonly checks: (string | undefined)[] = [];
  // a timer (like a socket or an event listener) runs in the context it was created in
  readonly #timer = setInterval(
    () => this.checks.push(storage.getStore()?.traceId),
    5,
  ).unref();

  [Symbol.dispose](): void {
    clearInterval(this.#timer);
  }
}

// ✗ Created by the first request that needs it: its timer runs in that request's context forever
const lazy = new DIContainer().addSingleton('pool', Pool);
storage.run({ traceId: 'a' }, () => lazy.get('pool'));
// lazy.get('pool').checks: ["a", "a", …]

// ✓ Created at startup, outside any request
const eager = new DIContainer().addSingleton('pool', Pool);
await preload(eager);
storage.run({ traceId: 'a' }, () => eager.get('pool'));
// eager.get('pool').checks: [undefined, undefined, …]
```

`await preload(app)` before the server starts listening creates every singleton outside any request.

## Pitfalls

**Don't resolve the context once.** A singleton that stores the context, or reads it in its factory or
constructor, keeps the first request's context for every later one:

```ts
// ✗ The context is resolved once, by the first request, and kept for every later one
const captured = new DIContainer()
  .addSingleton('context', () => storage.getStore())
  .addSingleton('logger', (context) => ({ traceId: () => context?.traceId }), [
    'context',
  ]);

// ✓ An accessor is registered, and the context is read when it is used
const accessor = new DIContainer()
  .addInstance('context', { current: () => storage.getStore() })
  .addSingleton(
    'logger',
    (context) => ({ traceId: () => context.current()?.traceId }),
    ['context'],
  );

const requests = ['a', 'b'];
export const fromCaptured = requests.map((traceId) =>
  storage.run({ traceId }, () => captured.get('logger').traceId()),
); // ["a", "a"]
export const fromAccessor = requests.map((traceId) =>
  storage.run({ traceId }, () => accessor.get('logger').traceId()),
); // ["a", "b"]
```

The same happens with a function dependency, `[() => storage.getStore()]`, on a singleton: it runs once,
when the singleton is created. Don't keep what you read in a field either; call `current()` each time.

**Use `run()`, not `enterWith()`.** `enterWith()` sets the context for the rest of the synchronous
execution, which can leak it into the next request. `run()` scopes it to a callback, and every runtime
that has `AsyncLocalStorage` supports it.

**Keep the context small.** Ids and plain values, not services. A service or a container in the context
is a dependency that no registration shows, and nothing disposes it.

**Work that outlives the request still sees its context.** A `setTimeout` or a fire-and-forget promise
started in a request keeps the request's context, and keeps it in memory. Copy the values it needs, or
start it with `storage.exit()`.

**Changing the context during a request.** Create the context when the request starts. When a later step
adds to it (authentication sets the user), let that step set a field of the context object. A nested
`storage.run({ ...storage.getStore()!, user }, next)` works too, but it starts a new context object, which
[one instance per request](./request-state.md#one-instance-per-request) treats as a new request.

## Beyond ambient data

The context carries data that many layers read. For state a request **owns** (a transaction, a
per-request cache, a per-tenant implementation), see [Scoped state](./request-state.md): a scope, a
factory with `using`, one instance per context or a strategy chosen by the context, with a table for
choosing.

## Testing

Test the application with a fixed context, without `AsyncLocalStorage`:

```ts
// The application needs no AsyncLocalStorage in tests: give it a fixed context.
const fixed: ContextAccessor<RequestContext> = {
  current: () => ({ traceId: 'test-1', tenantId: 'acme' }),
};
const app = new DIContainer().addInstance('context', fixed).extend(addOrders);

export const placed = await app.get('orders').place('book'); // "acme: book"
```

To give a whole app container another context in a test, replace it in an isolated fork:
`app.fork({ isolated: true }).addInstance('context', fixed, { replace: true })`.

## Other runtimes

`AsyncLocalStorage` is available in Node.js, Deno, Bun and Cloudflare Workers (with Node.js
compatibility); use `run()` and `getStore()`, which all of them support. Browsers don't have it yet;
the TC39 [AsyncContext proposal](https://github.com/tc39/proposal-async-context) would add a standard
one. Only the host touches the storage, so moving to another one changes a single registration.
