Что такое AsyncLocalStorage в Node.js и зачем он нужен?
Суть механизма
AsyncLocalStorage (модуль node:async_hooks) реализует аналог thread-local storage, но для асинхронного мира Node.js. Она создаёт «контекст выполнения», который автоматически распространяется через все асинхронные операции (промисы, setTimeout, callbacks, fs, сетевые запросы), порождённые внутри вызова .run(). Это позволяет получить доступ к данным контекста в любой точке цепочки — без явной передачи через каждый слой приложения.
Под капотом используется низкоуровневый API async_hooks: когда создаётся новый асинхронный ресурс, Node.js связывает его с родительским контекстом исполнения. AsyncLocalStorage использует эту связь, чтобы «протащить» store через всю цепочку.
API
new AsyncLocalStorage()— создание хранилища..run(store, callback, ...args)— выполняет callback (и всё, что из него асинхронно порождено) в контекстеstore..getStore()— возвращает текущий store илиundefined, если вызван вне.run()..enterWith(store)— привязывает контекст к текущему выполнению без обёртки в callback; менее безопасен, так как контекст «утекает» дальше по времени жизни события и его сложнее корректно завершить..disable()— сбрасывает все активные контексты.
Зачем нужен
- Request-scoped данные в серверах — traceId, requestId, userId из JWT можно положить в контекст на входе в middleware и читать в логгере, сервисах и репозиториях любого уровня вложенности, не протаскивая их через сигнатуры функций.
- Трейсинг и APM — OpenTelemetry, Sentry, Datadog используют
AsyncLocalStorage, чтобы автоматически связывать логи и спаны с текущим запросом. - Замена паттерна «передавай context явно» — особенно полезно в больших кодовых базах, где нет DI-контейнера с request-scope.
- Транзакционный контекст — например, привязка активной транзакции БД к цепочке вызовов, чтобы вложенные repository-методы автоматически её использовали.
Пример: requestId в логах
import { AsyncLocalStorage } from 'node:async_hooks';
import http from 'node:http';
import { randomUUID } from 'node:crypto';
const als = new AsyncLocalStorage<{ requestId: string }>();
function log(message: string) {
const store = als.getStore();
// requestId подставляется автоматически, без передачи параметром
console.log(`[${store?.requestId ?? 'no-context'}] ${message}`);
}
async function handleBusinessLogic() {
log('обработка запроса началась');
await new Promise((r) => setTimeout(r, 10));
log('обработка запроса завершена');
}
http.createServer((req, res) => {
const requestId = randomUUID();
als.run({ requestId }, async () => {
await handleBusinessLogic();
res.end('ok');
});
}).listen(3000);
Подводные камни
- Если код вызывает
getStore()вне цепочки, запущенной через.run(), вернётсяundefined— нужно предусматривать fallback. enterWithпроще, но может привести к «протеканию» контекста между несвязанными операциями на одном event loop tick — в большинстве случаев предпочтителенrun.- Контекст не передаётся автоматически между
worker_threads— нужно сериализовать нужные данные черезpostMessage. - Есть небольшой overhead на каждую асинхронную операцию из-за необходимости отслеживать граф контекстов; в hot path с миллионами операций в секунду это стоит измерить.
- Долго живущие callbacks, зарегистрированные внутри
run()(например, подписка на EventEmitter), могут держать store в памяти дольше, чем ожидается, создавая риск утечек.
Что хочет услышать интервьюер
Кандидат понимает, что AsyncLocalStorage — это контекст, привязанный к асинхронной цепочке выполнения, а не обычная глобальная переменная или singleton
Знает основной API: run(), getStore(), enterWith() и разницу в их поведении и рисках
Может привести практический кейс — request-scoped логирование/трейсинг, передача requestId/userId через middleware без явных параметров
Понимает, что механизм построен поверх async_hooks и распространяется через промисы, таймеры и callbacks
Знает ограничения: поведение вне run(), отсутствие автоматической передачи контекста в worker_threads, риски утечек при enterWith
Пример: Базовое использование run/getStore
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage<{ userId: string }>();
function getCurrentUserId(): string | undefined {
return als.getStore()?.userId;
}
async function processOrder() {
// где-то глубоко внутри асинхронной цепочки,
// без передачи userId параметром
console.log('Текущий пользователь:', getCurrentUserId());
}
als.run({ userId: 'user-42' }, async () => {
await processOrder();
});
console.log('Вне контекста:', getCurrentUserId()); // undefined
Пример: Express middleware с requestId и Winston-логгером
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import express from 'express';
import winston from 'winston';
const als = new AsyncLocalStorage<{ requestId: string }>();
const logger = winston.createLogger({
format: winston.format.printf(({ message }) => {
const requestId = als.getStore()?.requestId ?? 'system';
return `[${requestId}] ${message}`;
}),
transports: [new winston.transports.Console()],
});
const app = express();
app.use((req, res, next) => {
const requestId = randomUUID();
// всё, что произойдёт внутри next(), включая асинхронные вызовы,
// будет иметь доступ к requestId через als.getStore()
als.run({ requestId }, next);
});
app.get('/orders/:id', async (req, res) => {
logger.info('Получение заказа началось');
// сервис/репозиторий на любом уровне вложенности может вызвать logger.info
// и requestId подставится автоматически
res.json({ id: req.params.id });
});
app.listen(3000);
Типичные ошибки
Путают AsyncLocalStorage с обычной глобальной переменной или модульным singleton, не понимая, что контекст изолирован для каждой асинхронной цепочки
Вызывают getStore() вне run() и не могут объяснить, почему возвращается undefined
Используют enterWith вместо run без понимания рисков утечки контекста между операциями
Не знают, что контекст не передаётся автоматически в worker_threads и требует ручной сериализации
Считают, что AsyncLocalStorage сам по себе решает проблему конкурентного доступа к shared state, путая её с многопоточной синхронизацией


