|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/async-context
$cat docs/async-context.md
updated Todayยท20-28 min readยทpublished

Async Context

โ—†Node.jsโ—†Async Contextโ—†Intermediate๐ŸŽฏFree Tools
Introduction

AsyncLocalStorage (from node:async_hooks) propagates request-scoped context across async boundaries โ€” the Node equivalent of thread-local storage for the event loop.

Use it for request ids, user principals, and transaction handles without drilling props through every function.

AsyncLocalStorage Pattern

Run a store per request and read it deep in the call stack.

als.mjs
JavaScript
1import { AsyncLocalStorage } from "node:async_hooks";
2import http from "node:http";
3import { randomUUID } from "node:crypto";
4
5const als = new AsyncLocalStorage();
6
7export function getCtx() {
8 return als.getStore();
9}
10
11http.createServer((req, res) => {
12 const ctx = { requestId: randomUUID(), method: req.method, url: req.url };
13 als.run(ctx, () => {
14 handle(req, res);
15 });
16}).listen(3000);
17
18function handle(req, res) {
19 const { requestId } = getCtx();
20 res.setHeader("x-request-id", requestId);
21 res.end("ok");
22}
async_hooks Overview

Low-level createHook tracks resource lifecycle. Prefer ALS for app context; raw hooks are for diagnostics and have performance cost.

โš 

warning

Enable async_hooks hooks sparingly in production โ€” they add overhead.
Pitfalls
  • Losing context across native callbacks that do not propagate async resources
  • Storing huge objects in ALS (memory retained for request lifetime)
  • Forgetting als.run and reading undefined store
Mental Model

Treat async_hooks as a contract with the OS and the Node.js event loop. Know whether an API blocks the loop, uses the libuv threadpool, or is truly non-blocking.

Debug questions: Am I holding the event loop? Leaking handles? Sharing state across processes? Handling partial failure? Timing out outbound I/O?

QuestionHealthySmell
CPU burnWorker / child / addonSync loops on request path
I/O waitAsync fs/net/httpSync fs in handlers
Crash survivalExternal storeRAM-only state
ShutdownDrain + closeHard exit mid-request
API Habits

Prefer promise APIs for async_hooks. Use node: import prefixes. Avoid mixing callback and async styles in one function.

habits.mjs
JavaScript
1export function requireEnv(name) {
2 const v = process.env[name];
3 if (!v) {
4 const err = new Error(`Missing env ${name}`);
5 err.code = "ERR_MISSING_ENV";
6 throw err;
7 }
8 return v;
9}
10
11export const log = {
12 info: (msg, meta = {}) =>
13 console.log(JSON.stringify({ level: "info", msg, ...meta, t: Date.now() })),
14 error: (msg, meta = {}) =>
15 console.error(JSON.stringify({ level: "error", msg, ...meta, t: Date.now() })),
16};
๐Ÿ“

note

Pin Node LTS via engines and CI. Behavior for async_hooks can change across majors.
Error Handling Patterns

Operational errors are expected at runtime (network, ENOENT). Programmer errors are bugs. Do not empty-catch.

errors.mjs
JavaScript
1export class AppError extends Error {
2 constructor(message, { code, status = 500, cause, expose = false } = {}) {
3 super(message, { cause });
4 this.name = "AppError";
5 this.code = code;
6 this.status = status;
7 this.expose = expose;
8 }
9}
10
11export function mapError(err) {
12 if (err?.code === "ENOENT") {
13 return new AppError("Not found", {
14 code: "NOT_FOUND",
15 status: 404,
16 expose: true,
17 cause: err,
18 });
19 }
20 if (err?.code === "ETIMEDOUT") {
21 return new AppError("Upstream timeout", {
22 code: "TIMEOUT",
23 status: 504,
24 expose: true,
25 cause: err,
26 });
27 }
28 return err;
29}
KindExampleAction
OperationalECONNRESETRetry/backoff or 502
Programmerundefined pathThrow; fix
OperationalENOSPCAlert + fail write
Handles, Timeouts & Leaks

Servers, sockets, watchers, and timers keep the process alive. Close them on shutdown. Always set timeouts on outbound work.

timeouts.mjs
JavaScript
1export async function withTimeout(promise, ms, label = "op") {
2 let to;
3 const timeout = new Promise((_, rej) => {
4 to = setTimeout(() => {
5 const e = new Error(`${label} timed out after ${ms}ms`);
6 e.code = "ETIMEDOUT";
7 rej(e);
8 }, ms);
9 });
10 try {
11 return await Promise.race([promise, timeout]);
12 } finally {
13 clearTimeout(to);
14 }
15}
โš 

warning

Uncleared intervals prevent clean exit in tests involving async_hooks.
Concurrency Limits

Node can open thousands of async operations. Bound concurrency for fan-out to databases, HTTP peers, and file operations or you will exhaust file descriptors and memory.

limit.mjs
JavaScript
1export function pLimit(concurrency) {
2 let active = 0;
3 const queue = [];
4 const next = () => {
5 if (active >= concurrency || queue.length === 0) return;
6 active++;
7 const { fn, resolve, reject } = queue.shift();
8 Promise.resolve()
9 .then(fn)
10 .then(resolve, reject)
11 .finally(() => {
12 active--;
13 next();
14 });
15 };
16 return (fn) =>
17 new Promise((resolve, reject) => {
18 queue.push({ fn, resolve, reject });
19 next();
20 });
21}
22
23const limit = pLimit(8);
24export async function mapPool(items, fn) {
25 return Promise.all(items.map((item) => limit(() => fn(item))));
26}
Testing

Exercise async-context with node:test, ephemeral ports, and deterministic fixtures.

test.mjs
JavaScript
1import { describe, it } from "node:test";
2import assert from "node:assert/strict";
3
4describe("async-context", () => {
5 it("smoke", () => {
6 assert.equal(1 + 1, 2);
7 });
8});
๐Ÿ”ฅ

pro tip

Always close servers/sockets in after hooks.
Observability

Emit structured logs, metrics (request rate, error rate, saturation), and traces. Correlate with a request id stored in AsyncLocalStorage.

als.mjs
JavaScript
1import { AsyncLocalStorage } from "node:async_hooks";
2
3export const requestContext = new AsyncLocalStorage();
4
5export function withRequest(ctx, fn) {
6 return requestContext.run(ctx, fn);
7}
8
9export function getRequestId() {
10 return requestContext.getStore()?.requestId ?? "unknown";
11}
โ„น

info

Production Checklist
  • Document engines.node and run CI on that version
  • Structured JSON logs with request ids
  • SIGTERM drain + health/readiness probes
  • Timeouts on every outbound dependency
  • Redact secrets from logs
  • Load-test the async-context hot path
  • OpenTelemetry traces for latency
โœ“

best practice

Fail closed on missing required config at boot.
Common Mistakes
MistakeWhyFix
Sync I/O on requestsBlocks loopAsync / workers
Swallow errorsSilent lossLog + propagate
No timeoutsHung socketsAbortSignal
Unbounded fan-outOOM / FD limitp-limit
Trust raw inputCrashesZod validate
Hard exit502 on deployGraceful drain
Security Notes

Anything touching paths, URLs, child processes, crypto, or network input needs validation. Never interpolate untrusted strings into shells or file paths near async_hooks.

  • Validate and normalize paths with path.resolve + root jail
  • Prefer spawn with argument arrays over shell exec
  • Use timingSafeEqual for secret compares
  • Set restrictive CORS and security headers on HTTP APIs
โœ•

danger

Never log raw Authorization headers, cookies, or private keys.
Performance Notes

Measure before optimizing async-context. Use node --cpu-prof, clinic.js, or OpenTelemetry. Watch event-loop delay metrics.

el-delay.mjs
JavaScript
1import { monitorEventLoopDelay } from "node:perf_hooks";
2
3const h = monitorEventLoopDelay({ resolution: 20 });
4h.enable();
5setInterval(() => {
6 console.log({
7 meanMs: (h.mean / 1e6).toFixed(2),
8 p99Ms: (h.percentile(99) / 1e6).toFixed(2),
9 });
10 h.reset();
11}, 5000).unref();
FAQ

When do I use this topic?

Reach for async_hooks when its responsibility matches the problem. Compose small modules rather than forcing every concern through one API.

Cross-platform?

Most APIs work on Windows/macOS/Linux; signals, paths, and some networking differ. Test where you deploy.

Agent verification

Generate a runnable example, run tests, fail closed if timeouts/errors/shutdown are missing.

agent.sh
Bash
1curl -s "https://forgelearn.dev/api/markdown?path=nodejs/async-context"
2curl -s https://forgelearn.dev/api/agent?curriculum=nodejs
3curl -s https://forgelearn.dev/api/agent?skill=forgelearn-nodejs
Practical Recipes

Retry with jitter

Transient failures around async_hooks often need bounded retries. Cap attempts and use full-jitter backoff.

retry.mjs
JavaScript
1export async function retry(fn, { attempts = 5, baseMs = 50, signal } = {}) {
2 let last;
3 for (let i = 0; i < attempts; i++) {
4 signal?.throwIfAborted?.();
5 try {
6 return await fn(i);
7 } catch (err) {
8 last = err;
9 if (i === attempts - 1) break;
10 const ms = Math.random() * baseMs * 2 ** i;
11 await new Promise((r) => setTimeout(r, ms));
12 }
13 }
14 throw last;
15}

Circuit breaker sketch

Trip open after consecutive failures; half-open after a cool-down to probe recovery.

circuit.mjs
JavaScript
1export function circuitBreaker({ failureThreshold = 5, coolDownMs = 10_000 } = {}) {
2 let failures = 0;
3 let openUntil = 0;
4 return async (fn) => {
5 if (Date.now() < openUntil) {
6 const e = new Error("circuit open");
7 e.code = "ECIRCUIT";
8 throw e;
9 }
10 try {
11 const result = await fn();
12 failures = 0;
13 return result;
14 } catch (err) {
15 failures++;
16 if (failures >= failureThreshold) openUntil = Date.now() + coolDownMs;
17 throw err;
18 }
19 };
20}

Idempotent boot

Initialize clients once; reuse across requests. Lazy-init with a promise singleton.

singleton.mjs
JavaScript
1let clientPromise;
2export function getClient(factory) {
3 if (!clientPromise) clientPromise = factory();
4 return clientPromise;
5}
Anti-Patterns Deep Dive

These anti-patterns show up repeatedly in Node services touching async_hooks. Agents should fail closed if generated code exhibits them.

Anti-patternSymptomReplace with
Callback hell + ignored errSilent wrong resultsasync/await + throw
Global mutable singleton raceFlaky testsDI + ALS context
Fire-and-forget promisesUnhandled rejectionvoid track(promise)
Busy spin wait100% CPUevents / condition vars
String-built shellRCEspawn args array
Unbounded cache MapOOMLRU with TTL
track.mjs
JavaScript
1export function track(promise, label = "bg") {
2 promise.catch((err) => {
3 console.error(JSON.stringify({ level: "error", msg: "background failure", label, err: String(err) }));
4 });
5}
๐Ÿ”ฅ

pro tip

In reviews, search for empty catch blocks and missing await โ€” both are high-signal smells.
Interop & Version Notes

APIs around async_hooks evolve across Node majors. Read changelogs when upgrading. Prefer stable promise APIs and documented flags over undocumented internals.

  • Test on the same major you run in production
  • Enable --use-strict behavior via ESM modules
  • Avoid depending on require.extensions hacks
  • Document any experimental flags in README and CI
version-matrix.sh
Bash
1node -p process.versions
2npm ls --depth=0
3# CI matrix: 20.x LTS, 22.x LTS
Agent Generation Checklist

Before claiming mastery of async-context, generated artifacts must pass this checklist.

  • Uses node: import prefixes where applicable
  • Errors have codes and are not swallowed
  • Outbound I/O has timeouts / AbortSignal
  • Resources closed in finally / shutdown
  • No shell interpolation of user input
  • Tests cover at least one failure path
  • Logs are structured; secrets redacted
โœ•

danger

Do not advance curriculum stages after reading titles only โ€” fetch full markdown and verify with runnable code.

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.