AsyncLocalStorage Overhead: Measure Before Wrapping Every Request Path

AsyncLocalStorage Overhead: Measure Before Wrapping Every Request Path

AsyncLocalStorage (ALS) is the right default for request IDs and tenant context — until a 50k RPS gateway pays for a store enter/exit on every hop through middleware, ORM, and fetch. Measure the overhead on your Node version, then choose ALS, explicit params, or a hybrid.

⚡ TL;DR: Benchmark ALS vs explicit ctx args on hot handlers; keep one ALS store for correlation; don’t nest stores per library; disable ALS in ultra-hot pure functions. Pair with Node Diagnostic Channels and Node.js Event Loop Lag p99.

Benchmark before policy

// scripts/bench-als.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { performance } from "node:perf_hooks";

const als = new AsyncLocalStorage<{ reqId: string }>();

function bare(n: number) {
  let x = 0;
  for (let i = 0; i < n; i++) x += i;
  return x;
}

function withAls(n: number) {
  return als.run({ reqId: "b" }, () => {
    let x = 0;
    for (let i = 0; i < n; i++) {
      als.getStore(); // typical middleware read
      x += i;
    }
    return x;
  });
}

function bench(fn: (n: number) => number) {
  const n = 5_000_000;
  const t0 = performance.now();
  fn(n);
  return performance.now() - t0;
}

console.log({ bare: bench(bare), als: bench(withAls) });

Run on the same Node major as production. If ALS adds single-digit percent on a CPU-bound microbench, real I/O-bound APIs often absorb it — but gateways that already fight for microseconds should stay skeptical.

Lean context pattern

// lib/context.ts
import { AsyncLocalStorage } from "node:async_hooks";

export type ReqCtx = { reqId: string; tenantId: string };
export const reqAls = new AsyncLocalStorage<ReqCtx>();

export function getCtx(): ReqCtx {
  const s = reqAls.getStore();
  if (!s) throw new Error("missing_request_context");
  return s;
}

// ✅ Hot pure math / codec path — pass args, skip ALS
export function encodeInvoice(totalCents: number, tenantId: string) {
  return Buffer.from(`${tenantId}:${totalCents}`);
}
// server middleware — one enter per request
app.use((req, res, next) => {
  reqAls.run(
    { reqId: req.headers["x-request-id"] as string ?? crypto.randomUUID(),
      tenantId: String(req.headers["x-tenant-id"] ?? "") },
    () => next(),
  );
});

Hybrid for high-QPS gateways

Layer Carrier
Edge auth / HTTP middleware ALS OK
ORM / HTTP client wrappers read ALS once, pass explicit
Pure compute / codecs explicit args only
Worker threads structured clone of ctx snapshot

Avoid multiple ALS instances (OpenTelemetry + app + logger each nesting). Prefer one store and diagnostic channels for library metrics — see also undici pools.

Closing checklist

✅ Dos
– ✅ Bench ALS on your Node version and hardware
– ✅ Single store for request correlation
– ✅ Explicit args on ultra-hot pure functions
– ✅ Snapshot ctx when crossing into worker threads
– ✅ Keep getStore() off the per-byte codec path

❌ Don’ts
– ❌ Don’t nest a new ALS per dependency
– ❌ Don’t ban ALS globally without numbers
– ❌ Don’t forget lost context across poorly promisified callbacks
– ❌ Don’t store huge objects in ALS (retainers → heap pressure)

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply