Skip to content

lib,events: speed up AbortSignal - #20

Closed
ronag wants to merge 1 commit into
nxtedition:abort-signal-perf-basefrom
ronag:abort-signal-perf
Closed

ronag wants to merge 1 commit into
nxtedition:abort-signal-perf-basefrom
ronag:abort-signal-perf

Conversation

@ronag

@ronag ronag commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Review copy, based on abort-signal-perf-base (upstream main at 2745698). Upstream PR: nodejs#66789

Speeds up the hot AbortController / AbortSignal paths: aborting,
adding and removing abort listeners, AbortSignal.any() and
AbortSignal.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 and AbortSignal.timeout() ~2.5x.
Plain EventTarget listener add/remove gets 3-7% faster as well.

What was slow

Profiles of the slow paths pointed at a handful of hotspots:

  • Every abort allocated and dispatched an Event, even with no
    listeners. Each trusted Event also inserted itself into a WeakSet
    for isTrusted, and every abort allocated a null-prototype array via
    Object.setPrototypeOf([], null) for the dependent signals.
  • AbortSignal's kNewListener / kRemoveListener hooks used
    super[kNewListener](...).
    V8 doesn't optimize keyed super
    property access: a super[symbol]() call costs ~25 ns, against ~4 ns
    for a named super call or a direct call. These two calls were a
    third of the cost of adding and removing a listener.
  • addEventListener() allocated a normalized options object on
    every call and always called the hideStackFrames-wrapped
    validateAbortSignal().
  • AbortSignal.any() allocated a new WeakRef per source per call
    and made two FinalizationRegistry registrations 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

  • Abort: skip allocating and dispatching the abort event when the
    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 a WeakSet.
    The shared getter function, its name and its result for any receiver
    are unchanged.
  • addEventListener(): flattens its options into locals and only
    validates signal when one is passed. The abort-removal closure
    captures a copy of capture, so that capture stays out of the
    function context. Capturing the variable itself made the
    duplicate-listener walk measurably slower. Listener uses its flag
    bits directly.
  • Listener hooks: AbortSignal calls the EventTarget hooks
    directly instead of through keyed super.
  • Object shape: the slot that is added to a signal once it becomes
    an any() source is pre-initialized, as are the composite-only slots
    for composites, so that live signals don't change shape. Aborting
    still removes every signal from gcPersistentSignals
    unconditionally, 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():
    • Each signal gets a single self-WeakRef, shared by every
      composite that follows it.
    • Each composite registers once, to leave its sources' dependant sets
      when it is collected.
    • Each source registers once, when it gets its first dependant, to
      leave its dependants' source sets when it is collected.
    • No unregister tokens are needed, and the cleanup when either side
      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 a
    small holder that is emptied when the timer fires, instead of using
    the signal as an unregister token.
  • Benchmarks: new benchmarks for abort with and without listeners,
    listener add and remove (including addAbortListener), and
    AbortSignal.any().

Benchmarks

All numbers are for db1b78c against its base 2745698. db1b78c has the
same tree as this PR's squashed commit.

benchmark/compare.js, 20 runs per configuration:

                                                                                    confidence improvement   accuracy (95% CI)
abort_controller/abort-controller-abort.js listeners=0 n=1000000                    ***   517.24 %   ±  6.95%
abort_controller/abort-controller-abort.js listeners=1 n=1000000                    ***   151.99 %   ±  3.28%
abort_controller/abort-controller-creation.js kind='AbortController' n=1000000        *    -3.92 %   ±  3.54%
abort_controller/abort-controller-creation.js kind='AbortSignal.timeout' n=1000000  ***    57.96 %   ±  2.01%
abort_controller/abort-signal-add-remove.js kind='addEventListener' n=1000000       ***    47.50 %   ±  2.37%
abort_controller/abort-signal-add-remove.js kind='addEventListener-once' n=1000000  ***    58.02 %   ±  2.22%
abort_controller/abort-signal-add-remove.js kind='addAbortListener' n=1000000       ***    19.13 %   ±  1.36%
abort_controller/abort-signal-any.js kind='create' n=200000                         ***    37.91 %   ±  5.14%
abort_controller/abort-signal-any.js kind='listen' n=200000                         ***    32.53 %   ±  4.83%
abort_controller/abort-signal-any.js kind='abort' n=200000                          ***    44.79 %   ±  3.60%
abort_controller/abort-signal-static-abort.js kind='default-reason' n=5000000         *     1.04 %   ±  0.80%
abort_controller/abort-signal-static-abort.js kind='same-reason' n=5000000          ***   -12.85 %   ±  3.18%
events/eventtarget-add-remove-abort.js nListener=1 n=100000                                 1.26 %   ±  4.88%
events/eventtarget-add-remove-abort.js nListener=5 n=100000                           *     3.38 %   ±  2.56%
events/eventtarget-add-remove-abort.js nListener=10 n=100000                        ***     4.19 %   ±  1.85%
events/eventtarget-add-remove.js nListener=1 n=100000                                **     6.70 %   ±  4.11%
events/eventtarget-add-remove.js nListener=5 n=100000                                **     3.36 %   ±  2.50%
events/eventtarget-add-remove.js nListener=10 n=100000                               **     2.97 %   ±  1.94%
events/eventtarget-creation.js n=1000000                                                    1.22 %   ±  7.90%
events/eventtarget.js listeners=1 n=1000000                                                -1.27 %   ±  2.13%
events/eventtarget.js listeners=5 n=1000000                                                -0.22 %   ±  1.56%
events/eventtarget.js listeners=10 n=1000000                                               -0.86 %   ±  1.13%

