Skip to content

perf: create the event listeners map on first use - #335

Open
zirkelc wants to merge 1 commit into
WebReflection:mainfrom
zirkelc:perf-lazy-event-listeners
Open

zirkelc wants to merge 1 commit into
WebReflection:mainfrom
zirkelc:perf-lazy-event-listeners

Conversation

@zirkelc

@zirkelc zirkelc commented Sep 23, 2026

Copy link
Copy Markdown

Context: performance optimization campaign. This is one of 4 PRs from a systematic performance campaign on linkedom, run as an automated research loop: 18 isolated experiments, 8 kept, 10 discarded. Every candidate change was

  • benchmarked with an A/B harness that loads two git revisions of esm/ into one process and times 14 workloads in alternation. Module instances carry a stable load-order bias of several percent, so the harness runs both load orders in separate child processes and combines them with a geometric mean. The machine was shared and has cores of two speeds, so the reported delta is the median of the paired ratios (both sides run back to back in every iteration), not a ratio of minima.
  • measured on 14 deterministic workloads, half of them mirroring test/benchmark/content.js (parse, crawl childNodes/children, cloneNode(true), querySelectorAll, getElementsByTagName, remove, outerHTML, innerHTML round trip on w3c.html and dom.html), half a real content-extraction pipeline on 14 full Shopify storefront pages (skip-link and main detection, contains, compareDocumentPosition, section cloning, ten removal passes by selector, outerHTML). test/benchmark/*.js loads one version per process, so using it for A/B would mean comparing two standalone runs, and run-to-run drift is larger than most effect sizes here. It was kept as an external cross-check, below.
  • gated by a calibrated noise floor (1.8% on the suite total, measured with identical code on both sides): changes under 3.5% total, or under 6% on a targeted case, were discarded, and every keep required a second confirming run.
  • verified behaviour-preserving by a characterisation guard (hashes of the serialized output, query results, text content and document-order answers of all 14 workloads) plus npm test, both green after every commit. Coverage stays at 100% lines and branches.

The numbers below are fresh verification runs of this branch alone against main (two independent runs; an identical-source control run measured +0.56% total, i.e. noise). Negative = faster.

What this PR does

DOMEventTarget's constructor ran wm.set(this, new Map). Since Node extends it, every element, text node, comment and attribute allocated an empty Map plus a WeakMap entry, although almost no node in a parsed document ever gets a listener. Both cost memory, and the WeakMap entries are ephemerons, which the garbage collector has to trace separately.

The map is now created when the first listener is added. dispatchEvent already handled a missing entry (invokeListeners starts with const map = wm.get(currentTarget); if (map && ...)), so only addEventListener (create on demand) and removeEventListener (skip when there is no map) needed a line each.

This is the single largest effect of the campaign, and it is mostly a garbage-collection effect: in a profile of the extraction workload, GC went from 29% to 6% of samples.

Verification (this branch vs main)

case run 1 run 2 speed-up vs main
clone-deep (documentElement.cloneNode(true)) -69.6% -70.3% 3.3x
bench-dom (full content.js sequence on dom.html) -44.1% -41.6% 1.75x
bench-w3c (same sequence on w3c.html) -37.7% -36.2% 1.59x
parse-shop (parse 2.7 MB of storefront HTML) -19.2% -19.2% 1.24x
extract-pages (extraction pipeline, /pages/*) -22.0% -21.0% 1.27x
extract-products -15.8% -14.6% 1.18x
crawl +0.4% -1.2% n/a (noise)
serialize -1.9% -0.1% n/a (noise)
query-simple +14.1% +16.5% see below
suite TOTAL -34.8% -33.6% 1.52x

Retained heap per parsed w3c.html document, measured by allocating 300 documents and forcing a collection: 675,278 -> 300,888 bytes, -55.4% (this metric is allocation-counting, so it is stable to a fraction of a byte).

query-simple (querySelectorAll('div'), getElementsByTagName('p'), getElementsByClassName) reports a regression in the A/B harness, but it is an artefact of loading two module instances into one process: measured standalone, one process per revision, the same workload is equal or faster after the change (13.6 / 16.9 / 12.4 ms before, 10.9 / 11.9 / 14.0 ms after, three runs each). The repo's own benchmark below also shows querySelectorAll('div') and getElementsByTagName('p') unchanged.

External cross-check with the repo's own npm run benchmark:html (the 12 MB page), two runs per side. These are standalone runs, not paired, so they carry run-to-run drift; the effects here are far larger than that drift.

phase main this branch
parsing 767 / 711 ms 227 / 215 ms
heap after parse 320.7 / 320.7 MB 128.3 / 134.2 MB
html.cloneNode(true) 591 / 527 ms 82 / 80 ms
html.innerHTML round trip 472 / 496 ms 211 / 235 ms
querySelectorAll('div') 20 / 19 ms 20 / 19 ms
total benchmark time 4.23 / 4.09 s 2.40 / 2.47 s

Observable surface

  • removeEventListener on a target that never had a listener now returns without touching a map. Before it read an empty map and found nothing: same result, no throw in either version.
  • The WeakMap no longer holds an entry for every node, only for targets that have had a listener added. The map is module-private and not reachable from user code, so this is visible only as lower memory.
  • The listener map is never deleted again once created, exactly as before: removeEventListener removes the type entry, not the map.
  • addEventListener / removeEventListener / dispatchEvent semantics, including once, capture/bubbling order and handleEvent objects, are untouched.
  • No new own properties on nodes, no property-descriptor or enumerability change, no new dependency, no change to the worker build.
  • DOMEventTarget has no constructor any more, so EventTarget.prototype.constructor is now the implicit one. new EventTarget keeps working, instanceof is unchanged.

Reproducing the numbers

git clone https://github.com/WebReflection/linkedom && cd linkedom && npm ci
git fetch origin pull/335/head:pr-335

The harness lives on the campaign branch of my fork: zirkelc/linkedom@perf/autoresearch. It holds perf/*.mts (A/B harness, characterisation guard, memory harness, profiler, the 14 cases), perf/plan.md (method, calibration, every experiment and why it was kept or discarded) and perf/experiments.tsv (the log, including the 10 discarded experiments and their numbers).

git remote add campaign https://github.com/zirkelc/linkedom && git fetch campaign
git checkout campaign/perf/autoresearch -- perf && git reset -- perf
perf/fetch-fixtures.sh                # 14 public storefront pages, ~13 MB, not committed
node perf/guard.mts --update          # record the current behaviour of your checkout
node perf/ab.mts main main            # noise control: expect well under 2% on TOTAL
node perf/ab.mts main pr-335          # the measurement, twice

One run takes about 4.5 minutes on an idle machine. Judge a case only when both runs agree; the suite total is the headline number, and GEOMEAN weights every case equally. The fixtures are live pages, so a fresh download changes the absolute milliseconds (not the ratios); perf/fetch-fixtures.sh lists the URLs.

npm run benchmark:html on each side reproduces the external cross-check.


Companion PRs from the same campaign, independent of each other and of this one:

Stacked, the four together measure 2.02x on the suite total and -62% on retained heap per document; on npm run benchmark:html (the 12 MB page) parsing goes from 767/711 ms to 259/235 ms, cloneNode(true) from 591/527 ms to 84/85 ms and the total benchmark time from 4.23/4.09 s to 2.43/2.41 s. Each PR can be taken or left on its own; the numbers in each body are that branch measured alone against main.

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.

1 participant