Repository navigation
[BUG]: express.middleware spans retained unbounded via AsyncLocalStorage frame capture → heap OOM #9227
Description
Activity
- added a commit that references this issue
on Jul 6, 2026 - added 8 commits that reference this issue
on Jul 7, 2026 @BridgeAR Any update when a fix can be released? The underlying issue is creating a memory leak in our application as well
The tracer kind of behaves correct is the problem.
It is a very tricky situation where different requirements mean we have to still have access to older memory in some situations.
It is on the radar and I will try to focus on this next week.
Reacted by Robert Dailey and khaneh-liner@BridgeAR thank you for looking into and working on this!! more context if its at all helpful:
Confirming the same retention pattern in Next.js (App Router, standalone) where the documented workaround doesn't apply
We believe we hit the same root cause in a production Next.js 16 App Router app (
output: "standalone",cacheComponents: true) on dd-trace5.113.0i.e., theenterWithframe-capture retention described here isn't limited toexpress.middlewarespans; the path inpackages/dd-trace/src/plugins/tracing.js/plugin.jsis generic to tracing plugins.What we observed
- Tracing enabled: monotonic heap growth of ~23-54 MiB/h under steady production traffic, ending in a V8 heap OOM (
FATAL ERROR: Ineffective mark-compacts near heap limit) at ~30h uptime, with a burst of user-facing 502s at each crash. DD_TRACE_ENABLED=false, same traffic: ~7-10 MiB/h with a healthy GC sawtooth, no crashes.
How we confirmed
Env-var differential only, no code changes: flippedDD_TRACE_ENABLEDoff in staging, reproduced the flat profile, then rolled the same toggle through production. Heap growth tracked the tracer, not the app.Two Next.js-specific notes
- The next plugin's
bindStartreturns{ ...store, span, req }, so every captured async-context frame additionally pins theIncomingMessage(and its buffers/headers), which plausibly makes per-request retained size worse than the express numbers in the issue body. (Related but distinct from Memory leak in Next.js plugin: WeakMap in #requestsBySpanId retains IncomingMessage objects #7876, which was the#requestsBySpanIdWeakMap path.) - The validated mitigations here (
DD_TRACE_MIDDLEWARE_TRACING_ENABLED=false/middleware: false) only suppress express middleware spans. Next.js standalone users have no equivalent short of disabling tracing entirely.
it would be great if the fix in #9292 and its tests also covered the next plugin's store shape (if not already planning to), not just express middleware chains. also always happy to help test a prerelease against our workload!
- Tracing enabled: monotonic heap growth of ~23-54 MiB/h under steady production traffic, ending in a V8 heap OOM (
Hi @BridgeAR It's been a few weeks, and it seems like the PR stalled. Any chance we can prioritize a fix?
Reacted by Robert DaileyReacted by David PihlajaAfter looking further into it, it is likely related to using undici.
Could you please deactivate it with
tracer.use('undici', false)(programmatic) orDD_TRACE_UNDICI_ENABLED=false(environment variable). That should resolve the problem for now.The undici fix should land in the next release. I want to apologize that this took so long!
Reacted by Robert DaileyI opened a PR to fix the root issue nodejs/undici#5761
We landed a fix in dd-trace-js as well (a kind of bug workaround for the undici bug) and it will be released in the next version.
Now that the fix landed in undici and we also landed a fix in dd-trace that the issue does not surface anymore, this should be resolved.
If you are still seeing any of the former behavior, please let us know right away, thank you!
Reacted by Julien Whitney- added 5 commits that reference this issue
on Sep 10, 2026
Tracer Version(s)
5.110.0 (also confirmed present on
master/ v5.111.0)Node.js Version(s)
24.16.0 (also reproduces on 18.20.8)
Bug Report
Under sustained traffic, an Express app instrumented by dd-trace accumulates
express.middlewarespans in the JS heap without bound, eventually crashing withFATAL ERROR: … JavaScript heap out of memory. In a production service the post-GC live heap climbed ~linearly to the--max-old-space-sizeceiling and OOM'd roughly daily; ~93% of retainedDatadogSpanobjects wereexpress.middleware.Root cause (traced through the source): the active span is stored with
AsyncLocalStorage.enterWith({ ...store, span })(notrun()) —packages/dd-trace/src/plugins/tracing.js:268-269,packages/dd-trace/src/plugins/plugin.js:105-107, and per-middleware atpackages/datadog-plugin-router/src/index.js:41. Because the HTTPrequestevent is emitted synchronously on the connection's async-context frame, this writes{ ...store, span }into that frame. Any never-released async resource created during request handling (a retainedAsyncResource, an event listener added to a long-lived emitter and never removed, a subscription, a cache entry, etc.) captures that frame (AsyncContextFrame/ legacykResourceStore) and pins the entire middleware span chain for the life of that resource — independent of the HTTP socket. The span is finished correctly, but the captured store snapshot still references it, so it is never collected. Real handlers create such resources constantly (DB clients, pub/sub subscriptions, timers, listeners), so retained spans grow ~N-per-request unbounded.Retainer chain from a heap snapshot (taken after the load client closed its sockets + GC), proving it is not socket- or timer-anchored:
Reproduction Code
Full, self-contained reproducer: https://github.com/dwinrick-lever/dd-trace-express-middleware-span-leak —
npm install && ./reproduce.shExpress + ~20 no-op middlewares +
dd-trace.init(); sustained requests where each creates one never-destroyed async resource (stand-in for a real subscription/listener). Measured:--max-old-space-size=256(~18 s) and512(~22 s): the larger cap crashes later but still crashes — a bounded working set would plateau below 512 MB and never crash there.DD_TRACE_MIDDLEWARE_TRACING_ENABLED=false(ortracer.use('express', { middleware: false })) cuts retained spans ~96% and stops the growth.--no-async-context-framedoes not help.Error Logs
Tracer Config
Default:
require('dd-trace').init()with the express integration enabled; server uses HTTP keep-alive (Node default). No unusual config required to trigger it.Workaround:
DD_TRACE_MIDDLEWARE_TRACING_ENABLED=falseortracer.use('express', { middleware: false }).Operating System
Linux (mechanism is not OS-specific).
Bundling
No Bundling