jsrosetta

Async

Request Context: AsyncLocalStorage and context.Context

How Node.js's AsyncLocalStorage carries a request ID along the async chain on its own, compared to Go's context.Context, which you pass explicitly through every function.

By
Minimum versions
Node.js ≥ 14.13.1Go ≥ 1.25
Verified on
Node.js 24.12.0Go 1.27.1

Node.js code is an ES module: save it as .mjs or set "type": "module" in package.json.

A request flows through a handler, a service, a repository and finally a logger. The logger wants to print the request ID, but nobody wants to add a requestId parameter to every function in between. Node.js solves this implicitly: AsyncLocalStorage (from node:async_hooks) attaches a "store" to the running async chain, and that store follows along across await, promises and timers — the deepest function just calls getStore(). Go deliberately has no goroutine-local storage: goroutines have no public ID and no "belongs to this goroutine" variables. Instead, context.Context carries request-scoped values explicitly — every function that needs it takes ctx as its first parameter. Implicit is terser; explicit means the function signature tells you which code depends on the context.

Attaching a value to a request and reading it deep down (run/getStore vs WithValue/Value)

import { AsyncLocalStorage } from "node:async_hooks";
 
const requestContext = new AsyncLocalStorage();
 
// The deepest-level logger: it does not take requestId as a parameter.
function log(message) {
  const store = requestContext.getStore(); // undefined outside of run()
  console.log(`[${store?.requestId ?? "-"}] ${message}`);
}
 
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 
async function loadUser(id) {
  await sleep(10);
  log(`loaded user ${id}`);
  return { id };
}
 
function handleRequest(requestId, userId) {
  // Everything inside the callback, even after await, sees the same store.
  return requestContext.run({ requestId }, async () => {
    log("start");
    await loadUser(userId);
    log("done");
  });
}
 
// Two requests interleave but never mix up their requestId.
await Promise.all([handleRequest("req-1", 1), handleRequest("req-2", 2)]);
log("outside");
// → [req-1] start
// → [req-2] start
// → [req-1] loaded user 1
// → [req-1] done
// → [req-2] loaded user 2
// → [req-2] done
// → [-] outside

Context across timers, promises, and goroutines

The clearest difference: a callback scheduled inside run() carries the store, even though run() returned long before the callback runs. A new goroutine in Go inherits nothing — it only has what you pass in.

import { AsyncLocalStorage } from "node:async_hooks";
import { EventEmitter } from "node:events";
 
const requestContext = new AsyncLocalStorage();
const currentId = () => requestContext.getStore()?.requestId ?? "-";
 
requestContext.run({ requestId: "req-1" }, () => {
  // Callbacks scheduled inside run() carry the store, even though they run after run() has returned.
  setTimeout(() => console.log("timeout:", currentId()), 10);
  Promise.resolve().then(() => console.log("then:", currentId()));
});
 
// Scheduled outside run(): no store.
setTimeout(() => console.log("outside:", currentId()), 20);
 
// EventEmitter listeners run in the context of emit(), not of on().
const emitter = new EventEmitter();
requestContext.run({ requestId: "req-1" }, () => {
  emitter.on("job", () => console.log("listener:", currentId()));
});
requestContext.run({ requestId: "req-2" }, () => emitter.emit("job"));
// → listener: req-2
// → then: req-1
// → timeout: req-1
// → outside: -

The store is a mutable object (collecting per-request data)

The store is just an ordinary JavaScript value. If it's an object, every function in the same async chain can mutate it, and the caller of run() sees the changes afterwards. Go contexts are immutable: to collect data, put a pointer in the context and handle synchronization yourself when several goroutines write to it.

import { AsyncLocalStorage } from "node:async_hooks";
 
const requestContext = new AsyncLocalStorage();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 
async function query(sql) {
  requestContext.getStore().queries.push(sql); // the store is a plain object: mutate it directly
  await sleep(1);
}
 
async function handleRequest() {
  await query("SELECT * FROM users");
  await Promise.all([query("SELECT * FROM orders"), query("SELECT * FROM items")]);
}
 
const store = { requestId: "req-1", queries: [] };
await requestContext.run(store, handleRequest);
 
// Changes made inside are visible outside, since it is the same object.
console.log(`${store.requestId} ran ${store.queries.length} queries`);
// → req-1 ran 3 queries

Key differences

Node.js Go
How it's passed implicitly, along the async chain explicitly, ctx as the first parameter
Attaching and reading a value als.run(store, fn) / als.getStore() context.WithValue(ctx, key, v) / ctx.Value(key)
Across timers, promises / goroutines follows setTimeout, .then, await automatically doesn't follow on its own: pass ctx into the goroutine or closure
Changing the value the store is a plain object, mutable in place immutable: WithValue returns a new ctx; to collect data, store a pointer + a lock
Typing the store is any JS value Value returns any, so a type assertion is needed (v, ok := …(string))