Skip to content

detached-flush never exits, leaking a reparented process per next dev shutdown (regression in 16.0.3) #97716

Description

@gesposito

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 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 only next.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):

  1. Line 26await loadConfig(PHASE_DEVELOPMENT_SERVER, dir), used only to compute distDir. This evaluates the user's entire next.config under the dev-server phase inside a throwaway, detached, unsupervised process, so every dev-only side effect in the config chain fires there.
  2. 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 after unlinkSync — 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.

Not the same as #96317 / #96318

#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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    RuntimeRelated to Node.js or Edge Runtime with Next.js.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions