Mantener el contexto entre llamadas asíncronas en Node.js
Estás tres llamadas asíncronas dentro del manejo de una petición HTTP. Necesitas el ID de la petición para tu logger, el ID del usuario para tu consulta a la base de datos y el ID del tenant para tu clave de caché. ¿Los pasas a través de cada firma de función? Eso se vuelve un caos rápidamente.
Node.js tiene una solución limpia integrada: AsyncLocalStorage.
Puntos clave
AsyncLocalStoragedenode:async_hookspropaga el contexto a través de los límites asíncronos sin contaminar las firmas de las funciones.- Ha sido estable desde Node.js 16.4.0 y es preferible frente a
cls-hookedo el uso directo de la API de bajo nivelasync_hooks. - Establece el contexto una sola vez en el punto de entrada de la petición con
run(), y luego léelo en cualquier lugar usandogetStore(). - Ideal para IDs de petición, datos de trazado, metadatos de tenant y contexto de autenticación — no para el estado de la lógica de negocio.
- Ten cuidado con la pérdida de contexto al usar promesas no nativas o APIs heredadas basadas en callbacks, lo cual
util.promisify()normalmente resuelve.
El problema de la propagación del contexto asíncrono
En código síncrono, puedes usar una simple pila global para rastrear el contexto. Pero las funciones asíncronas rompen ese modelo. Cuando un setTimeout se dispara o una Promise se resuelve, la pila de llamadas original ya no existe. Una variable global simple se compartiría entre todas las peticiones concurrentes — un bug grave esperando a ocurrir en cualquier servidor de API real.
Antes de que AsyncLocalStorage fuese estable, los desarrolladores recurrían a librerías como cls-hooked o a soluciones hechas a mano usando el módulo de más bajo nivel async_hooks. Ambos enfoques son frágiles. La API cruda de async_hooks es intencionalmente de bajo nivel y conlleva una sobrecarga real de rendimiento cuando se usa mal. No deberías construir sobre ella directamente para el código de aplicación.
AsyncLocalStorage, parte de node:async_hooks, es la API de alto nivel recomendada. Ha sido estable desde Node.js 16.4.0 y es lo que frameworks como AdonisJS utilizan internamente para gestionar el contexto HTTP.
Cómo funciona AsyncLocalStorage
AsyncLocalStorage funciona como el almacenamiento local de hilos (thread-local storage) de otros lenguajes — excepto que Node.js es de un solo hilo, por lo que el “hilo” se reemplaza por un contexto de ejecución asíncrono. Cualquier operación asíncrona iniciada dentro de una llamada run() hereda ese contexto automáticamente, incluyendo setTimeout, cadenas de Promise y llamadas await.
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
Creas una instancia (típicamente como un singleton a nivel de módulo) y luego usas run() para establecer un contexto en el punto de entrada de cada petición.
Un ejemplo realista de logging por petición
Aquí tienes un middleware mínimo de Express que adjunta un ID de petición a cada línea de log — sin pasar nada a través de los argumentos de las funciones:
import express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const requestContext = new AsyncLocalStorage();
// Middleware: establece el contexto para cada petición
function contextMiddleware(req, res, next) {
const store = { requestId: randomUUID(), userId: req.headers['x-user-id'] };
requestContext.run(store, next);
}
// Logger: lee el contexto sin necesidad de argumentos
function log(message) {
const ctx = requestContext.getStore();
const prefix = ctx ? `[${ctx.requestId}]` : '[no-context]';
console.log(`${prefix} ${message}`);
}
// Consulta a base de datos asíncrona simulada
async function someDbQuery() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
// Manejador de ruta: llama funciones asíncronas libremente
async function fetchUserData() {
log('Fetching user data'); // ✅ tiene request ID
await someDbQuery();
log('Fetched user data'); // ✅ aún tiene 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);
La clave: fetchUserData nunca recibe el ID de la petición como parámetro. El contexto se propaga automáticamente a través de los límites asíncronos porque fue establecido con run().
Discover how at OpenReplay.com.
Qué almacenar en el contexto
AsyncLocalStorage funciona bien para preocupaciones transversales que son de alcance de petición pero no forman parte de tu lógica de negocio:
- IDs de petición para trazado distribuido y correlación de logs
- Metadatos de usuario autenticado o tenant para aplicaciones multi-tenant
- Contexto de trazado para herramientas como OpenTelemetry
- Feature flags resueltos en tiempo de petición
Evita almacenar objetos grandes o cualquier cosa que cambie frecuentemente. Mantén el store pequeño y trátalo como de solo lectura después de la inicialización.
Una trampa: la pérdida de contexto
El contexto puede perderse al usar implementaciones de promesas no nativas o algunas APIs antiguas basadas en callbacks. Si getStore() devuelve undefined donde no lo esperas, comprueba si la operación asíncrona fue iniciada dentro de una llamada run(). Envolver código basado en callbacks con util.promisify() suele ayudar, aunque algunos recursos asíncronos personalizados pueden requerir AsyncResource.
Conclusión
AsyncLocalStorage resuelve un problema real de forma elegante. En lugar de pasar metadatos de petición a través de cada llamada a función, estableces el contexto una sola vez en el límite de la petición y lo lees donde lo necesites. Es la herramienta adecuada para logging por petición, trazado y contexto de autenticación en cualquier API o aplicación SSR de Node.js.
Preguntas frecuentes
Existe una pequeña sobrecarga porque Node.js debe rastrear los recursos asíncronos para propagar el store, pero para cargas de trabajo web típicas el coste es insignificante. El rendimiento ha mejorado significativamente en las versiones recientes de Node.js, y la compensación normalmente vale la pena en comparación con pasar manualmente el contexto a través de cada llamada a función.
Sí. Puedes crear instancias separadas para diferentes preocupaciones como el contexto de logging, el trazado y los datos de tenant. Cada instancia mantiene su propio store independiente, por lo que no interferirán entre sí. Simplemente mantén cada instancia como un singleton a nivel de módulo para que se use la misma referencia en todo tu código.
Cada worker thread tiene su propio estado aislado de AsyncLocalStorage, por lo que el contexto no cruza los límites entre hilos. Si necesitas compartir el contexto de una petición con un worker, pasa los datos relevantes explícitamente a través del canal de mensajes del worker y vuelve a establecer el store dentro del worker con otra llamada run().
Pasar el contexto explícitamente es más predecible y fácil de testear, pero satura las firmas de las funciones y contamina las capas intermedias que en realidad no necesitan los datos. AsyncLocalStorage es lo mejor para preocupaciones transversales como el logging y el trazado, mientras que los datos críticos para el negocio deberían seguir fluyendo a través de los argumentos para mantener el código claro y testeable.
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.