Skip to content

Performance: Render Bench Harness - #93

Merged
jhweir merged 6 commits into
devfrom
perf/render-bench-harness
Jul 21, 2026
Merged

Performance: Render Bench Harness#93
jhweir merged 6 commits into
devfrom
perf/render-bench-harness

Conversation

@jhweir

@jhweir jhweir commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Move the benchmarks out of the app, and publish what they measure

Summary

WE had no trustworthy figure for what its template system costs to render, relative either to Solid
or to raw DOM. Producing one turned out to require fixing the measurement first: the benchmark suite
lived inside the app it was measuring, and the app kept contaminating the results. An animating
spinner inflated every figure in proportion to DOM size. The results panel rendered inside the
measured route and so measured itself. Switching fixtures went through the router — which will not
re-render the route you are already on — so repeat samples needed an /idle route to bounce
through, and a click handler that navigated could silently pre-empt the sampler and hang a run on
its first sample. None of those are bugs to fix one at a time; they follow from where the suite was
mounted.

This branch moves the benchmarks into a standalone app where a fixture is a value rather than a
route, adds a ladder of hand-written controls so each layer's cost can be attributed rather than
guessed at, enforces the ladder's equivalence in CI, and publishes the resulting measurements as
docs/architecture/performance.md. No shipped runtime behaviour changes beyond the removal of
the in-app benchmark route itself — the one design-system optimisation this work produced was
already merged separately in #92.

The headline: WE templates cost ~25% more JavaScript work than hand-writing the same page with
the same components, and 3.4× a hand-rolled DOM page that has none of the design system's
theming, states, encapsulation or accessibility. 84% of the full-stack cost is the design system
rather than the template layer.

Changes

New — apps/playgrounds/solid/render-bench

A standalone app with no AD4M, no stores, no app shell and no embedded apps. None of those are things
the renderer depends on, and a team adopting WE brings their own shell rather than ours — so the
harness doubles as a portability guard: it cannot build if the renderer or design system ever
acquires an AD4M dependency.

  • src/runner.ts — phases, median-of-N, timeRender, timeUpdates. Documents at length why
    there is no router, because that was the source of most of the old harness's failure modes. Paint
    is bracketed by a double requestAnimationFrame and therefore cannot report less than one frame
    interval; the doc comment says so, so the number is not read as work.
  • src/controls.tsx — the hand-written rungs: raw DOM, plain Solid, Solid over design-system
    components, and the same again binding props as DOM properties rather than attributes. That last
    rung exists so the comparison is not open to the charge that its control was written badly.
    RawDomCards returns a real element rather than JSX so construction lands in the measured Build
    phase instead of being charged to Mount.
  • src/fixtures.ts — both ladders. The realistic one (50 feed posts, seven component types,
    three levels of nesting, per-card varying content) exists because attribution derived from trivial
    uniform cards is fairly open to the charge that it does not generalise.
  • tests/ladder.test.tsx — asserts every rung renders identical content and mounts identical
    custom elements. The schema fixtures and the hand-written controls are separate implementations, so
    a silent divergence would invalidate every published ratio while still looking entirely plausible.
    Runs in CI as a correctness test.
  • bench/headless.bench.tsx — the previous package's benchmarks, moved across with their
    standing explicitly downgraded. happy-dom has no layout engine and overstates flush by ~2.7×, so a
    regression there means stop, a win there is only a hypothesis, and no figure from that file is ever
    published. Kept out of CI via a separate vitest.bench.config.ts, because timings on shared
    runners are noise and must not decide whether a merge is allowed.

Removed — the in-app benchmark suite

SchemaBenchmark.schema.ts, BenchmarkTimer.tsx, its registry entries, and ~400 lines from
testStore.ts. Removing the suite left createTestStore's navigate parameter dead — its only use
was the /idle bouncing described above — so it goes too, along with its argument at the single call
site, which in turn stranded an unused shellRouteStore binding.

SchemaTests.schema.ts gains a note recording why the Tokens, Mutations, Queries and Routing tests
deliberately stay in-app: the environment that corrupted the timings is the same environment that
validates those.

Removed — packages/schema-system/benchmarks

Superseded by the harness above; setProperty.probe.ts and the tsconfig move across (git tracks both
as renames).

SchemaRenderer.tsx — a measured dead end, recorded in place

Comment only. Consolidating the per-prop memos into one shared memo per node is the obvious
optimisation for a node whose props are all static literals. It was tried and measured slower
~+6% headless, worse in a browser — because the per-read indirection and object allocation outweighed
every memo removed. That result is invisible in the code, so the next reader would have the same idea
and spend the same time disproving it. The one untried direction (resolving static props at
template-install time) is noted alongside.

docs/architecture/performance.md

The deliverable, indexed in docs/README.md. Structured to answer the reader's questions in the
order they ask them: how much slower, will users notice, what does it buy, where does the cost go.

Three things it is deliberately careful about:

  • ~33ms of every total is the frame floor, not work. A double requestAnimationFrame on a 60 Hz
    display spans up to two frame intervals — 33.3ms predicted, 33.2ms measured against a two-element
    fixture. Totals are therefore compressed and JS work is the figure that scales. An earlier reading
    of an update burst at "33ms" was this floor; the real cost is 0.2ms.
  • Which estimator is quoted. Dividing the median JS-work figures gives +32% / +19%; the per-run
    medians used throughout give +25% / +16%. Both appear in the document, so it states both and
    defends the paired per-run figure rather than leaving a reviewer to find the contradiction.
  • What it does not establish. Fast desktop only, viewport uncontrolled, two list-shaped fixtures,
    no comparison against React or Vue, and the design-system rungs — the denominator of every tax
    figure — are the least stable rows in the suite.

It also records two findings it cannot explain (templates flush faster than hand-written code; the
prop: advantage appears on one fixture and not the other) and flags them as not citable. A
recommendation to expose prop: variants in the generated types was withdrawn once it failed to
replicate on realistic content.

Known follow-ups

  • Findings 3 and 5 are unexplained. They may share a cause — both anomalies are much larger on
    the simple ladder than the realistic one.
  • ~80 non-state design-system props are registered for attribute reflection where roughly seven
    need it.
    Identified during this work, unimplemented; the largest remaining design-system lever.
  • No test infrastructure in the design-system packages. tests/ladder.test.tsx covers the
    rendering contract from outside, but the primitives themselves have no unit tests to extend.
  • The remaining app-framework test schemas load eagerly. Not a correctness issue, but they are
    the reason the shell's initial parse is larger than it needs to be.
  • Three pre-existing lint errors in packages/app-framework (unused QueryAdapter/ModelClass,
    one import-sort). Present on dev before this branch — confirmed by stashing — and left alone
    rather than folded into unrelated commits.

Test plan

Verified on this branch:

  • pnpm -r build — clean
  • pnpm -r typecheck — clean
  • @we/playground-render-bench — 11 tests passing (ladder equivalence + setProperty)
  • @we/schema-shared — 448 passing
  • @we/schema-solid — 38 passing
  • @we/playground-portable-slice — 7 passing
  • 13 schemas validate
  • prettier --check clean on all touched files
  • eslint — no new errors; the 3 remaining confirmed pre-existing by stashing and re-running
  • Figures collected from three consecutive runs of a production build (vite build +
    preview) in an incognito Chrome 148 window, kept focused throughout
  • Every derived figure in the doc re-checked arithmetically against the raw-data tables (16
    ratios and differences)
  • Countable claims re-verified against source rather than prose: component types per fixture (7
    and 3), prop and attribute-binding counts, we-* call sites, non-state design-system prop count

