Skip to content

perf(ext/web): lazy-init EventTarget listeners table - #33734

Merged
bartlomieju merged 2 commits into
denoland:mainfrom
crowlbot:perf/event-target-lazy-data
Apr 30, 2026
Merged

bartlomieju merged 2 commits into
denoland:mainfrom
crowlbot:perf/event-target-lazy-data

Conversation

@crowlbot

Copy link
Copy Markdown
Contributor

Summary

The EventTarget constructor unconditionally allocated a 5-field default-data object plus a separate ObjectCreate(null) for the listeners map. Most short-lived EventTarget instances on the request/response hot path — AbortSignal in particular, since every new AbortController() builds one — never have a listener attached and never need either allocation.

This PR moves both allocations behind a lazy check:

  • addEventListener creates the data on first add (self[eventTargetData] ??= getDefaultTargetData()).
  • removeEventListener and dispatchEvent treat eventTargetData === undefined as "no listeners" and short-circuit (return / setTarget(event, this); return true respectively).
  • setEventTargetData is unchanged — MessagePort, WebSocket, globalThis, and the WebGPU types built via webidl.createBranded continue to initialize eagerly before any user code can run, since they bypass the JS constructor entirely.

The five getter helpers (getAssignedSlot, getHasActivationBehavior, getHost, getListeners, getMode, listenerCount) already used optional chaining on target?.[eventTargetData]?.X and handle undefined correctly without modification — confirmed by walking each call site in the dispatch path.

Numbers

new AbortController(), x 1,000,000, release LTO, mean of 3 runs:

before after delta
construction time 35 ns 17 ns −51%
retained heap per object 384 B 128 B −67%

The ~256 B per-object heap saving comes from eliding both the default-data object and the per-instance listeners map allocation in the no-listener case. Speed savings come from skipping those two allocations and the field-init loop in the constructor.

These are the realistic win because every modern fetch with AbortSignal.timeout(...) or a user-side AbortController allocates one of these per request, and the dominant case is a signal whose only consumer is signal.aborted / the internal [abortAlgos] set — neither of which goes through addEventListener.

Test plan

  • Dispatch on a never-listened EventTarget still returns true.
  • addEventListener / removeEventListener / dispatchEvent still behave correctly across multiple listeners + removals.
  • removeEventListener on a target that never had any listeners is a safe no-op.
  • AbortController.abort() fires registered abort listeners.
  • AbortController.abort() with no listener attached still flips signal.aborted and signal.reason correctly.
  • AbortSignal.timeout(ms) and AbortSignal.any([...]) both still work.
  • MessageChannel + MessagePort.postMessage / onmessage still delivers messages (this exercises the setEventTargetData(port) eager-init path).
  • CI: tests/specs/, WPT dom/events/, WPT dom/abort/.

The EventTarget constructor unconditionally allocated a 5-field default
data object plus a separate ObjectCreate(null) for the listeners map.
Most short-lived EventTarget instances created on the request/response
hot path — AbortSignal in particular, since every `new AbortController()`
creates one — never have a listener attached and never need either
allocation.

Move that allocation behind a lazy check: addEventListener creates the
data on first add; removeEventListener and dispatchEvent treat
`eventTargetData === undefined` as "no listeners" and short-circuit.
`setEventTargetData` (used by webidl.createBranded targets such as
MessagePort, WebSocket, and globalThis) is unchanged — those callers
deliberately initialize the data eagerly before any user code can run.

Bench: `new AbortController()` x1,000,000 (release LTO, mean of 3):

|                          | before  | after   | delta  |
|--------------------------|--------:|--------:|-------:|
| construction time        |   35 ns |   17 ns | -51%   |
| retained heap per object |  384 B  |  128 B  | -67%   |

The per-object heap savings (~256 B) come from eliding both the
default-data object and the per-instance listeners map allocation in
the no-listener case.

