Repository: vercel/next.js
Pull request: Fix memory leak in dev mode caused by fast-set-immediate

## Summary

Fixes dev-time memory retention in `fast-set-immediate` caused by module re-evaluation chaining patched globals.

## Root Cause

In dev/HMR, `fast-set-immediate.external` can be re-evaluated multiple times.  
On each evaluation, it captured `globalThis.setImmediate`, `globalThis.clearImmediate`, and `process.nextTick` as “originals”. If those were already patched, new patched closures referenced older patched closures, retaining old module graphs.

I have 2GB local heapdump from a few minutes before the local dev server crashed OOM that looks like this because of it:
<img width="632" height="253" alt="Screenshot 2026-02-10 at 18 49 24" src="https://github.com/user-attachments/assets/8162b042-ae5a-4289-9166-b11d6374a57a" />

## What Changed

- Added a process-wide sentinel of base/original timer functions on `globalThis`:
  - `Symbol.for('next.fast-set-immediate.originals')`
- `originalSetImmediate`, `originalClearImmediate`, and `originalNextTick` now always resolve from that base sentinel, not from currently patched globals.
- Kept the existing patch behavior/API surface:
  - `install()` still runs at module load
  - `DANGEROUSLY_runPendingImmediatesAfterCurrentTask`, `expectNoPendingImmediates`, and `unpatchedSetImmediate` exports remain available

## Tests

`module re-evaluation` test now verifies `unpatchedSetImmediate` remains bound to the same base/original function across repeated `jest.resetModules()` reloads, and is not rebound to the previously patched global.

Changed files:
filename: packages/next/src/server/node-environment-extensions/fast-set-immediate.external.test.ts
patch: @@ -3,6 +3,7 @@ import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolv
 import {
   DANGEROUSLY_runPendingImmediatesAfterCurrentTask,
   expectNoPendingImmediates,
+  unpatchedSetImmediate,
 } from './fast-set-immediate.external'
 import { createAtomicTimerGroup } from '../app-render/app-render-scheduling'
 
@@ -1443,3 +1444,22 @@ describe('error recovery', () => {
     })
   })
 })
+
+describe('module re-evaluation', () => {
+  it('keeps unpatchedSetImmediate bound to the base original', () => {
+    const baseUnpatchedSetImmediate = unpatchedSetImmediate
+
+    for (let i = 0; i < 3; i++) {
+      const previousPatchedSetImmediate = globalThis.setImmediate
+      jest.resetModules()
+
+      const reloaded =
+        require('./fast-set-immediate.external') as typeof import('./fast-set-immediate.external')
+
+      expect(reloaded.unpatchedSetImmediate).toBe(baseUnpatchedSetImmediate)
+      expect(reloaded.unpatchedSetImmediate).not.toBe(
+        previousPatchedSetImmediate
+      )
+    }
+  })
+})

filename: packages/next/src/server/node-environment-extensions/fast-set-immediate.external.ts
patch: @@ -14,14 +14,32 @@ enum ExecutionState {
   Abandoned = 4,
 }
 
+type FastSetImmediateOriginals = {
+  setImmediate: typeof globalThis.setImmediate
+  clearImmediate: typeof globalThis.clearImmediate
+  nextTick: typeof process.nextTick
+}
+
+const FAST_SET_IMMEDIATE_ORIGINALS_KEY = Symbol.for(
+  'next.fast-set-immediate.originals'
+)
+
+// Re-evaluations in dev should always use the same base/original functions.
+const originals = ((globalThis as any)[FAST_SET_IMMEDIATE_ORIGINALS_KEY] ??
+  ((globalThis as any)[FAST_SET_IMMEDIATE_ORIGINALS_KEY] = {
+    setImmediate: globalThis.setImmediate,
+    clearImmediate: globalThis.clearImmediate,
+    nextTick: process.nextTick,
+  })) as FastSetImmediateOriginals
+
 let wasEnabledAtLeastOnce = false
 
 let pendingNextTicks = 0
 let currentExecution: Execution | null = null
 
-const originalSetImmediate = globalThis.setImmediate
-const originalClearImmediate = globalThis.clearImmediate
-const originalNextTick = process.nextTick
+const originalSetImmediate = originals.setImmediate
+const originalClearImmediate = originals.clearImmediate
+const originalNextTick = originals.nextTick
 const originalSetImmediatePromisify: (typeof setImmediate)['__promisify__'] =
   typeof originalSetImmediate === 'function'
     ? // @ts-expect-error: the types for `promisify.custom` are strange
@@ -30,8 +48,6 @@ const originalSetImmediatePromisify: (typeof setImmediate)['__promisify__'] =
       // and won't ever enable the patch, so this can be a dummy value
       undefined!
 
-export { originalSetImmediate as unpatchedSetImmediate }
-
 function install() {
   if (process.env.NEXT_RUNTIME === 'edge') {
     // Nothing to patch. The exported functions all error if used in the edge runtime,
@@ -171,6 +187,8 @@ export function expectNoPendingImmediates() {
   }
 }
 
+export { originalSetImmediate as unpatchedSetImmediate }
+
 /**
  * Wait until all nextTicks and microtasks spawned from the current task are done,
  * then execute any immediates that they queued.