Not verified:

  • Low-end or thermally constrained hardware, or any browser other than Chrome — the doc states this
    as a limitation rather than implying the ratios travel
  • The app-framework shell was not manually exercised after the benchmark route's removal; the
    remaining test schemas are covered by typecheck and schema validation only

jhweir and others added 6 commits July 21, 2026 15:25
The benchmark suite lived inside the app it was measuring, and the app kept
contaminating the measurements. An animating spinner inflated every figure in
proportion to DOM size; the results panel rendered inside the measured route and
so measured itself; switching fixtures went through the router, which will not
re-render the route you are already on, so repeat samples needed an "/idle"
route to bounce through and a click handler that navigated could silently
pre-empt the sampler and hang a run on sample 1.

Those are not bugs to fix one at a time — they follow from where the suite was
mounted. It now lives in apps/playgrounds/solid/render-bench, where a fixture is
a value rather than a route.

Removing the suite leaves createTestStore's `navigate` parameter dead — its only
use was the "/idle" bouncing above — so it goes too, along with its argument at
the one call site.

The Tokens, Mutations, Queries and Routing tests deliberately stay: the environment
that corrupted the timings is the same environment that validates those. The
principle is recorded in SchemaTests.schema.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ess app

packages/schema-system/benchmarks ran headless against happy-dom. That is the
right tool for a fast regression filter, but it cannot answer the question the
benchmarks were being asked. happy-dom has no layout engine, so paint is
invisible, and its JS CSSOM implementation overstates flush by roughly 2.7x
versus a real browser — a change that looked like +6% there measured +160% in a
browser, because the cost lived in what the per-prop effects went on to do
rather than in the walk itself.

apps/playgrounds/solid/render-bench keeps both. The headless benchmarks move
across unchanged as the fast filter, with their standing explicitly downgraded:
a regression there means stop, a win there is only a hypothesis, and no figure
from that file is ever published. Alongside them is a browser harness that
measures build, flush and paint against a real layout engine and reports the
median of five warm samples.

What the harness adds over what it replaces:

- A ladder of rungs rendering identical output through progressively fewer
  layers — raw DOM, plain Solid, Solid over design-system components, and the
  same again binding props as DOM properties rather than attributes. The
  difference between adjacent rungs is that layer's cost.
- tests/ladder.test.tsx, which asserts the rungs stay equivalent. A silent
  divergence would make every published ratio wrong while still looking
  plausible, so this runs in CI as a correctness test.
- A second ladder on page-shaped content — 50 feed posts, five component types,
  three levels of nesting — because attribution derived from trivial uniform
  cards is fairly open to the charge that it does not generalise.
- A `minimal` fixture that measures the frame floor rather than leaving it to be
  guessed at. Paint is bracketed by a double requestAnimationFrame and so cannot
  report less than one frame interval of waiting.

Benchmarks are kept out of CI (vitest.bench.config.ts, separate from
vitest.config.ts) — timings on shared runners are noise and must not decide
whether a merge is allowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One memo per prop looks wasteful for a node whose props are all static literals,
and consolidating them into a single shared memo per node is the obvious fix.
It was tried and measured slower — roughly +6% headless and worse in a browser,
because the per-read indirection and the object allocation it introduced
outweighed every memo it removed.

That result is invisible in the code, so the next person to read this loop will
have the same idea and spend the same time disproving it. Recorded in place,
along with the one direction that has not been tried: resolving static props at
template-install time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prompted by a colleague asking how much more WE costs to run relative to Solid
and raw DOM. Answering that honestly needed more than a ratio, because the
obvious way to measure it is misleading in both directions.

Headline: WE templates cost about 25% more JavaScript work than hand-writing the
same page with the same components, and about 3.4x a hand-rolled DOM page that
has none of the design system's theming, states, encapsulation or accessibility.
Four fifths of the full stack cost is the design system rather than the template
layer — so a team weighing adoption is mostly weighing the design system, which
they would also pay for by hand.

The report leads with the whole-stack comparison, because that is what a reader
evaluating adoption actually wants, and treats the design-system/template split
as supporting attribution rather than the opening claim.

Two things it is careful about:

- Roughly 33ms of every total is the frame floor, not work. A double
  requestAnimationFrame on a 60Hz display spans up to two frame intervals —
  33.3ms predicted, 33.2ms measured against a two-element fixture. Totals are
  therefore compressed and JS work is the figure that scales. An earlier reading
  of an update burst at "33ms" was this floor; the real cost is 0.2ms.
- Every figure is a median of three runs in a clean browser profile, with the
  hardware stated, run-to-run ranges given rather than single numbers, and the
  noisiest rows flagged as such.

It also records what the measurements do not explain — templates flushing faster
than hand-written components, and a property-versus-attribute binding gap that
moves with the fixture. A recommendation about the latter was withdrawn once it
failed to reproduce on realistic content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The report claimed the design-system layer "moves a great deal with content",
citing 65.0ms on the simple ladder against 36.9ms on the realistic one. That
compared raw milliseconds across fixtures with different element counts — 1,201
against 801 — and read a size difference as a content one. Worse, it invited the
reasonable reaction that the simpler fixture was somehow costing more.

Normalised, the two nearly agree, and the two available normalisations disagree
in direction: the simple fixture is 17% more expensive per element and 14%
cheaper per prop. That is the signature of no real per-unit effect, the fixtures
simply differing in both element count and props per element. Both residuals are
inside the 17–27% run-to-run spread of these particular rows.

Re-measuring would not have helped — the gap is deterministic, not noise, and
reproduces at any sample count.

Now states the size caveat before quoting the two totals rather than after,
carries element counts in the layer table so the difference is visible at a
glance, and adds a normalised per-element/per-prop table.

The design-system-to-template ratio is also now given as 5.3x and 3.1x rather
than "3–5x", which clipped the top of its own range, and is labelled a property
of the pages measured rather than a constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e report

Verified every countable claim against the source rather than trusting the prose.
Eight were wrong or unclear:

- Component-type counts were understated. The realistic fixture uses seven
  component types and the simple one three, not "five" and "two". Counted from
  the hand-written controls.
- The +25% headline sat directly above a table of the simple fixture, from which
  a reader divides +19%. The 25% is the realistic feed's figure; the summary now
  says so before the table rather than leaving the mismatch to be discovered.
- Two ways of computing the tax were being mixed silently. Dividing the median
  JS-work figures gives +32% realistic and +19% simple; the per-run medians
  quoted throughout give +25% and +16%. Both appear in the document and a
  reviewer would find the contradiction immediately. Finding 4 now states both,
  and why the paired per-run figure is the one used — each run compares rungs
  under the same session and thermal state, whereas dividing medians combines
  runs and an unstable denominator inflates the result.
- The realistic ladder's design-system figure subtracts the simple ladder's
  0.8ms Solid baseline, because that ladder has no plain-Solid rung. Small at
  this size, but it is an approximation and was presented as a measurement.
- The normalisation table's prop counts (4,000 / 1,950) and finding 3's binding
  counts (2,400 / 1,450) describe the same fixtures and were unreconciled. The
  latter is the subset landing on custom elements as HTML attributes; now said.
- "653 call sites" could not be reproduced — the scope it was counted under is
  lost. Replaced with over 800 we-* call sites, which a grep reproduces.
- "78 design-system props" is 79 non-state props, counted from the key arrays
  with spreads resolved.
- "Four fifths" was used for two different quantities: flush as a share of the
  design system's cost (81%, correct) and the design system as a share of the
  whole stack (84%, understated). The latter is now given as a percentage.

All derived figures re-checked arithmetically and reconciled with the raw-data
tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit e69293a
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a5f85e6cc9f840008b161c8
😎 Deploy Preview https://deploy-preview-93--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir
jhweir merged commit 128ed93 into dev Jul 21, 2026
3 of 5 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.

1 participant