fix(fetch): hold the request's signal weakly in its finalizer - #5822
Open
xabierlameiro wants to merge 1 commit into
Open
xabierlameiro wants to merge 1 commit into
xabierlameiro wants to merge 1 commit into
Conversation
The FinalizationRegistry that removes a Request's abort listener kept a strong reference to the caller's signal in its held value. When anything reachable from that signal could reach the Request, it also reached the registry's target (the Request's AbortController), so the Request and everything it held were never collected. Hold the signal through a WeakRef instead: a collected signal has no listener left to remove. Signed-off-by: xabierlameiro <xabier.lameiro@gmail.com>
This was referenced Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This relates to...
vercel/next.js#84884 — a memory leak reported eleven months ago against Next.js middleware that turns out to be this.
Related: #4984 replaces this code with
AbortSignal.any([signal])and fixes the same cycle too (I checked its branch against the repro below), buttest/tls-cert-leak.jsfails on that branch (3 of 3 runs here) and passes on main and with this change. This one keeps the current implementation and only changes what the held value references. It doesn't touch nodejs/node#55428, the case #4984 was opened for: dropping theRequestand keeping onlyrequest.signalbehaves the same before and after.Rationale
When a
Requestfollows a caller's signal,requestFinalizerregisters the Request's ownAbortControllerwith{ signal, abort }as the held value. A held value is a strong reference for as long as the target lives. So if anything reachable from the caller's signal can reach theRequest— a listener that closes over it, or a property likesignal.unsubscribe— the held value reaches the registry's own target, the target is never finalized, and theRequest, the signal and everything they hold stay in memory for the life of the process.It isn't hypothetical. axios's fetch adapter hands its composed signal to
new Request(url, { signal }), and once a bundler inlinescomposeSignalsinto the adapter, the closure it stores onsignal.unsubscribegets the adapter's scope as its parent, which holdsrequest. That's the whole of vercel/next.js#84884: +17 MB per 1,000 requests on Node 24.18, flat with the bundler's module merging turned off.Plain Node, no bundler (Node 24.21, undici main):
20,000 calls against a local server, heap after a full GC: 58 MB on main against 11 MB without the
cleanupline. With this change it's 11 MB either way. The cleanup added in #5318 doesn't cover it, becausefetch()always wraps its input in a newRequestand removes that one's listener, not the listener of the Request the caller constructed.Holding the signal weakly is safe because a signal that can still fire is reachable from whatever fires it. A controller holds its signal.
AbortSignal.timeout()andAbortSignal.any()are the cases where the source doesn't, and Node keeps those alive for as long as they have an abort listener and haven't fired. I checked all three: a timeout signal, a composite and a plain controller's signal still abort a liveRequestafter forced GCs, on main and with this change. The timeout case is in the new test, since it's the one that depends on Node keeping the signal alive.End to end, on the reproduction from that Next.js issue: the same Next 16.3.1 build, untouched, with only the global
fetchandRequestreplaced by undici's through--require. Retained MB per 1,000 requests:Changes
Features
N/A
Bug Fixes
requestFinalizer's held value keeps the caller's signal through aWeakRef. A signal that has already been collected has no listener left to remove, so the callback does nothing in that case.test/fetch/request-signal-cycle.js: two cases that fail on main (a listener and a property on the signal that reference theRequest), one that checks the listener is still removed from a signal that outlives theRequest, and one that checks a timeout signal nobody else references still aborts theRequest.Tested on Node 22.11, 22.17, 22.23, 24.4, 24.15, 24.18 and 24.21 (not 25 or 26), and the new file 20 times in a row on 22.23 and 24.21 with no failures.
npm run test:fetchpasses.Node 24 ships the v7 line (undici 7.29.1 in Node 24.21), which has the same held value. The diff doesn't apply to
v7.xas is, becausev7.xdoesn't have #5318, but the same change made by hand passes the new tests there. I can open the backport if you want it.Breaking Changes and Deprecations
N/A
Status