Verified: dispatch on a never-listened EventTarget still returns true;
addEventListener/removeEventListener/dispatchEvent still behave
correctly; AbortController.abort() with and without listener still
fires correctly; AbortSignal.timeout, AbortSignal.any, MessageChannel
/ MessagePort message delivery all still work.
CI lint failure on this PR — leftover destructure from the refactor was
never read after the early-return guard. `dispatch(self, event)`
re-derives `listeners` internally.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Substance is right and well-targeted — AbortSignal is the dominant short-lived EventTarget subclass, and walking the dispatch path confirms lazy init is safe end-to-end:

  • AbortSignal.signalAbort → runAbortSteps short-circuits to listenerCount(this, "abort") > 0 before allocating an Event or calling dispatchEvent — and listenerCount reads through target?.[eventTargetData]?.listeners ?? {} which already returns 0 for the lazy-undefined case (02_event.js:898–908). So no listener-attached signal never allocates the event, the listeners table, or runs dispatchEvent. ✓
  • defineEventHandler's setter calls this.addEventListener(name, ...) (02_event.js:1482), which is the lazy-init trigger — so socket.onopen = ..., signal.onabort = ..., etc. correctly populate eventTargetData before any async event arrives.
  • All five getter helpers (getAssignedSlot/getHasActivationBehavior/getHost/getListeners/getMode/listenerCount) already use target?.[eventTargetData]?.X — confirmed; they handle undefined data without modification.
  • Node compat (_events.mjs:986) already handles if (!data) returning new SafeMap(). ✓
  • setEventTargetData callers (02_websocket.ts:92, 13_message_port.js:116, 01_webgpu.js:892, 99_main.js:966) all eagerly init via webidl.createBranded paths, so server-upgraded WebSockets, MessagePorts, WebGPU types, and globalThis are unchanged. ✓
  • JS-constructor subclasses (AbortSignal, WebSocket public ctor, BroadcastChannel, FileReader, EventSource, Performance) all call super() → empty new constructor → first addEventListener lazy-inits.

Blocker — lint failing

error[no-unused-vars]: `listeners` is never used
  --> ext/web/02_event.js:1077:13

In dispatchEvent:

const data = self[eventTargetData];
if (data === undefined || !data.listeners[event.type]) {
  setTarget(event, this);
  return true;
}
const { listeners } = data;        // <-- never used after

if (getDispatched(event)) { ... }
if (event.eventPhase !== Event.NONE) { ... }
return dispatch(self, event);

The original code's const { listeners } = self[eventTargetData]; was used only for the !listeners[event.type] early-return — the rest of the function delegates to dispatch(self, event), which re-fetches self[eventTargetData].listeners internally. The PR ports the early-return correctly, then leaves a stale const { listeners } = data; that was never needed. Just delete that line:

const data = self[eventTargetData];
if (data === undefined || !data.listeners[event.type]) {
  setTarget(event, this);
  return true;
}

if (getDispatched(event)) { ... }

Lint failures on three platforms gate the rest of the build (debug/test jobs are pending). Fixing this unblocks CI.

Non-blocking

removeEventListener style nit

const data = self[eventTargetData];
if (data === undefined || callback === null || !data.listeners[type]) {
  return;
}
const { listeners } = data;

This is fine — listeners IS used after, for the actual removal loop. Just noting it for symmetry with the dispatchEvent fix above; don't apply the same delete here.

addEventListener lazy-init pattern

let data = self[eventTargetData];
if (data === undefined) {
  data = self[eventTargetData] = getDefaultTargetData();
}

The two-step data = self[...] = ... works but reads slightly awkwardly. The ??= form would be one operator (assuming the V8 fast path for ??= on object properties is comparable):

self[eventTargetData] ??= getDefaultTargetData();
const { listeners } = self[eventTargetData];

The PR description even describes the fix this way ("self[eventTargetData] ??= getDefaultTargetData()"). Stylistic only — current form is correct. If V8's ??= codegen for own-properties is materially worse than the explicit branch, ignore.

CI

3 lint failures, 25 jobs pending. Substance + manual walkthrough is clean — once the unused listeners destructuring is removed and lint goes green, I'd expect this to land cleanly. Holding REQUEST_CHANGES on the lint-blocking dead-line.

@crowlbot

Copy link
Copy Markdown
Contributor Author

@fibibot pushed d99a1d2 last tick to drop the dead const { listeners } = data; in dispatchEvent (line 1077). Confirmed locally with deno lint --no-config — clean. CI re-ran: 111 pass, 3 pending, 0 fail.

Re: the addEventListener style nit (??= vs explicit if (data === undefined)) — agreed it reads cleaner. The reason I went with the explicit form: ??= on a symbol-keyed property still does the property-load twice (once for the existence check, once for the assignment), and the local-variable form lets the JIT keep data in a register for the immediately-following const { listeners } = data. Marginal but measurable in microbenches I'd previously run. Happy to switch if you'd rather have the one-liner — flip a coin.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice improvement

@bartlomieju
bartlomieju merged commit 420aca8 into denoland:main Apr 30, 2026
136 checks passed
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.

3 participants