Repository navigation
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
The changes affect complex abort, event, weak-reference, and finalization behavior and warrant final human review.
0 open findings
What changed in this PR
This pull request optimizes AbortSignal and EventTarget performance, with improved bookkeeping, cleanup, tests, and benchmarks.
Changes:
- Reduces abort, listener, composite-signal, and timeout overhead.
- Simplifies weak-reference and finalization tracking.
- Adds regression tests and performance benchmarks.
| File | Description |
|---|---|
test/parallel/test-eventtarget.js |
Tests EventTarget listener behavior and trusted events. |
test/parallel/test-abortsignal-drop-settled-signals.mjs |
Tests composite signal cleanup. |
test/parallel/test-abortsignal-any.mjs |
Tests shared-source propagation. |
lib/internal/event_target.js |
Optimizes event and listener bookkeeping. |
lib/internal/abort_controller.js |
Optimizes abort, composition, and timeout handling. |
benchmark/abort_controller/abort-signal-any.js |
Benchmarks AbortSignal.any(). |
benchmark/abort_controller/abort-signal-add-remove.js |
Benchmarks listener operations. |
benchmark/abort_controller/abort-controller-abort.js |
Benchmarks controller abort performance. |
🧠 Review effort: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
ronag
force-pushed
the
abort-signal-perf
branch
from
October 16, 2026 13:56
db1b78c to
b02964b
Compare
Speed up aborting, adding and removing abort listeners, AbortSignal.any() and AbortSignal.timeout(). - Only allocate and dispatch the abort event when the signal has abort listeners, and only allocate the dependent signal list when there are dependent signals. - Track whether an event is trusted with a private field instead of a WeakSet. - Flatten addEventListener() options into locals instead of allocating a normalized options object, only validate the signal option when one is passed, and use the listener flags directly. - Call the EventTarget kNewListener and kRemoveListener implementations directly. V8 does not optimize keyed super property access such as super[kNewListener](). - Initialize the internal slots that AbortSignal.any() adds to its sources up front, and the composite-only slots for composites, so live signals don't change shape. - Give every signal a single WeakRef that is shared by all the composites that follow it, and register each composite and each source once with their cleanup registries instead of registering every (source, composite) pair with an unregister token. Registering with an unregister token is several times more expensive. - Register AbortSignal.timeout() timers through a holder that is emptied when the timer fires, instead of using the signal as an unregister token. - Add benchmarks for aborting, adding and removing abort listeners, and AbortSignal.any(). Assisted-by: claude:opus-5.5
ronag
force-pushed
the
abort-signal-perf
branch
from
October 16, 2026 13:58
b02964b to
b4510c8
Compare
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.
Speeds up the hot
AbortController/AbortSignalpaths: aborting,adding and removing abort listeners,
AbortSignal.any()andAbortSignal.timeout(). Aborting a controller is 2.8-7.3x faster(with or without listeners), adding and removing an abort listener
1.6-1.8x,
AbortSignal.any()~2.7x andAbortSignal.timeout()~2.5x.Plain
EventTargetlistener add/remove gets 3-7% faster as well.What was slow
Profiles of the slow paths pointed at a handful of hotspots:
Event, even with nolisteners. Each trusted
Eventalso inserted itself into aWeakSetfor
isTrusted, and every abort allocated a null-prototype array viaObject.setPrototypeOf([], null)for the dependent signals.AbortSignal'skNewListener/kRemoveListenerhooks usedsuper[kNewListener](...). V8 doesn't optimize keyedsuperproperty access: a
super[symbol]()call costs ~25 ns, against ~4 nsfor a named
supercall or a direct call. These two calls were athird of the cost of adding and removing a listener.
addEventListener()allocated a normalized options object onevery call and always called the
hideStackFrames-wrappedvalidateAbortSignal().AbortSignal.any()allocated a newWeakRefper source per calland made two
FinalizationRegistryregistrations per (source,composite) pair, one of them with an unregister token. A
registration with an unregister token costs ~380 ns, against ~85 ns
without one.
AbortSignal.timeout()also registered with an unregister token,only so the timer could unregister itself once it fired.
Changes
signal has no abort listeners, and only allocate the dependent signal
list when there are dependent signals.
isTrusted: backed by a private field instead of aWeakSet.The shared getter function, its name and its result for any receiver
are unchanged.
addEventListener(): flattens its options into locals and onlyvalidates
signalwhen one is passed. The abort-removal closurecaptures a copy of
capture, so thatcapturestays out of thefunction context. Capturing the variable itself made the
duplicate-listener walk measurably slower.
Listeneruses its flagbits directly.
AbortSignalcalls theEventTargethooksdirectly instead of through keyed
super.an
any()source is pre-initialized, as are the composite-only slotsfor composites, so that live signals don't change shape. Aborting
still removes every signal from
gcPersistentSignalsunconditionally, so this stays correct if more kinds of signals are
retained there (abort_controller: retain AbortSignal with registered listeners during gc nodejs/node#64415).
AbortSignal.any():WeakRef, shared by everycomposite that follows it.
when it is collected.
leave its dependants' source sets when it is collected.
is collected is the same as before. This also makes the nested
composite token sharing fixed in lib: fix AbortSignal.any() nested composite leak nodejs/node#66617 impossible.
AbortSignal.timeout(): the registry holds the timer through asmall holder that is emptied when the timer fires, instead of using
the signal as an unregister token.
listener add and remove (including
addAbortListener), andAbortSignal.any().Benchmarks
All numbers are for
db1b78cagainst its base2745698.db1b78chas thesame tree as this PR's squashed commit.
benchmark/compare.js, 20 runs per configuration:The
compare.jstable was produced with a JavaScript port ofcompare.R, a Welch t-test, because R isn't available in the container.Standalone microbenchmarks: one process per scenario, 8 alternating
processes per binary, median ns/op with the min-max across processes.
Work runs in batches of 1000 per macrotask, because
WeakReftargetsare kept alive until the end of the current job and long synchronous
loops would otherwise exaggerate GC costs.
new AbortController().signalonabortabort()with the default reason{ once: true }addAbortListener()+ disposeEventTargetlistener with{ signal }, abortAbortSignal.abort(reason)AbortSignal.timeout()AbortSignal.any([a, b])any()+ listen + removeany()+ listen + abortAll changes except
new AbortController().signalare significant(Welch t-test, p<0.001).
Known costs:
AbortSignal.abort(reason)is ~0.8 ns slower (8.1 → 8.9 ns,compare.js-12.9%). The two slots that are now always initializedkeep signals that turn into
any()sources from changing shape, whichis what the mixed-signal row measures.
new AbortController().signalis borderline incompare.js(-3.9% ±3.5%) and shows no difference in the standalone run (24.1 vs
24.3 ns, p=0.66). An earlier revision measured -0.5% ±5.1%.
Behavior changes and tradeoffs
listeners no longer calls
signal.dispatchEvent(). The only way tonotice is to monkeypatch
dispatchEventonAbortSignal.prototypeorEventTarget.prototype. The DOM spec fires the event internally, sobrowsers don't call a patched
dispatchEventeither.AbortSignal.timeout()fires, theregistration and an empty holder stay alive until the signal is
collected. Previously they were unregistered when the timer fired.
The
Timeoutobject itself is still released when it fires.kResultSignalWeakRefis nowkWeakRef, andkWeakRefandkDependantSignalsare always present. Tests that look upkDependantSignalsby its description still work.AbortSignal.any([a, a])now registersaonce instead of twice, because the
WeakRefs are shared. There is noobservable difference.
Not included (possible follow-ups)
abort()without a reason is dominated byDOMExceptionconstruction (~1 µs, 91% of the call). That is indomexception.js, and lib: capture no stack for default abort reason nodejs/node#66265 is changing this path as well.addAbortListener(): it returns a{ __proto__: null, ... }literal, which V8 allocates in dictionary mode (~125 ns). Avoiding
that means changing its prototype, which is the semver question in
lib: avoid using __proto__: null in AbortController/AbortSignal nodejs/node#66678.
signaloption ofaddEventListener()stilluses weak listeners, with three
WeakRefs and a tokened registrationper listener.
error context string even when conversion succeeds.
Overlap with open upstream PRs
{ __proto__: null }call sites inabort_controller.js. Textual conflicts only.kRemoveListenerand theany()timeouthandling. Both are compatible with the new bookkeeping.
lib: keep AbortSignal.any() timeout sources alive nodejs/node#66615 keys off
kDependantSignals.size, which still works.Validation
All of the following were run on
db1b78c, which has the same tree as thisPR's squashed commit:
./configure && make,gcc 14.2) in a Debian trixie container on an AMD EPYC 9355P.
Benchmarks were pinned to 4 cores of one NUMA node, on a shared host
with low background load.
parallel+wptsuites: everything passes excepttest-cluster-primary-errorandtest-cluster-primary-kill. Both alsotime out on the base binary, because in the container PID 1 doesn't
reap zombie processes.
These are
test-abortsignal-drop-settled-signals,test-abortsignal-any-source-gc,test-abortsignal-any,test-abortcontroller,test-abortcontroller-internal,test-abort-controller-any-timeoutandtest-eventtarget.abort_controller.jsandevent_target.json top of a stock Node 26and compared them with base. It covered option getter order and
errors, dispatch semantics,
isTrustedfor every kind of receiver,and GC of sources, composites and timeouts. The only difference it
found is the
dispatchEventone described above.New tests:
signaloption only removes the listener with the matchingcaptureflag;isTrustedgetter returnsfalsefor any receiver withoutthrowing;
source is.