The compare.js table was produced with a JavaScript port of
compare.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 WeakRef targets
are kept alive until the end of the current job and long synchronous
loops would otherwise exaggerate GC costs.

scenario base head change
new AbortController().signal 24.1 [24-26] 24.3 [24-25] ~0 (p=0.66)
abort, no listeners 225.1 [218-229] 30.7 [30-31] 7.3x
abort, 1 listener 386.8 [382-401] 136.6 [134-145] 2.8x
abort, onabort 465.3 [454-485] 161.7 [158-169] 2.9x
abort() with the default reason 1411.1 [1381-1427] 1193.8 [1139-1264] 1.18x
add + remove listener 107.6 [105-118] 66.9 [65-68] 1.61x
add + remove listener, { once: true } 127.0 [123-131] 69.3 [68-73] 1.83x
addAbortListener() + dispose 419.6 [399-425] 337.5 [316-371] 1.24x
EventTarget listener with { signal }, abort 1519.4 [1494-1542] 1263.5 [1252-1304] 1.20x
add + remove on mixed plain/source/composite/timeout signals 177.2 [152-195] 111.7 [102-134] 1.59x
AbortSignal.abort(reason) 8.1 [8-8] 8.9 [9-10] 0.91x
AbortSignal.timeout() 911.2 [763-935] 368.5 [340-472] 2.47x
AbortSignal.any([a, b]) 1483.0 [1442-1530] 556.3 [552-570] 2.67x
any() + listen + remove 2114.0 [2075-2188] 1474.1 [1297-1583] 1.43x
any() + listen + abort 2851.4 [2755-3032] 1678.8 [1652-2132] 1.70x

All changes except new AbortController().signal are 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 initialized
    keep signals that turn into any() sources from changing shape, which
    is what the mixed-signal row measures.
  • new AbortController().signal is borderline in compare.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

  • Abort without listeners: aborting a signal that has no abort
    listeners no longer calls signal.dispatchEvent(). The only way to
    notice is to monkeypatch dispatchEvent on AbortSignal.prototype or
    EventTarget.prototype. The DOM spec fires the event internally, so
    browsers don't call a patched dispatchEvent either.
  • Fired timeouts: after AbortSignal.timeout() fires, the
    registration and an empty holder stay alive until the signal is
    collected. Previously they were unregistered when the timer fired.
    The Timeout object itself is still released when it fires.
  • Internal slots: the internal symbol slots on signals changed:
    kResultSignalWeakRef is now kWeakRef, and kWeakRef and
    kDependantSignals are always present. Tests that look up
    kDependantSignals by its description still work.
  • Duplicate sources: AbortSignal.any([a, a]) now registers a
    once instead of twice, because the WeakRefs are shared. There is no
    observable difference.

Not included (possible follow-ups)

  • Default abort reason: abort() without a reason is dominated by
    DOMException construction (~1 µs, 91% of the call). That is in
    domexception.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.
  • Weak listeners: the signal option of addEventListener() still
    uses weak listeners, with three WeakRefs and a tokened registration
    per listener.
  • WebIDL sequences: the sequence converter builds a per-element
    error context string even when conversion succeeds.

Overlap with open upstream PRs

Validation

All of the following were run on db1b78c, which has the same tree as this
PR's squashed commit:

  • Environment: Linux x64. Release builds (./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 + wpt suites: everything passes except
    test-cluster-primary-error and test-cluster-primary-kill. Both also
    time out on the base binary, because in the container PID 1 doesn't
    reap zombie processes.
  • GC-sensitive tests, 20 repeats each: all pass.
    These are test-abortsignal-drop-settled-signals,
    test-abortsignal-any-source-gc, test-abortsignal-any,
    test-abortcontroller, test-abortcontroller-internal,
    test-abort-controller-any-timeout and test-eventtarget.
  • Lint: ESLint passes on all changed files.
  • Independent review: a separate pass ran the new
    abort_controller.js and event_target.js on top of a stock Node 26
    and compared them with base. It covered option getter order and
    errors, dispatch semantics, isTrusted for every kind of receiver,
    and GC of sources, composites and timeouts. The only difference it
    found is the dispatchEvent one described above.

New tests:

  • the signal option only removes the listener with the matching
    capture flag;
  • the isTrusted getter returns false for any receiver without
    throwing;
  • aborting a source aborts every composite that shares it;
  • observed composites that share a source are collected once the
    source is.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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
ronag force-pushed the abort-signal-perf branch from db1b78c to b02964b Compare October 16, 2026 13:56
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
ronag force-pushed the abort-signal-perf branch from b02964b to b4510c8 Compare October 16, 2026 13:58
@ronag ronag closed this Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants