Muhammad Shahnewaz
AboutProjectsPosts
Muhammad Shahnewaz on GitHubMuhammad Shahnewaz on LinkedInMuhammad Shahnewaz on MediumMuhammad Shahnewaz on Email

Copyright © 2026 | All rights reserved.

← Back to posts

Managing Request Context in Node.js Async I/O: The Tradeoffs, Pitfalls, and Performance Costs Nobody Talks About

28 May, 2026·8 min read
nodejsasync-local-storageasync-resourcecontext-propagationdependency-injection
Managing Request Context in Node.js Async I/O
Table of Contents
  • The Problem
  • AsyncLocalStorage
  • AsyncResource
  • Dependency Injection
  • The Hidden Performance Cost
  • My Recommendation

The Problem

In a typical Node.js application, a single request touches multiple layers — route handler, service, database, logger. Each layer needs context: the request ID, the authenticated user, maybe a trace ID for distributed tracing. In Java, you would reach for ThreadLocal and move on. Node.js does not have that luxury. It runs concurrent requests on a single event loop, and the moment you hit an await, the call stack is gone.

The obvious fix is passing a context parameter through every function. That works until your call chain grows. Then you are refactoring 15 function signatures every time you add a field.

I will explain three approaches, each with tradeoffs that only surface in production.


AsyncLocalStorage

Built into Node.js since v14.17. You set a value once at the start of the request, and any function in the async chain can read it — no matter how deep.

This webhook handler processes a payment notification. The context flows from the HTTP handler into the service layer, the database call, and the logger — without being passed as a parameter:

import { createServer } from 'node:http';
import crypto from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
 
const store = new AsyncLocalStorage();
 
async function persistEvent(event) {
  const ctx = store.getStore();
  ctx.log('Persisting webhook event', { eventId: event.id });
  await db.insert('webhook_events', event);
}
 
async function processPayment(event) {
  const ctx = store.getStore();
  ctx.log('Processing payment', { amount: event.data.amount });
 
  await persistEvent(event);
  await notifyAccounting(event.data);
 
  return { status: 'processed', eventId: event.id };
}
 
function createContext(req) {
  return {
    requestId: req.headers['x-request-id'] || crypto.randomUUID(),
    traceId: req.headers['x-trace-id'],
    log(message, meta = {}) {
      console.log(JSON.stringify({
        level: 'info',
        requestId: this.requestId,
        traceId: this.traceId,
        message,
        ...meta,
        timestamp: new Date().toISOString()
      }));
    }
  };
}
 
const server = createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhook/payment') {
    res.writeHead(404);
    return res.end();
  }
 
  const body = await readBody(req);
  const event = JSON.parse(body);
 
  store.run(createContext(req), async () => {
    const ctx = store.getStore();
    ctx.log('Webhook received', { eventId: event.id });
 
    try {
      const result = await processPayment(event);
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(result));
    } catch (err) {
      ctx.log('Webhook failed', { error: err.message });
      res.writeHead(500);
      res.end(JSON.stringify({ error: 'Internal Server Error' }));
    }
  });
});
 
server.listen(8000);

processPayment and persistEvent never receive the context. They read it from the store. The logger inside each function automatically includes the request ID and trace ID. Node.js handles the propagation behind the scenes.

Tradeoffs

The good: Zero dependencies, works everywhere — HTTP handlers, queue consumers, cron jobs, WebSockets. Set it up once and forget about it.

The bad: Measurable overhead per async operation from async_hooks. Debugging async context chains is painful when something breaks.

TypeScript can type the store with generics, but it is manual — there is no inference. You define the shape yourself:

interface RequestContext {
  requestId: string;
  traceId: string;
  log: (message: string, meta?: Record<string, unknown>) => void;
}
 
const store = new AsyncLocalStorage<RequestContext>();

Pitfalls

Promise.all works fine — each promise inherits the parent context. The real problems are elsewhere.

EventEmitter drops context silently. Event handlers don't inherit the async context from the emitting code. You call getStore() inside the handler and get undefined. No error, no warning.

const emitter = new EventEmitter();
 
emitter.on('payment-processed', () => {
  const ctx = store.getStore();
  console.log(ctx); // undefined
});

Third-party libraries can break the chain. Some HTTP clients and database drivers create internal promises or callback chains that don't carry the async context forward. You won't know until you check getStore() and get undefined. For database drivers, check if they support a context option — modern drivers like pg v8+ and mysql2 usually work. If not, wrap the call with AsyncResource.

run() vs enterWith(). The examples in this post use store.run() — it creates a scoped context that restores the previous store when the callback finishes. enterWith() also sets the store, but it does not restore the previous context when the current one ends. This means enterWith() leaks context into subsequent requests if you forget to reset it:

// run() — restores previous context when done (safe)
store.run(context, () => { /* context is scoped */ });
 
// enterWith() — sets context for rest of execution (careful)
store.enterWith(context);
// context stays active until you call enterWith() again or the execution ends

Use run() for request-scoped context. Use enterWith() only when you need context to persist beyond a single callback — and make sure you clean up.


AsyncResource

This is the lower-level tool behind AsyncLocalStorage. You create a resource and run code inside its scope. It gives you manual control over context propagation.

You can fix the EventEmitter problem with a plain function:

import { AsyncResource } from 'node:async_hooks';
import { EventEmitter } from 'node:events';
import { AsyncLocalStorage } from 'node:async_hooks';
 
const store = new AsyncLocalStorage();
 
function emitWithContext(emitter, event, ...args) {
  const resource = new AsyncResource(`event:${event}`);
  resource.runInAsyncScope(() => {
    emitter.emit(event, ...args);
  });
}
 
const emitter = new EventEmitter();
 
store.run({ requestId: 'whk-abc-123', log: console.log }, () => {
  emitter.on('payment-processed', () => {
    const ctx = store.getStore();
    ctx.log(`[${ctx.requestId}] Sending confirmation email`);
  });
 
  emitWithContext(emitter, 'payment-processed');
});

The emitWithContext function wraps the emit call inside runInAsyncScope(). That is what preserves the context. Without it, you are back to undefined.

Another pattern — wrapping a callback-based database query:

function queryWithCtx(db, sql, params, callback) {
  const resource = new AsyncResource('db-query');
  resource.runInAsyncScope(() => {
    db.query(sql, params, callback);
  });
}

Tradeoffs

The good: Full control over context propagation. You can wrap any callback-based API, any event emitter, any async primitive.

The bad: You need to understand how async_hooks work. Debugging is significantly harder — stack traces look nothing like normal JavaScript.

Pitfalls

Easy to misuse. Creating an AsyncResource without calling runInAsyncScope() does nothing. No error, no warning. This is the most common mistake.

Overkill for application code. If you just need to pass a request ID through your service layer, this is the wrong tool. Most developers will never need AsyncResource directly — it is the engine under AsyncLocalStorage's hood, designed for library authors building custom async primitives.


Dependency Injection

No hooks, no magic. Just functions that close over the request context.

You create a service factory that takes context once and returns an object of bound functions. Every method already knows the context through the closure.

function createPaymentServices(ctx) {
  return {
    log(message, meta = {}) {
      console.log(JSON.stringify({
        level: 'info',
        requestId: ctx.requestId,
        traceId: ctx.traceId,
        message,
        ...meta,
        timestamp: new Date().toISOString()
      }));
    },
 
    async processPayment(event) {
      this.log('Processing payment', { amount: event.data.amount });
      await db.insert('webhook_events', event);
      await notifyAccounting(event.data);
      return { status: 'processed', eventId: event.id };
    },
 
    async getPaymentStatus(eventId) {
      this.log('Checking payment status', { eventId });
      return db.query('SELECT status FROM webhook_events WHERE id = $1', [eventId]);
    }
  };
}

Wire it up in your HTTP handler:

const server = createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhook/payment') {
    res.writeHead(404);
    return res.end();
  }
 
  const body = await readBody(req);
  const event = JSON.parse(body);
 
  const services = createPaymentServices({
    requestId: req.headers['x-request-id'] || crypto.randomUUID(),
    traceId: req.headers['x-trace-id']
  });
 
  services.log('Webhook received', { eventId: event.id });
 
  try {
    const result = await services.processPayment(event);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(result));
  } catch (err) {
    services.log('Webhook failed', { error: err.message });
    res.writeHead(500);
    res.end(JSON.stringify({ error: 'Internal Server Error' }));
  }
});
 
server.listen(8000);

No getStore(), no hooks, no runtime tracking. The context lives in the closure.

Tradeoffs

The good: Zero performance overhead. Fully explicit — you see exactly where context lives. Easy to debug and test. TypeScript catches everything because types flow through the closure.

The bad: Deep call chains require passing the services object through every layer, and third-party libraries like fetch or database drivers have no access to your context.

Pitfalls

Breaks with third-party libraries. You call fetch() inside your service method, and any logging inside the HTTP client has no request ID.

Deep call chains bring back prop drilling. Five layers of services means five levels of passing the object around.

Spawning async work needs extra wiring. You push a job to a queue or schedule a setTimeout — the services object is not in scope unless you explicitly capture it.


The Hidden Performance Cost

AsyncLocalStorage and AsyncResource use async_hooks under the hood. Every async operation — every await, every setTimeout, every callback — gets a small overhead.

How much? Roughly 2-5% per async operation. A typical HTTP request makes 5-10 async calls. That adds up to microseconds. You won't notice it.

When it matters: APIs serving 10,000+ requests per second. Requests with hundreds of async operations. Real-time systems where every millisecond counts.

When it does not matter: Pretty much everything else. If your request spends 50ms waiting for a database query, 0.002ms of async_hooks overhead is noise. Don't disable async_hooks for performance unless you have measured and confirmed it is the bottleneck. Premature optimization here breaks your context propagation for zero real-world gain.

Worker threads: AsyncLocalStorage does not propagate into worker threads — they run on separate V8 instances with their own event loops. Use workerData or postMessage to pass context across thread boundaries.


My Recommendation

Use AsyncLocalStorage as the default. It works everywhere, the overhead is irrelevant for 99% of apps, and you set it up once and forget about it.

Use AsyncResource only if you are building a library. Custom event emitters, connection pools, async primitives that other developers will use. If you are writing application code, you do not need this.

Use Dependency Injection when you want zero magic. Small apps, explicit code, full TypeScript control.

Combine them. This is the real answer. Use AsyncLocalStorage for the global request context (request ID, trace ID, user). Use DI for your own service dependencies (database client, API clients, config). They complement each other.

Share this post on: