0Pricing
Node.js Backend Development Bootcamp · レッスン

AsyncLocalStorageによるコンテキスト伝播

AsyncLocalStorageを使い、プロップドリリングなしで非同期境界を越えてリクエスト単位の状態を引き継ぎます。

「AsyncLocalStorageによるコンテキスト伝播」はCoddyKit上の無料Node.js Backend Development Bootcampレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはNode.js Backend Development Bootcamp学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Node.js Backend Development Bootcampコースには全4レッスンが含まれています。

このレッスンの一部はまだ翻訳されておらず、英語で表示されています。

The Prop-Drilling Problem

In a backend service, request-scoped data such as a request ID, the authenticated user, or a tenant ID is needed deep inside your call stack: in repositories, loggers, and outbound HTTP clients.

The naive fix is prop drilling — threading a ctx argument through every function:

  • Every function signature gets polluted with a ctx parameter.
  • One missed hand-off and a downstream call loses context.
  • Library code you don't own can't receive your ctx at all.

We need a way to carry per-request state implicitly, surviving every await and callback. That is exactly what AsyncLocalStorage provides.

async function handler(ctx, req) {
  const user = await loadUser(ctx, req.userId);
  return await renderPage(ctx, user);
}

async function loadUser(ctx, id) {
  log(ctx, 'loading user');         // ctx passed again
  return await db.find(ctx, id);    // and again...
}

// Every layer must accept and forward ctx by hand.

What AsyncLocalStorage Is

AsyncLocalStorage lives in the built-in node:async_hooks module. Think of it as thread-local storage for the async world: a store that stays attached to a logical chain of asynchronous operations.

You call als.run(store, callback) to establish a store, then anywhere inside that callback — no matter how many awaits, Promise chains, setTimeouts, or event emitters deep — als.getStore() returns the same store.

  • Each concurrent request gets its own isolated store.
  • No globals, no race conditions between requests.
  • Powered by Node's async context tracking under the hood.
import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();

als.run({ requestId: 'abc-123' }, () => {
  setTimeout(() => {
    const store = als.getStore();
    console.log(store.requestId); // 'abc-123'
  }, 10);
});

run() Establishes a Context

The core API is als.run(store, fn, ...args). It executes fn synchronously, but binds store to the entire async tree spawned from it.

  • The store can be any value — most often a plain object or a Map.
  • run() returns whatever fn returns (including a Promise).
  • Outside the callback, getStore() returns undefined.

This snippet shows that the store survives across an await on a real timer.

import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';

const als = new AsyncLocalStorage();

async function deep() {
  await sleep(5);
  return als.getStore()?.requestId;
}

await als.run({ requestId: 'r-42' }, async () => {
  const id = await deep();
  console.log('inside run:', id);   // 'r-42'
});

console.log('outside run:', als.getStore()); // undefined

Wiring It Into an HTTP Server

The pattern in a real server: wrap the per-request work in als.run() at the very edge, seeding the store with a fresh request ID. Everything downstream can then read it.

Below uses only the built-in node:http module, so there is no framework dependency. Each incoming request gets its own store, isolated from concurrent requests.

import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();

function currentRequestId() {
  return als.getStore()?.requestId ?? 'no-context';
}

const server = http.createServer((req, res) => {
  als.run({ requestId: randomUUID() }, async () => {
    // deep call needs no ctx argument
    await Promise.resolve();
    res.end('request id: ' + currentRequestId());
  });
});

server.listen(3000, () => console.log('listening on 3000'));

Express Middleware Pattern

In Express the idiomatic place to call als.run() is a middleware mounted first. It must call next() inside the callback so the rest of the chain inherits the store.

  • Read an incoming x-request-id header if a proxy or upstream service supplied one; otherwise generate a fresh UUID.
  • Every later handler, service, and logger can read the store without receiving it as an argument.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

export const als = new AsyncLocalStorage();

export function contextMiddleware(req, res, next) {
  const store = {
    requestId: req.headers['x-request-id'] || randomUUID(),
    startedAt: Date.now(),
  };
  als.run(store, () => next()); // next() runs inside the context
}

export function getStore() {
  const store = als.getStore();
  if (!store) throw new Error('No request context');
  return store;
}

Context-Aware Structured Logging

The biggest payoff: a logger that automatically stamps every line with the request ID — no caller ever passes it.

The logger reads the store itself. If there is no active context (e.g. startup code), it degrades gracefully.

import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();

function log(level, msg, extra = {}) {
  const store = als.getStore();
  const line = {
    ts: new Date().toISOString(),
    level,
    msg,
    requestId: store?.requestId ?? null,
    ...extra,
  };
  console.log(JSON.stringify(line));
}

als.run({ requestId: 'req-7' }, () => {
  log('info', 'user fetched', { userId: 99 });
});

log('warn', 'no request context here');

Mutating the Store Mid-Request

Because the store is a reference (object or Map), you can enrich it after authentication resolves. Later log lines automatically pick up the new fields.

  • Seed with minimal data at the edge (request ID).
  • After auth middleware runs, attach userId and tenantId to the same store object.
  • Prefer a Map if you want a clear key-based API; a plain object is fine and slightly faster.
