Skip to content

perf(ranui): build an SSR element in 0.034µs instead of 1.389µs - #407

Merged
chaxus merged 1 commit into
mainfrom
perf/builder-lazy-mock-fields
Sep 13, 2026
Merged

chaxus merged 1 commit into
mainfrom
perf/builder-lazy-mock-fields

Conversation

@chaxus

@chaxus chaxus commented Sep 13, 2026

Copy link
Copy Markdown
Owner

HTMLElementMock held style and classList as instance fields containing object literals of arrow functions. An arrow function closes over this, so it cannot live on the prototype: every element allocated two objects and six closures in its constructor, plus a Map for event listeners and another for inline styles — whether or not anything ever touched them. Almost nothing does. A generated page is overwhelmingly elements with a class attribute and a text child.

They are created on first access now; inlineStyles and eventListeners on first write.

Measured

new HTMLElementMock('div') plus two attributes:

before after
construct one element 1.389 µs 0.034 µs 41x

End to end — building and serialising a realistic 209-node sidebar 1,393 times, the docs site's page count:

before after
total 710 ms 241 ms 2.9x
…of which build 494 ms 80 ms 6.2x
…of which serialise 216 ms 156 ms —

The bottleneck is now serialisation, which is the work that actually has to happen. Output is byte-identical before and after, checked against the equivalent string-template rendering.

How it was found

Worth recording, because my first two explanations were wrong.

I said the cost was "five objects and two Maps per node". A standalone class of exactly that shape measures 0.060 µs — 3.5% of it. Bisecting each chained call showed new HTMLElementMock() alone accounted for 93%, with .class(), .attr() and .text() adding a tenth of a microsecond between them. Only then did reading the constructor make the closures obvious.

Safety

  • inlineStyles is now a ReadonlyMap. The empty map is shared by every element that never set a style, so writing through it would leak one element's styles into all of them. Nothing outside the mock writes to it, and the existing tests read it with .get() and .has(), which the read-only type allows.
  • Nothing enumerates an element's own properties — the one thing that would notice a field becoming a getter. Checked across utils/, components/, src/ and test/.
  • 1,732 unit tests and 254 SSR tests pass unchanged.

Scope

This is the SSR path only. In a browser the builder wraps real elements from document.createElement, where style and classList are native. A component costs ~15.6 µs to construct there against 0.85 µs for a plain element — a separate question this does not touch.

🤖 Generated with Claude Code

https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d

`HTMLElementMock` held `style` and `classList` as instance fields containing
object literals of arrow functions. An arrow function closes over `this`, so it
cannot live on the prototype: every element allocated two objects and six
closures in its constructor, plus a Map for event listeners and another for
inline styles — whether or not anything ever touched them. Almost nothing does.
A generated page is overwhelmingly elements with a class attribute and a text
child.

They are created on first access now, and `inlineStyles` and `eventListeners`
on first write.

Measured, `new HTMLElementMock('div')` plus two attributes:

    before  1.389 µs      after  0.034 µs      41x

End to end, building and serialising a realistic 209-node sidebar 1,393 times
— the docs site's page count:

    before  710 ms        after  241 ms        2.9x
    of which build        494 → 80 ms          6.2x

The bottleneck is now serialisation, which is the work that actually has to
happen. Output is byte-identical before and after, checked against the
equivalent string-template rendering.

How this was found is worth recording, because my first two explanations were
wrong. I said the cost was "five objects and two Maps per node"; a standalone
class of exactly that shape measures 0.060 µs, or 3.5% of it. Bisecting each
chained call showed `new HTMLElementMock()` alone accounted for 93%, with
`.class()`, `.attr()` and `.text()` adding a tenth of a microsecond between
them. Only then did reading the constructor make the closures obvious.

`inlineStyles` is now a `ReadonlyMap`: the empty map is shared by every element
that never set a style, so writing through it would leak one element's styles
into all of them. Nothing outside the mock writes to it, and the existing tests
read it with `.get()` and `.has()`, which the read-only type allows.

Nothing enumerates an element's own properties — the one thing that would have
noticed a field becoming a getter. 1,732 unit tests and 254 SSR tests pass
unchanged.

This is the SSR path only. In a browser the builder wraps real elements from
`document.createElement`, where `style` and `classList` are native; a component
costs ~15.6µs to construct there against 0.85µs for a plain element, which is a
separate question this does not touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pjSL6jGCw2wmBLhCAW35d
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying ran with  Cloudflare Pages  Cloudflare Pages

Latest commit: dbcd7d3
Status: ✅  Deploy successful!
Preview URL: https://2159074c.ran-4ty.pages.dev
Branch Preview URL: https://perf-builder-lazy-mock-field.ran-4ty.pages.dev

View logs

@chaxus
chaxus merged commit 530eb7d into main Sep 13, 2026
11 checks passed
@chaxus
chaxus deleted the perf/builder-lazy-mock-fields branch October 3, 2026 11:52
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