You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Scaffolded with npx create-next-app -e reproduction-template, so it runs on canary. The only change from that template is a setInterval in next.config.ts, standing in for any config plugin that leaves a live handle behind during evaluation.
git clone https://github.com/gesposito/nextjs-detached-flush-orphan
cd nextjs-detached-flush-orphan
npm install
npm run repro
npm run repro starts next dev, issues one request, sends SIGINT as Ctrl+C would, then watches the detached-flush child the shutdown spawns:
detached-flush child was spawned:
95001 1 94352 /path/to/node .../next/dist/telemetry/detached-flush.js dev ...
LEAKED: 1 process(es) still alive ~13s after the dev server exited.
pid=95001 ppid=1 rss=92MB
It stays there indefinitely. Its parent is gone, so it is reparented to init and nothing supervises it any more — nothing will ever tell it to stop. It survives until it is killed by hand or the machine reboots.
These are not zombies waiting to be cleaned up. They are live processes: the one above holds 92 MB, and the ones on my machine ran at 370 MB and, in one case, 137% CPU.
The request matters. With no traffic no telemetry events are recorded, flushDetached returns early, and no child spawns at all — so a shorter reproduction appears to pass. Easy to trip over.
npm run repro:isolate pins the cause without a dev server: it invokes detached-flush directly, twice, changing onlynext.config.js.
next.config.js
result
events file consumed
module.exports = {}
exits 0, ~0.1s
yes
setInterval(() => {}, 1000); module.exports = {}
never exits
yes
Current vs. Expected behavior
Expected: the detached flush child submits its telemetry and exits, as it did before 16.0.3.
Actual: it submits its telemetry, unlinks the events file, and then never exits — one such process left behind per next dev shutdown, each holding several hundred MB, on any project whose dev-phase next.config leaves a live handle behind. 63 accumulated on my machine over ~27 hours: 18.9 GB RSS and ~350% CPU in aggregate, each with an esbuild --service grandchild. Individually invisible, only obvious in aggregate.
Note the telemetry work itself completes: the events file is unlinked in both cases above. It is also not a stuck request — lsof -a -p <pid> -i on a real orphan returns empty, so it holds zero sockets.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:46 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6000
Available memory (MB): 65536
Available CPU cores: 10
Binaries:
Node: 25.8.0
npm: 11.11.0
Yarn: N/A
pnpm: 10.32.1
Relevant Packages:
next: 16.4.0-canary.0 // Latest available version is detected (16.4.0-canary.0).
eslint-config-next: N/A
react: 19.2.8
react-dom: 19.2.8
typescript: 5.9.3
Next.js Config:
output: N/A
Mechanism is platform-independent; only tested on macOS.
Which area(s) are affected? (Select all that apply)
Runtime
Which stage(s) are affected? (Select all that apply)
next dev (local)
Additional context
Re-filing #97715, which the triage bot closed automatically: I had put an explanatory sentence underneath the URL in the reproduction-link field, and the automated check parses that whole field as a single URL. Same report, that field corrected — apologies for the noise.
The area list has no Telemetry option; the affected code is packages/next/src/telemetry/, spawned by the dev server on shutdown, so I picked the nearest one. The detached flush is only spawned on dev-server shutdown.
Cause
Two ingredients in packages/next/src/telemetry/detached-flush.ts (53 lines):
Line 26 — await loadConfig(PHASE_DEVELOPMENT_SERVER, dir), used only to compute distDir. This evaluates the user's entire next.configunder the dev-server phase inside a throwaway, detached, unsupervised process, so every dev-only side effect in the config chain fires there.
Lines 49–52 (the regression) — commit ad93e452 (Fix telemetry event loss on build failures and server shutdown #85867, "Fix telemetry event loss on build failures and server shutdown", 2025-11-10) removed process.exit(0) after unlinkSync and replaced it with "let Node.js exit naturally after all pending work completes". That never happens for a process holding a ref'd libuv handle. And because it is spawned detached: true, the parent's exit leaves it reparented to init, still running, with nothing left that would ever shut it down.
loadConfig has been in the file since 2023; the process.exit(0) was the only thing making it harmless.
Two consequences beyond the orphan itself: any process the config spawns during evaluation (a bundler service, for instance) is left running too, and a config whose promise never settles hangs the flush before it starts.
One caveat on the obvious fix
Putting the process.exit(0) back would reintroduce what #85867 removed it for: exiting there discards NEXT_TELEMETRY_DEBUG output still queued behind a full pipe buffer. Measured with 300 records and a stalled reader, only the first ~64 KB arrives — 117 of 300. Flagging it only so this regression is not traded for that one.
Affected versions
16.0.2
16.0.3
16.1.x
16.2.x
16.3.1
16.4.0-canary.0
safe
leaks
leaks
leaks
leaks
leaks
Verified against published tarballs by checking for an exit afterunlinkSync — the ENOENT branch (line 40) retains its own process.exit(0), so a naive grep gives a false negative. The change was cherry-picked into the 16.0.x line, so it is not accurate to say it landed in 16.1.0.
The linked reproduction runs on 16.4.0-canary.0, which next info reports as the latest available, so this is not something already fixed on canary. It also reproduces against a from-source build of canary at 8c7ed006b8.
The telemetry opt-out does not help
isDisabled is consulted only in notify() and submitRecord(). loadConfig (line 26) runs 19 lines before new Telemetry() (line 45). The opt-out suppresses one HTTP POST; every leaking action happens upstream of it.
Nor does the spawn itself stop: flushDetached only checks whether any events were queued, and record() queues them regardless of isDisabled. On a real project opted out through the documented command (next telemetry disable, i.e. the conf store rather than the env var), a next dev shutdown still spawns the child. That is how the orphans on my machine accumulated in the first place.
Prior reports
Discussion Accumulating detached-flush.js processes #92805 — "Accumulating detached-flush.js processes" (2026-04-14). The chosen answer recommends disabling telemetry and pkill; neither addresses the cause, and the opt-out demonstrably does not prevent it.
PR fix: skip flushDetached if telemetry is disabled #92927 — "skip flushDetached if telemetry is disabled" (open since 2026-04-17). Same symptom, same trigger (E2E cycling a dev server), same telemetry-disabled observation. It narrows the blast radius but leaves opted-in users leaking.
#96317 (_controller lost through the promise chain) is a genuine defect, but it cannot produce an orphan: flushDetached runs only in the parent; the child never calls it. On the shutdown path record(event, deferred=true) skips submitRecord, so no AbortController is ever constructed. And allEvents.push(...) is unconditional, so a working abort yields a byte-identical spawn. Filed separately to keep the two from being conflated.
No test coverage
detached-flush has no test coverage anywhere in the repo — the only other mention is a filename inside the next-server-nft file-trace snapshot, which never executes it. That is plausibly why the removal shipped and survived nine releases.
Workaround
pkill -f 'telemetry/detached-flush.js' to reclaim, or pin next@16.0.2. The telemetry opt-out is not a workaround.
Link to the code that reproduces this issue
https://github.com/gesposito/nextjs-detached-flush-orphan
To Reproduce
Scaffolded with
npx create-next-app -e reproduction-template, so it runs on canary. The only change from that template is asetIntervalinnext.config.ts, standing in for any config plugin that leaves a live handle behind during evaluation.git clone https://github.com/gesposito/nextjs-detached-flush-orphan cd nextjs-detached-flush-orphan npm install npm run repronpm run reprostartsnext dev, issues one request, sendsSIGINTas Ctrl+C would, then watches thedetached-flushchild the shutdown spawns:It stays there indefinitely. Its parent is gone, so it is reparented to init and nothing supervises it any more — nothing will ever tell it to stop. It survives until it is killed by hand or the machine reboots.
These are not zombies waiting to be cleaned up. They are live processes: the one above holds 92 MB, and the ones on my machine ran at 370 MB and, in one case, 137% CPU.
The request matters. With no traffic no telemetry events are recorded,
flushDetachedreturns early, and no child spawns at all — so a shorter reproduction appears to pass. Easy to trip over.npm run repro:isolatepins the cause without a dev server: it invokesdetached-flushdirectly, twice, changing onlynext.config.js.next.config.jsmodule.exports = {}setInterval(() => {}, 1000); module.exports = {}Current vs. Expected behavior
Expected: the detached flush child submits its telemetry and exits, as it did before 16.0.3.
Actual: it submits its telemetry, unlinks the events file, and then never exits — one such process left behind per
next devshutdown, each holding several hundred MB, on any project whose dev-phasenext.configleaves a live handle behind. 63 accumulated on my machine over ~27 hours: 18.9 GB RSS and ~350% CPU in aggregate, each with anesbuild --servicegrandchild. Individually invisible, only obvious in aggregate.Note the telemetry work itself completes: the events file is unlinked in both cases above. It is also not a stuck request —
lsof -a -p <pid> -ion a real orphan returns empty, so it holds zero sockets.Provide environment information
Operating System: Platform: darwin Arch: arm64 Version: Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:46 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6000 Available memory (MB): 65536 Available CPU cores: 10 Binaries: Node: 25.8.0 npm: 11.11.0 Yarn: N/A pnpm: 10.32.1 Relevant Packages: next: 16.4.0-canary.0 // Latest available version is detected (16.4.0-canary.0). eslint-config-next: N/A react: 19.2.8 react-dom: 19.2.8 typescript: 5.9.3 Next.js Config: output: N/AMechanism is platform-independent; only tested on macOS.
Which area(s) are affected? (Select all that apply)
Runtime
Which stage(s) are affected? (Select all that apply)
next dev (local)
Additional context
Re-filing #97715, which the triage bot closed automatically: I had put an explanatory sentence underneath the URL in the reproduction-link field, and the automated check parses that whole field as a single URL. Same report, that field corrected — apologies for the noise.
The area list has no Telemetry option; the affected code is
packages/next/src/telemetry/, spawned by the dev server on shutdown, so I picked the nearest one. The detached flush is only spawned on dev-server shutdown.Cause
Two ingredients in
packages/next/src/telemetry/detached-flush.ts(53 lines):await loadConfig(PHASE_DEVELOPMENT_SERVER, dir), used only to computedistDir. This evaluates the user's entirenext.configunder the dev-server phase inside a throwaway, detached, unsupervised process, so every dev-only side effect in the config chain fires there.ad93e452(Fix telemetry event loss on build failures and server shutdown #85867, "Fix telemetry event loss on build failures and server shutdown", 2025-11-10) removedprocess.exit(0)afterunlinkSyncand replaced it with "let Node.js exit naturally after all pending work completes". That never happens for a process holding a ref'd libuv handle. And because it is spawneddetached: true, the parent's exit leaves it reparented to init, still running, with nothing left that would ever shut it down.loadConfighas been in the file since 2023; theprocess.exit(0)was the only thing making it harmless.Two consequences beyond the orphan itself: any process the config spawns during evaluation (a bundler service, for instance) is left running too, and a config whose promise never settles hangs the flush before it starts.
One caveat on the obvious fix
Putting the
process.exit(0)back would reintroduce what #85867 removed it for: exiting there discardsNEXT_TELEMETRY_DEBUGoutput still queued behind a full pipe buffer. Measured with 300 records and a stalled reader, only the first ~64 KB arrives — 117 of 300. Flagging it only so this regression is not traded for that one.Affected versions
Verified against published tarballs by checking for an exit after
unlinkSync— the ENOENT branch (line 40) retains its ownprocess.exit(0), so a naive grep gives a false negative. The change was cherry-picked into the 16.0.x line, so it is not accurate to say it landed in 16.1.0.The linked reproduction runs on
16.4.0-canary.0, whichnext inforeports as the latest available, so this is not something already fixed on canary. It also reproduces against a from-source build of canary at8c7ed006b8.The telemetry opt-out does not help
isDisabledis consulted only innotify()andsubmitRecord().loadConfig(line 26) runs 19 lines beforenew Telemetry()(line 45). The opt-out suppresses one HTTP POST; every leaking action happens upstream of it.Nor does the spawn itself stop:
flushDetachedonly checks whether any events were queued, andrecord()queues them regardless ofisDisabled. On a real project opted out through the documented command (next telemetry disable, i.e. the conf store rather than the env var), anext devshutdown still spawns the child. That is how the orphans on my machine accumulated in the first place.Prior reports
pkill; neither addresses the cause, and the opt-out demonstrably does not prevent it.Not the same as #96317 / #96318
#96317 (
_controllerlost through the promise chain) is a genuine defect, but it cannot produce an orphan:flushDetachedruns only in the parent; the child never calls it. On the shutdown pathrecord(event, deferred=true)skipssubmitRecord, so noAbortControlleris ever constructed. AndallEvents.push(...)is unconditional, so a working abort yields a byte-identical spawn. Filed separately to keep the two from being conflated.No test coverage
detached-flushhas no test coverage anywhere in the repo — the only other mention is a filename inside thenext-server-nftfile-trace snapshot, which never executes it. That is plausibly why the removal shipped and survived nine releases.Workaround
pkill -f 'telemetry/detached-flush.js'to reclaim, or pinnext@16.0.2. The telemetry opt-out is not a workaround.