#11657·langchainjs

Background callbacks run in another request's AsyncLocalStorage context when the callback queue is busy

Author: avivtabakCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbug

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):

typescript
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:

typescript
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:

typescript
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 (current latest), which depends on p-queue 6.6.2
  • langchain package: not installed; the example needs only @langchain/core
  • Also present on main: libs/langchain-core/src/singletons/callbacks.ts still calls queue.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