Prompt and context
A Node.js service stores requestId, tenant, and audit data in AsyncLocalStorage. The HTTP entry can read the store, but some logs lose context after a database-driver callback, event emitter, custom Promise-like object, or worker boundary. The team has just upgraded from Node.js 22 to 24 and wants to know whether the default implementation change is related. Design diagnosis, repair, regression, and fallback plans.
The Node.js 24 release notes record that AsyncLocalStorage now uses AsyncContextFrame by default. The official documentation still says callback-based APIs or custom thenables may need AsyncResource to associate asynchronous work with the correct execution context. The interview tests async boundaries, observability, and version migration rather than blaming the runtime for every loss.
What the interviewer is assessing
The interviewer is looking for a distinction between a context created by run(), asynchronous-resource lifetime, cross-thread boundaries, and business code that overwrites a store. Strong answers use a minimal reproduction to find the first break, avoid blanket enterWith(), and define verification for third-party callbacks, error paths, sampled diagnostics, and Node versions.
Clarifying questions to ask
- Does the loss occur in the same event loop, a worker, a child process, or a network boundary?
- Does the library use native promises, callbacks, an event emitter, or a custom thenable?
- Is the store accidentally overwritten by another
run(),enterWith(), or reused async task? - Does losing requestId affect audit and billing correctness, or only log correlation?
- Are startup flags, dependency versions, and experimental switches identical on Node.js 22 and 24?
30-second answer
“I would record immutable store snapshots at the entry, every async boundary, and the final log, then use a minimal reproduction to find the first loss instead of blaming Node 24. Native promise chains should use run() at the request boundary. If a third-party callback or custom thenable fails to propagate context, I would use AsyncResource where the task is created and where its callback runs. I would avoid global enterWith() contamination and regress Node 22/24, errors, timeouts, and workers separately. The fix is proven by requestId completeness and business correctness.”
Step-by-step deep answer
1. Define the context contract
Specify store fields, lifetime, and immutability. Create a new store per request; downstream code may read or derive values but should not share mutable objects across requests. Whether a missing requestId is merely a logging issue or affects authorization and tenant isolation determines the stop condition and repair priority.
2. Find the first loss with snapshots
Capture a field summary from getStore() at the entry, before and after database calls, in event listeners, promise callbacks, timeouts, error handlers, and final logging. Log only a hash or short request ID, never sensitive tenant data. Label each async boundary and find the first transition from a value to undefined instead of inspecting only the last error.
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
function contextSnapshot(label) {
const store = requestContext.getStore();
return { label, requestId: store?.requestId ?? null };
}3. Separate run(), enterWith(), and resource association
run(store, callback) provides the store in the callback and async work it creates, making it a good request boundary. enterWith() extends context into later event handling in the same synchronous execution and can contaminate listeners, so it should not be a generic fix without a proven boundary. A callback library that does not create async resources correctly needs an AsyncResource wrapper.
4. Wrap a third-party callback boundary
First check whether the library already uses Node’s async resources correctly. If it does not, create one AsyncResource per task, call the callback with runInAsyncScope, and destroy the resource when the task completes. Do not reuse one resource across requests or patch only the logger with a requestId; that hides the actual context break.
import { AsyncResource } from 'node:async_hooks';
function bindCallback(callback) {
const resource = new AsyncResource('third-party-callback');
return (...args) => resource.runInAsyncScope(callback, null, ...args);
}5. Test thenables, events, and workers separately
A custom thenable may not follow native Promise context propagation. An event emitter may invoke listeners on a later tick. A worker or child process has an independent execution context and cannot implicitly share the store. Write a minimal test for each boundary, documenting which fields cross through an explicit message and which are recreated at a new request boundary.
6. Regress Node 22/24 and observe the result
Include Node 24’s default implementation change in the upgrade matrix, but do not substitute a version comparison for root-cause analysis. Fix startup flags, dependency versions, and execution mode, then compare requestId completeness, boundary breaks, error rate, latency, and throughput. Keep low-rate boundary diagnostics after release; if authorization or tenant fields disappear, stop the canary and return to the stable version.
High-quality sample answer
I would define the store contract and record non-sensitive snapshots at the entry, key async boundaries, and final logs to find the first transition from a value to empty. Native Promise chains use run() at the request boundary. If a third-party callback or custom thenable fails to propagate context, the wrapper creates one AsyncResource per task and invokes the callback with runInAsyncScope, then destroys it. I would not mask the issue with global enterWith(). Test event emitters, timeouts, errors, workers, and Node 22/24 separately, comparing requestId completeness and business fields. Node 24’s AsyncContextFrame is a version variable in the test matrix, not the sole explanation.
Common mistakes
- Blaming every loss on Node 24 → The third-party boundary may be the break → Use a minimal reproduction and boundary snapshots first.
- Calling
enterWith()everywhere → Event listeners can contaminate one another → Prefer request-scopedrun(). - Adding requestId only in the logger → Tenant or audit context is still lost → Repair async-resource association.
- Reusing one
AsyncResourcefor all requests → Contexts cross-contaminate → Create and destroy one per task. - Assuming workers inherit the store → Cross-thread context is not implicit → Pass required fields explicitly in messages.
Follow-up questions and responses
How do you choose between run() and enterWith()?
Prefer run() for a request or task boundary because the scope follows the callback and its async work. enterWith() affects later event handling in the current synchronous execution and should be used only when the boundary is explicit and listener contamination is ruled out.
When is AsyncResource needed?
Use it when a callback API, event wrapper, or custom thenable fails to connect its async operation to Node’s async-resource graph, so the callback can run in the context where the task was created.
How do you preserve requestId in a worker?
A worker has an independent execution context, so pass requestId or the minimum tenant identifier in a message, create a new AsyncLocalStorage store at the worker entry, and avoid sending sensitive objects.
Will Node 24’s AsyncContextFrame fix every loss?
No. It changes the default implementation, but third-party callbacks, incorrect enterWith(), custom thenables, and cross-thread boundaries still require separate tests.
How do you show that the fix did not add overhead?
Compare latency, throughput, CPU, memory, and requestId completeness under identical traffic and dependency versions, and observe the number of resources created for bound callbacks rather than relying on one benchmark.