Background callbacks run in another request's AsyncLocalStorage context when the callback queue is busy
Checked other resources
- This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/).
- I added a very descriptive title to this issue.
- I searched the LangChain.js documentation with the integrated search.
- I used the GitHub search to find a similar question and didn't find it.
- I am sure that this is a bug in LangChain.js rather than my code.
- The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
Example Code
Run with @langchain/[email protected] on Node 22 (node --experimental-strip-types repro.mts):
import { AsyncLocalStorage } from "node:async_hooks";
import { BaseCallbackHandler } from "@langchain/core/callbacks/base";
import { awaitAllCallbacks } from "@langchain/core/callbacks/promises";
import { RunnableLambda } from "@langchain/core/runnables";
// Any request-scoped context (e.g. OpenTelemetry context, a logger, auth).
const requestContext = new AsyncLocalStorage<string>();
class ContextRecorder extends BaseCallbackHandler {
name = "context_recorder";
seen: { request: string; contextSeen: string | undefined }[] = [];
async handleChainStart(
_chain: unknown,
_inputs: unknown,
_runId: string,
_parentRunId?: string,
_tags?: string[],
metadata?: Record<string, unknown>,
) {
this.seen.push({
request: metadata?.request as string,
contextSeen: requestContext.getStore(),
});
}
}
const recorder = new ContextRecorder();
const chain = RunnableLambda.from(async (x: string) => {
await new Promise((r) => setTimeout(r, 5));
return x;
});
// 20 concurrent requests, each invoking the chain inside its own context.
const requests = Array.from({ length: 20 }, (_, i) => `request-${i}`);
await Promise.all(
requests.map((request) =>
requestContext.run(request, () =>
chain.invoke("hi", { callbacks: [recorder], metadata: { request } }),
),
),
);
await awaitAllCallbacks();
const wrong = recorder.seen.filter((s) => s.request !== s.contextSeen);
console.log(`LANGCHAIN_CALLBACKS_BACKGROUND=${process.env.LANGCHAIN_CALLBACKS_BACKGROUND ?? "(unset)"}`);
console.log(`${wrong.length}/${recorder.seen.length} handleChainStart calls saw another request's context`);
console.log(wrong.slice(0, 3));Output:
LANGCHAIN_CALLBACKS_BACKGROUND=(unset)
19/20 handleChainStart calls saw another request's context
[
{ request: 'request-1', contextSeen: 'request-0' },
{ request: 'request-2', contextSeen: 'request-0' },
{ request: 'request-3', contextSeen: 'request-0' }
]
LANGCHAIN_CALLBACKS_BACKGROUND=false
0/20 handleChainStart calls saw another request's context
[]Error Message and Stack Trace (if applicable)
No response
Description
What I'm doing
A server handles several requests concurrently. Each request runs a LangChain runnable inside its own AsyncLocalStorage context, which is how request-scoped context such as OpenTelemetry context, loggers or auth usually works in Node. A callback handler reads that context in handleChainStart / handleLLMStart (for example, to tag a span or a log line with the request it belongs to).
What I expect
A callback for a run sees the async context of the code that started that run.
What happens
With the default background callbacks (LANGCHAIN_CALLBACKS_BACKGROUND unset), a callback that has to wait in the callback queue runs in the async context of whichever callback ran before it, often a different, concurrent request. In the example above, 19 of 20 requests had their handleChainStart see request-0's context. With LANGCHAIN_CALLBACKS_BACKGROUND=false, all 20 are correct.
The wrong context persists for as long as the queue stays busy: every callback queued back-to-back inherits the context of the callback that started the streak. It resets once the queue empties.
Why
consumeCallback (libs/langchain-core/src/singletons/callbacks.ts) adds the callback to a process-wide p-queue with concurrency: 1:
queue.add(async () => { ... await promiseFn(); });queue.add stores a plain function. When the slot is busy, p-queue calls it later from inside the previous task's wrapper (after await operation, this._next() → job()), so the stored callback runs in that previous task's async context, not in the context of the code that queued it.
Suggested fix
Capture the caller's context when the callback is queued, and run the callback inside it:
const runInCallerContext = AsyncLocalStorage.snapshot();
queue.add(() => runInCallerContext(async () => { ... await promiseFn(); }));With this change applied locally to @langchain/[email protected], the example reports 0/20. It doesn't conflict with the existing asyncLocalStorageInstance.run(undefined, …) inside the task: LangChain's own run config is still cleared, and every other AsyncLocalStorage (e.g. OpenTelemetry's) sees the caller's value. AsyncLocalStorage.snapshot() is available in Node ≥ 18.16 / 20; environments without AsyncLocalStorage could keep the current behaviour.
Workaround
LANGCHAIN_CALLBACKS_BACKGROUND=false avoids it, but makes every handler run inline in the request path.
Possibly related: #11221 / #11226 (context during run execution). This issue is about the queued callbacks themselves, which those changes don't touch.
System Info
@langchain/core: 1.2.11 (currentlatest), which depends onp-queue6.6.2langchainpackage: not installed; the example needs only@langchain/core- Also present on
main:libs/langchain-core/src/singletons/callbacks.tsstill callsqueue.add(...)without capturing the caller's context - Platform: macOS 26.6.2 (Darwin 25.6.0, arm64)
- Node: v22.15.0
- Package manager: npm 11.4.2
Source: langchain-ai/langchainjs