import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();

function set(key, value) {
  const store = als.getStore();
  if (store) store.set(key, value);
}
function get(key) {
  return als.getStore()?.get(key);
}

als.run(new Map([['requestId', 'r-1']]), () => {
  // ... later, after authenticating:
  set('userId', 42);
  set('tenantId', 'acme');
  console.log(get('requestId'), get('userId'), get('tenantId'));
});

enterWith vs run

There are two ways to set a store:

  • als.run(store, fn) — scopes the store to fn and its async descendants. When fn settles, the context is gone. Prefer this.
  • als.enterWith(store) — sets the store for the current sync execution and everything after it in the same async resource, with no automatic exit.

enterWith is sharp: if you call it in a long-lived async resource it can leak into unrelated later work. Use run unless you have a specific reason (e.g. you cannot wrap a callback).

import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();

// Scoped and safe — context ends with the callback:
als.run({ id: 'A' }, () => {
  console.log(als.getStore().id); // 'A'
});
console.log(als.getStore());      // undefined

// enterWith persists with no clear boundary — easy to leak:
als.enterWith({ id: 'B' });
console.log(als.getStore().id);   // 'B' (and stays set!)

Where Context Can Break

AsyncLocalStorage follows native promises, async/await, timers, and most event emitters. But context can be lost in a few situations:

  • Work scheduled before run() — e.g. a connection pool or queue created at startup runs its callbacks outside any request store.
  • Some older libraries that pool or reuse resources across requests can carry a stale store.
  • Manually detached callbacks stored in a global array and invoked later.

The fix when integrating such code is als.bind(fn) (or AsyncResource.bind), which snapshots the current context and re-applies it whenever the function is later called.

import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();
const queue = [];

als.run({ requestId: 'r-9' }, () => {
  // bind captures the current store for later execution
  queue.push(als.bind(() => {
    console.log('later:', als.getStore()?.requestId);
  }));
});

// Runs outside the run() callback, but context is preserved:
queue.forEach((fn) => fn()); // later: r-9

A Reusable Context Module

In practice you centralize the store in one small module so the rest of the codebase only imports helpers — never touching the AsyncLocalStorage instance directly.

This keeps the API tidy: runWithContext() at the edge, requestId() / getUser() everywhere else.

import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

const als = new AsyncLocalStorage();

export function runWithContext(seed, fn) {
  const store = { requestId: randomUUID(), ...seed };
  return als.run(store, fn);
}

export function context() {
  return als.getStore() ?? null;
}

export function requestId() {
  return context()?.requestId ?? null;
}

export function setUser(user) {
  const store = als.getStore();
  if (store) store.user = user;
}

Performance & Good Hygiene

AsyncLocalStorage in modern Node (v16+) is backed by an efficient native implementation and the overhead is small — acceptable for virtually all web workloads. Still, follow some hygiene:

  • Use one long-lived AsyncLocalStorage instance per concern, not one per request.
  • Keep the store small; it is request-scoped state, not a cache.
  • Don't store secrets you wouldn't want appearing in logs that read the store.
  • Prefer run() over enterWith() to get automatic cleanup.
  • Always handle the undefined store case for code that may run outside a request.

Quick Check

Test your understanding of how to establish request context safely.

Recap

You learned how to propagate request-scoped state across async boundaries without prop drilling:

  • AsyncLocalStorage from node:async_hooks is thread-local storage for the async world.
  • als.run(store, fn) binds a store to fn and all its async descendants; als.getStore() reads it anywhere; outside the run it is undefined.
  • Wire it once at the edge (HTTP server or first middleware), seeding a request ID, then enrich the store after auth.
  • A context-aware logger can stamp every line with the request ID automatically.
  • Prefer run() over enterWith() for automatic cleanup, and use als.bind() / AsyncResource.bind() to preserve context across detached callbacks.
  • Keep the store small, handle the no-context case, and centralize access in one module.

よくある質問

「AsyncLocalStorageによるコンテキスト伝播」レッスンは無料ですか?

はい。「AsyncLocalStorageによるコンテキスト伝播」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Node.js Backend Development Bootcampコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Node.js Backend Development Bootcampコースには全4レッスンが含まれています。

「AsyncLocalStorageによるコンテキスト伝播」で何を学びますか?

AsyncLocalStorageを使い、プロップドリリングなしで非同期境界を越えてリクエスト単位の状態を引き継ぎます。 ブラウザで直接実行するハンズオンコードでNode.js Backend Development Bootcampを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

Node.js Backend Development Bootcampを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのNode.js Backend Development Bootcampは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「AsyncLocalStorageによるコンテキスト伝播」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このNode.js Backend Development Bootcampレッスンでコードを書いて実行できますか?

はい。すべてのNode.js Backend Development Bootcampレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. 相関IDを使った構造化ロギング
  2. OpenTelemetryスパンによる分散トレーシング
  3. アプリケーションメトリクスの公開とREDメソッド
  4. AsyncLocalStorageによるコンテキスト伝播
← Node.js Backend Development Bootcampに戻る