Skip to content

Raise CACHE_LIMIT to 4096 - #186

Merged
jdalton merged 4 commits into
dperini:masterfrom
jdalton:perf/cache-limit
Sep 7, 2026
Merged

jdalton merged 4 commits into
dperini:masterfrom
jdalton:perf/cache-limit

Conversation

@jdalton

@jdalton jdalton commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Tooling update

Rebased onto merged #212 as one commit. Sources and tests use .mts, Node tests use Vitest, and the published JavaScript paths stay unchanged. The original engine changes are preserved.

Local lint, formatting, Node and packed-package checks pass. All 41 WPT pages pass against both generated builds.

Change

A working set that fits costs the same at any limit, one that fits 4096 and not 1000 is worth 12x, and 8192 buys nothing further while doubling the worst-case heap. 4096 is also what the fork of this engine inside jsdom uses.

Detail, and how it was checked

Sweeping the limit over three workloads, with retained heap alongside throughput, because the question is a trade rather than a maximum:

workload 1000 2048 4096 8192
30 selectors, all hit 26.18 us 26.93 us 26.80 us 27.27 us
2000 selectors 50.75 ms 50.36 ms 4.12 ms 4.15 ms
heap, cache full 2.99 mb 3.86 mb 6.87 mb 14.05 mb

Read it a row at a time. A working set small enough to fit costs the same at every limit, which is the first row. A working set that fits in 4096 entries but not in 1000 is the second row, where raising the limit is worth 12x. Going on to 8192 buys nothing further and doubles the retained heap, and only a caller holding that many distinct selectors ever reaches that heap, because the caches grow as they fill rather than being allocated up front. 4096 is also what the fork of this engine inside jsdom settled on, in @asamuzakjp/dom-selector 8.3.0, which sets CACHE_SIZE = 4096.

The cliff sits between 2048 and 4096 for a set of 2000 selectors because a selector is not one cache entry: :not(.x) compiles to a run-time s.match(.x, e), so the argument takes an entry of its own. 100 such selectors leave 200 entries in the match caches, measured.

4096 is also the value the fork of this engine inside jsdom's current selector implementation uses.

Extracted from #167 as a standalone change: one file, applies to master on its own, and checked against the benchmark fixture to confirm every selector still agrees with the native engine.

References: the spec, the browser source, and what each part was reasoned from

This patch applies to master on its own. The sixteen in this series were checked by cherry-picking them onto master one after another, in this order and in reverse, and all sixteen land without a conflict.

@jdalton
jdalton force-pushed the perf/cache-limit branch 2 times, most recently from a412975 to 55addec Compare September 5, 2026 02:48
@jdalton

jdalton commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

This one sits with #185 and #188, which touch the resolver caches.

They do not depend on each other. All seventeen in the series cherry-pick onto master in any order, and I checked that in both directions, so any one of these can land alone. The order below is the one they read best in:

  • #185 makes eviction cheap, by replacing the LRU that walked tombstones with two generations.
  • #186 then raises the limit to 4096, which is worth doing once eviction no longer costs per insertion.
  • #188 caches the query plan rather than the answer, so a cache entry stops retaining DOM.

Sweeping the limit over three workloads, with retained heap alongside throughput, because the question is a trade rather than a maximum:
 workload 1000 2048 4096 8192 30 selectors, all hit 26.18us 26.93us 26.80us 27.27us 2000 selectors 50.75ms 50.36ms 4.12ms 4.15ms heap, cache full 2.99mb 3.86mb 6.87mb 14.05mb
 A working set that fits costs the same at any limit. One that fits 4096 and not 1000 is worth 12x. 8192 buys nothing further and doubles the worst case, which is only reached by a caller that has that many distinct selectors, since the caches grow lazily.
 The cliff sits between 2048 and 4096 for a set of 2000 selectors because a selector is not one cache entry: ':not(.x)' compiles to a run-time s.match('.x', e), so the argument takes an entry of its own. 100 such selectors leave 200 entries in the match caches, measured.
 4096 is also the value the fork of this engine inside jsdom's current selector implementation uses.

References:

- MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
- Reading: https://zod.dev/blog/reducing-memory-footprint — why a cache size is measured rather than chosen
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant