Mantendo o Contexto Entre Chamadas Assíncronas no Node.js
Você está três chamadas assíncronas dentro do tratamento de uma requisição HTTP. Precisa do request ID para o seu logger, do user ID para a sua consulta ao banco de dados e do tenant ID para a sua chave de cache. Você passa todos eles por cada assinatura de função? Isso fica bagunçado rapidamente.
O Node.js tem uma solução elegante já embutida: AsyncLocalStorage.
Principais Conclusões
AsyncLocalStorage, do módulonode:async_hooks, propaga contexto entre fronteiras assíncronas sem poluir assinaturas de função.- Está estável desde o Node.js 16.4.0 e é preferível em relação ao
cls-hookedou ao uso direto da API de baixo nívelasync_hooks. - Estabeleça o contexto uma vez no ponto de entrada da requisição com
run(), e depois leia-o em qualquer lugar usandogetStore(). - Ideal para request IDs, dados de tracing, metadados de tenant e contexto de autenticação — não para estado de lógica de negócio.
- Atenção à perda de contexto com promises não nativas ou APIs legadas baseadas em callbacks, geralmente resolvidas com
util.promisify().
O Problema da Propagação de Contexto Assíncrono
Em código síncrono, é possível usar uma simples pilha global para rastrear o contexto. Mas funções assíncronas quebram esse modelo. Quando um setTimeout dispara ou uma Promise é resolvida, a pilha de chamadas original já não existe. Uma variável global comum seria compartilhada entre todas as requisições concorrentes — um bug sério à espera de acontecer em qualquer servidor de API real.
Antes de AsyncLocalStorage se tornar estável, desenvolvedores recorriam a bibliotecas como cls-hooked ou a soluções caseiras utilizando o módulo de baixo nível async_hooks. Ambas as abordagens são frágeis. A API bruta de async_hooks é intencionalmente de baixo nível e traz um custo real de desempenho quando mal utilizada. Você não deveria construir aplicações diretamente sobre ela.
AsyncLocalStorage, parte de node:async_hooks, é a API de alto nível recomendada. Está estável desde o Node.js 16.4.0 e é o que frameworks como o AdonisJS utilizam internamente para gerenciar o contexto HTTP.
Como o AsyncLocalStorage Funciona
O AsyncLocalStorage funciona como o thread-local storage de outras linguagens — exceto que o Node.js é single-threaded, então a “thread” é substituída por um contexto de execução assíncrono. Qualquer operação assíncrona iniciada dentro de uma chamada run() herda esse contexto automaticamente, incluindo setTimeout, cadeias de Promise e chamadas await.
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
Você cria uma instância (tipicamente como um singleton em nível de módulo) e então usa run() para estabelecer um contexto no ponto de entrada de cada requisição.
Um Exemplo Realista de Logging por Requisição
Aqui está um middleware Express mínimo que anexa um request ID a cada linha de log — sem passar nada através de argumentos de função:
import express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const requestContext = new AsyncLocalStorage();
// Middleware: establish context for each request
function contextMiddleware(req, res, next) {
const store = { requestId: randomUUID(), userId: req.headers['x-user-id'] };
requestContext.run(store, next);
}
// Logger: reads context without any arguments
function log(message) {
const ctx = requestContext.getStore();
const prefix = ctx ? `[${ctx.requestId}]` : '[no-context]';
console.log(`${prefix} ${message}`);
}
// Simulated async database query
async function someDbQuery() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
// Route handler: calls async functions freely
async function fetchUserData() {
log('Fetching user data'); // ✅ has request ID
await someDbQuery();
log('Fetched user data'); // ✅ still has request ID
}
const app = express();
app.use(contextMiddleware);
app.get('/user', async (req, res) => {
log('Request received');
await fetchUserData();
res.json({ ok: true });
});
app.listen(3000);
O insight principal: fetchUserData nunca recebe o request ID como parâmetro. O contexto se propaga automaticamente através da fronteira assíncrona porque foi estabelecido com run().
Discover how at OpenReplay.com.
O Que Armazenar no Contexto
AsyncLocalStorage funciona bem para preocupações transversais que sejam específicas da requisição, mas não façam parte da sua lógica de negócio:
- Request IDs para tracing distribuído e correlação de logs
- Metadados de usuário autenticado ou tenant para aplicações multi-tenant
- Contexto de trace para ferramentas como o OpenTelemetry
- Feature flags resolvidas em tempo de requisição
Evite armazenar objetos grandes ou qualquer coisa que mude com frequência. Mantenha o store pequeno e trate-o como majoritariamente apenas para leitura após a inicialização.
Um Detalhe: Perda de Contexto
O contexto pode ser perdido ao utilizar implementações de promises não nativas ou algumas APIs antigas baseadas em callbacks. Se getStore() retornar undefined onde você não esperaria, verifique se a operação assíncrona foi iniciada dentro de uma chamada run(). Envolver código baseado em callbacks com util.promisify() frequentemente ajuda, embora alguns recursos assíncronos customizados possam exigir o uso de AsyncResource.
Conclusão
AsyncLocalStorage resolve um problema real de forma elegante. Em vez de passar metadados da requisição por cada chamada de função, você estabelece o contexto uma vez na fronteira da requisição e o lê onde for necessário. É a ferramenta certa para logging por requisição, tracing e contexto de autenticação em qualquer API Node.js ou aplicação SSR.
Perguntas Frequentes
Existe um pequeno overhead porque o Node.js precisa rastrear recursos assíncronos para propagar o store, mas para cargas web típicas o custo é insignificante. O desempenho melhorou substancialmente nas versões recentes do Node.js, e o trade-off geralmente vale a pena em comparação com passar manualmente o contexto por cada chamada de função.
Sim. Você pode criar instâncias separadas para diferentes preocupações, como contexto de logging, tracing e dados de tenant. Cada instância mantém seu próprio store independente, de modo que não interferem umas nas outras. Apenas mantenha cada instância como um singleton em nível de módulo para que a mesma referência seja usada em todo o seu código.
Cada worker thread tem seu próprio estado isolado de AsyncLocalStorage, então o contexto não atravessa fronteiras de thread. Se você precisar compartilhar o contexto de uma requisição com um worker, passe os dados relevantes explicitamente pelo canal de mensagens do worker e restabeleça o store dentro do worker com outra chamada run().
A passagem explícita é mais previsível e mais fácil de testar, mas polui assinaturas de função e camadas intermediárias que não precisam dos dados. O AsyncLocalStorage é melhor para preocupações transversais como logging e tracing, enquanto dados críticos para o negócio devem continuar fluindo via argumentos, mantendo o código claro e testável.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.