Performance
Where Roots stands against Vue 3.5 and against a field of other frameworks and signal libraries, how it is measured, what made the difference, and what is still slower. The owner's goal is 2–4× faster than Vue, and since round nine to be the fastest of the field where that can be had; this page reports the real numbers, including where a goal is missed and why.
Summary #
Measured for 0.3.3 on 11 November 2026 on one machine (an Intel Core i9-9900K Mac, see "Running the benchmarks"), which was doing other work the whole time: every table below that compares two builds therefore carries an A/A control, the same build measured twice in the same session, and a ratio inside the control's range is reported as no difference shown. The tables of 0.3.0 (10 November 2026, the same machine under more load) and the numbers of 0.1.4–0.2.1 (another machine, a 4-core Xeon on Linux) are kept, marked as such, where the history is told; none of them is comparable in milliseconds.
| Target | Result (0.3.3, the shipped bundle, this machine) | |
|---|---|---|
| Rendering script time vs Vue 3.5.44 (js-framework-benchmark operations) | 2–4× | Met: 3.26× and 3.35× geometric mean in two sessions (0.3.0: 2.89×), faster on all 9 operations: remove 16×, select 4.7–5.1×, swap 4.6–4.9×, partial update 3.6–4.0×, append 2.5–2.6×, create 10,000 2.5×, create 1,000 2.2–2.3×, replace 1.8×, clear 1.2× |
| Rendering script time vs the field (Vue Vapor 3.6 rc, Solid, Inferno, ivi, Svelte 5, lit-html, Preact) | lowest | Lowest on the geometric mean of the 9 operations: Vapor rc 1.08 and 1.12 behind, Inferno 1.38, Solid 1.38–1.40, ivi 1.51, Svelte 1.65, lit-html 1.76, Preact 6.94. Not lowest on select (Vapor 0.89–0.94 in three sessions). Level with Vapor on create, replace, partial update, create 10,000 and clear, and with Solid on create and create 10,000. Hand-written code with its row in a <template> is at 0.67–0.68 of Roots (create 1,000: 0.63–0.65) |
| Rendering totals (script + style + layout) | vs Vue 3.5 1.29× and 1.34×, faster on 9 of 9. In the field Vapor is ahead (0.96 in both sessions), Solid level (0.98, 1.01), Inferno 1.04, ivi 1.11, Svelte 1.15, lit-html 1.18, Preact 1.83. Style and layout are 80–97% of Roots' time on seven of the nine operations | |
| Rendering in Firefox 144 (two sessions) | script vs Vue 3.5 3.28×, vs Solid 1.08 and 1.14; behind Solid on replace (0.69, 0.84) and in totals (0.93, 0.94); totals of swap and remove 12–15% above the others' | |
Reactivity vs @vue/reactivity 3.5.44 | ≥ 2× geometric mean; no case slower | Mean missed: 1.70× (Roots loaded first) and 1.72× (Vue loaded first) over 30 cases (0.3.0: 1.39–1.41×); ≥ 2× on 5–7. No case is resolved slower any more: create 1to1000 is level (0.97 [0.83–1.34] over six processes, band 0.87–1.15), create 1to1 ahead by the median |
| Reactivity vs the field (Vue 3.6 rc, alien-signals, Preact signals, reactively, S, oby, Solid, Svelte 5, TC39 polyfill) | lowest | Lowest on the geometric mean of the 30 cases: Vue 3.6 rc 1.13 behind, Preact 1.30, alien 1.36, reactively 1.43, S 2.16, oby 2.92, Solid 4.93, Svelte 6.09, the polyfill 8.45; lowest time in 19 of 30 rows. Behind Vue 3.6 rc in five cases (0.75–0.95) and one outside the mean (0.83), behind alien in two (0.94, 0.96), behind reactively on wide dense (0.69) |
| Template patterns of real components vs Vue 3.5 (7 patterns × 4 operations, script) | 1.81× geometric mean in two sessions (1.82× in a third), 0.75–4.63 per cell. Three patterns are behind Vue: text/fill 0.81–0.86, attr/updateAll 0.81–0.85, if/updateAll 0.75–0.93 | |
| First render (bench app, from the entry script's request) | localhost 8.9 ms: Vue 3.5 1.85× later, Vapor rc 1.15×, Svelte level; Solid, Preact, lit-html, Inferno and ivi render in 0.54–0.61× of Roots' time (4.8–5.4 ms). 25 ms latency, 10 Mbit/s, gzip: 56.8 ms; Vue 3.5 1.96×, Vapor 1.09×, Svelte level, the five small ones 0.68–0.80× | |
| Memory after creating 1,000 rows | 2.08 MB JS heap (0.3.0: 2.28): below Solid 2.13, Svelte 2.44, Vapor 2.52, Preact 2.76, Vue 3.5 3.49; above Inferno 1.99, lit-html 1.81, ivi 1.67 (hand-written 1.24). 17.2 live JS objects and 760 B per row (0.3.0: 24.4 and 1,042) | |
| Code size as built (bench app) | 19.6 KB gzip, one file (57.3 KB raw), tree-shaken and minified by roots build; 18.4 KB with --error-codes. Svelte 20.1, Vapor 28.7, Vue 3.5 43.7 KB; Inferno 10.2, Solid 6.8, Preact 6.4, lit-html 5.1, ivi 4.4 KB | |
| vs Roots 0.3.0 | Reactive core 1.20× (geometric mean of 30 cases, 1.192–1.219 over 4 processes; A/A 0.986–1.005; 26 cases faster, one slower: dynamic: simple component (pull) 0.97). Rendering script 1.20× and 1.21× (A/A 0.99, 1.01): create 1.29–1.40×, swap 1.45–1.61×, remove 1.19–1.35×, replace 1.14–1.16×; partial update, select and clear not resolved; totals 1.01–1.03×: no difference shown. Widget mounts 1.13× by the median, template patterns 1.07–1.09×. First render: no difference shown. Bundle 59.3 → 57.3 KB raw, 19.3 → 19.6 KB gzip |
Running the benchmarks #
Each is one command and prints a Markdown table (each builds what it measures first); runtime/bench/README.md describes every tool and its options.
node runtime/bench/run-reactivity.mjs # [--samples 21] [--warmup 5] [--order roots,vue|vue,roots] [--json out.json]
node runtime/bench/run-reactivity.mjs --libs roots,aa,vue,vue36,alien,preact # the field: also solid, svelte, reactively, tc39, s, oby
node runtime/bench/run-render.mjs # [--runs 15] [--warmup 3] [--baseline <checkout>] [--aa] [--json out.json]
node runtime/bench/run-render.mjs --frameworks roots,aa,vanilla,solid,vapor # the field: also svelte, ivi, inferno, preact, litfix, vue, vanillatpl
node runtime/bench/ab-reactivity.mjs --variants base=<checkout>,new=.,vue=@vue # [--processes 4]
node runtime/bench/run-patterns.mjs # [--baseline <checkout>] [--variants roots,aa,base,vue,lc]
node runtime/bench/run-widgets.mjs # [--baseline <checkout>] [--heap]
node runtime/bench/run-startup.mjs # [--app examples/todo] [--latency 25 --mbps 10 --gzip]
node runtime/bench/heap-objects.mjs # live objects per row, by constructor
node runtime/bench/count-dom.mjs # DOM calls per operationRun bin/roots install first: Vue, Vite, esbuild and the frameworks and signal libraries of the field are pinned development dependencies in package.json (Vue 3.5.44, the newest version the 7-day cooldown admitted; the tables of 0.1.4–0.2.1 were measured against 3.5.45). Roots itself has no npm dependencies.
Machine for the numbers below: Intel Core i9-9900K @ 3.60 GHz (8 cores, 16 threads), 64 GB, macOS 15.7; Node v24.13.1; Chromium 143.0.7499.4, Firefox 144 and WebKit 26 (Playwright 1.57, headless). The machine was not quiet. For the tables of 0.3.3 (11 November 2026) the 1-minute load average was between 4 and 8 on 16 threads, with other work running next to the benchmarks; for the tables of 0.3.0 (10 November 2026) it was between 5 and 27 (the rendering sessions started at 4.8 and 11.2, the reactivity runs at 27 and 10). Milliseconds of the two dates are not comparable (the build of 0.3.0 creates 1,000 rows in 5.8 ms of script on the later date and in 10.0 ms on the earlier one); each table says which it is. The runs were made one after another, never in parallel (since round nine through a machine-wide lock), with the scripts' defaults unless a table says otherwise. Ratios are more stable than times because all sides are measured interleaved in one session; how stable they were that day is what the A/A columns say.
Earlier machine (0.1.4–0.2.1): Intel Xeon @ 2.10 GHz, 4 cores, 16 GB, Linux; Node v22.23.4; Chromium 153.0.8010.12 (Playwright 1.63, headless), load average 0.2–0.5. Every number in this page that is attributed to rounds one to three comes from there.
The rounds #
| round | release | what it was |
|---|---|---|
| one | 0.1.4 | the reactive core rewritten (linked dependency nodes); static templates |
| two | 0.1.7 | computeds as their own node, event delegation, createSelector |
| three | 0.1.8 | 7-field effects; the scavenge before every sample |
| four | 0.3.0 | per-row work of the runtime and the DOM backend; the reactive core's flush and scopes |
| five | 0.3.0 | list items as scopes, slimmer render effects, untouched kept rows, key text written once |
| six | 0.3.0 | measurement only: real apps, kit widgets and startup instead of the benchmark row; the tools for it; the plan for round seven |
| seven | 0.3.0 | primitives in static templates, text runs, development checks off in production builds, lighter component instances, preload links and compile hints |
| eight | 0.3.0 | measurement only: the tables marked "0.3.0" below |
| nine | 0.3.1 | the field (nine more signal libraries, seven more frameworks behind the harness); the reactive core: strict null tests, fields assigned in the constructor, the onCleanup registrar in a field |
| ten | 0.3.3 | rendering: template prototypes in an inert document, the reconcile scan, lean helpers and shared row handlers, the selector binding; the minifier; --error-codes; the field measured again on the result |
Rounds nine and ten were planned from a comparison with other frameworks (the working notes call them rounds six and seven; this page counts on from eight).
Which build is measured #
roots build ships one bundle per entry, tree-shaken and minified, with DEV false (docs/build.md, docs/bundling.md). That is what every timing below loads (the rendering benchmark, startup's roots, patterns, widgets) and what the "as built" size rows describe. Until 0.2.1 the same command shipped one unminified file per module, and those are the files the tables of rounds one to three measured. The tools that attribute work to functions, files or constructors (heap-objects.mjs, count-dom.mjs, profile-render.mjs) build with --no-bundle --no-minify, for the names; the code is the same except that the bundle also removes the if (DEV) branches, which the unbundled modules keep and skip. The reactive core runs in Node, compiled from runtime/reactivity.lisp by the Roots compiler, unbundled and unminified, against @vue/reactivity's production build.
The A/A rule #
A difference between two builds is reported only from a session that also measured the same build twice, interleaved with the others. The control's range is what that session could not resolve; a ratio inside it is "no difference shown", whatever its value. Counts (DOM calls, heap objects, bytes) are exact and need no control.
| tool | the control | how its range is formed |
|---|---|---|
run-render.mjs --aa | a copy of the Roots build as one more page | aa/Roots per operation; the range over the operations |
run-patterns.mjs, run-widgets.mjs | the same, on by default | the largest deviation of aa/Roots over all cells, mirrored (a control of 0.76 gives the band 0.76–1.32) |
run-startup.mjs | a copy of the reference layout | aa/reference per block of 5 repetitions, mirrored |
ab-reactivity.mjs | the reference loaded a second time | min–max of reference/control over the processes, per case |
run-reactivity.mjs --libs …,aa,… | the same Roots build loaded a second time | aa/Roots and its inverse per case, the widest over the processes |
In Node the order in which the libraries are loaded is part of the result (the one loaded second finds the first one's maps and feedback in the heap; single cases move by several percent with it). A Roots-against-Roots difference of the reactive core is therefore reported only from at least 4 processes, half in each load order, with at most 7 variants per process, as min / median / max of the per-process ratio of medians (ab-reactivity.mjs), and the comparison with Vue is taken in both orders (run-reactivity.mjs --order). Both rules came out of round six, which chased a regression on update 1to1000 and dynamic: deep that turned out to be the load order.
Reactivity #
Method #
runtime/bench/run-reactivity.mjs compiles runtime/reactivity.lisp with the Roots compiler and loads @vue/reactivity's production build (reactivity.cjs.prod.js, what Node's production condition and bundlers pick; the development build is slower). The cases (reactivity-cases.mjs) follow the community js-reactivity-benchmark: the "kairo" cases, cellx, S.js-style create/update cases, random dynamic graphs (pull and effect driven), plus the four original Roots workloads. Each case is written once against the API both libraries share (shallowRef, computed, effect, .value), with no adapter closures in between, and each library gets its own instance of the case module so the cases' inline caches never mix the two libraries' objects.
Per sample: build the graph (untimed), run the timed work, tear the scope down (untimed). Samples alternate between the libraries after warm-up rounds; the heap is collected between cases, not between samples (a forced full GC between samples throws away JIT state and made every sample measure re-optimization: both libraries got ~3× slower and the ranking flipped). The young generation is emptied with a scavenge (gc({ type: "minor" }), which keeps all JIT state) before every sample, outside the timed part. Medians are reported, with half the interquartile range as spread, and every case returns a checksum that must match between the libraries. Vue 3.5 has no public batch, so no case batches writes.
The field (--libs, since round nine) runs the same cases against other signal libraries. A library that does not read and write through .value gets no wrapper object: the runner rewrites the source text of the cases into that library's own calls and loads the copy, so a read in a case is the call a user of that library writes.
| notation | read | write | libraries |
|---|---|---|---|
| value | x.value | x.value = e | Roots, Vue 3.5, Vue 3.6, Preact signals (the unchanged case file) |
| call | x() | x(e) | alien-signals, S, oby |
| get/set | x.get() | x.set(e) | Solid, reactively, the TC39 polyfill |
| functions | get(x) | set(x, e) | Svelte |
What an adapter still adds (lib/signal-libs.mjs): for Preact a call and an array push per effect, to collect the disposers it has no scope for; for Solid an object {get, set} per signal and createRenderEffect as the synchronous effect; for Svelte render_effect and a flushSync() after every write; for reactively a stabilize() after every write; for the polyfill an effect built from a Computed and its own Watcher, flushed after every write; for oby an arrow passing {sync: true}; for alien-signals, Vue 3.6 and S nothing. A process loads at most six libraries, the control included, with Roots and Vue 3.5 in every process, and every group runs once in each load order.
The scavenge dates from round three. Without it, the creation cases measured the sample order: they allocate 3–17 MB per sample, about one young generation per pair of samples, so a scavenge landed at the start of nearly every pair, in the sample of whichever library went first (traced with PerformanceObserver on the earlier machine: Roots 11 of 21 samples with a scavenge, Vue 10; with the order swapped, the medians swapped with it). Round two's creation medians (0.58–0.91×) were that artefact.
dynamic: simple component (pull) reads no leaf: its 20% draw selects none of the ten, so both libraries measure 200,000 writes to signals without subscribers. It keeps its name and its place in the table and in the geometric mean of 30 cases, so that rounds stay comparable; the case it was meant to be is dynamic: simple component, 2 leaves (pull), marked † in the tables and outside that mean. Two more cases outside the mean were added in round nine: create 10k effects declaring onCleanup and 100k writes, 1 effect declaring onCleanup, the first whose effect function declares a parameter. In Roots such an effect gets a registrar of its own (every component watchEffect is one); the other libraries pass nothing, so for them these are plain creation and plain writes.
Results #
Measured on 11 November 2026 on the tree of 0.3.3 (the reactive core is that of 0.3.1), load average 4.4–7.7. Every run went through a machine-wide lock, one at a time.
Against @vue/reactivity 3.5.44, 21 samples after 5 warm-up rounds, a scavenge before each, one process per load order (--order roots,vue and --order vue,roots). Roots loaded and sampled first, the 30 cases every round has measured:
| case | Roots ms | Vue ms | speedup (Vue/Roots) |
|---|---|---|---|
| avoidable propagation | 4.56 ±3% | 8.25 ±3% | 1.81× |
| broad propagation | 3.79 ±2% | 6.97 ±2% | 1.84× |
| deep propagation | 4.99 ±3% | 9.92 ±2% | 1.99× |
| diamond | 2.34 ±2% | 4.78 ±2% | 2.05× |
| mux | 4.69 ±1% | 5.91 ±2% | 1.26× |
| repeated observers | 2.67 ±4% | 4.00 ±2% | 1.50× |
| triangle | 3.77 ±9% | 8.27 ±6% | 2.19× |
| unstable | 1.25 ±6% | 1.97 ±1% | 1.58× |
| cellx 1000 layers | 25.7 ±3% | 43.7 ±4% | 1.70× |
| cellx 2500 layers | 43.2 ±8% | 75.2 ±4% | 1.74× |
| create 100000 signals | 2.22 ±1% | 3.11 ±3% | 1.40× |
| create 1to1 (10k effects) | 1.47 ±5% | 1.64 ±6% | 1.12× |
| create 1000to1 (100 effects) | 4.05 ±5% | 5.97 ±5% | 1.48× |
| create 1to1000 (10 sources) | 0.95 ±4% | 0.85 ±26% | 0.90× |
| create+dispose 10k ref/computed/effect | 2.04 ±4% | 2.90 ±2% | 1.42× |
| update 1to1 (1 source, 1 computed) | 11.5 ±4% | 22.9 ±3% | 1.99× |
| update 1to4 | 20.1 ±2% | 39.3 ±2% | 1.96× |
| update 1to1000 | 48.9 ±2% | 90.9 ±2% | 1.86× |
| update 4to1 | 15.8 ±1% | 35.2 ±1% | 2.23× |
| update 1000to1 | 20.0 ±2% | 56.9 ±3% | 2.85× |
| 100k writes, 1 effect | 5.37 ±3% | 7.97 ±2% | 1.49× |
| computed chain x100, 2k writes | 9.01 ±5% | 21.9 ±3% | 2.43× |
| 1000 computeds+effects, 100 writes | 9.82 ±3% | 18.1 ±2% | 1.84× |
| unobserved computed chain, write+read | 4.53 ±2% | 6.62 ±2% | 1.46× |
| dynamic: simple component (pull) | 2.58 ±2% | 3.19 ±2% | 1.24× |
| dynamic: dynamic component (pull) | 33.6 ±5% | 64.4 ±3% | 1.92× |
| dynamic: large web app (pull) | 84.4 ±4% | 145 ±3% | 1.72× |
| dynamic: wide dense (effects) | 187 ±6% | 365 ±5% | 1.95× |
| dynamic: deep (effects) | 35.7 ±1% | 64.7 ±1% | 1.81× |
| dynamic: very dynamic (effects) | 99.6 ±6% | 176 ±6% | 1.77× |
Both load orders, with the three cases added later (†), which are outside the geometric mean:
| case | Vue/Roots, Roots loaded first | Vue/Roots, Vue loaded first |
|---|---|---|
| avoidable propagation | 1.81× | 1.78× |
| broad propagation | 1.84× | 1.80× |
| deep propagation | 1.99× | 2.05× |
| diamond | 2.05× | 2.11× |
| mux | 1.26× | 1.28× |
| repeated observers | 1.50× | 1.44× |
| triangle | 2.19× | 2.05× |
| unstable | 1.58× | 1.52× |
| cellx 1000 layers | 1.70× | 1.86× |
| cellx 2500 layers | 1.74× | 2.00× |
| create 100000 signals | 1.40× | 1.41× |
| create 1to1 (10k effects) | 1.12× | 1.11× |
| create 1000to1 (100 effects) | 1.48× | 1.50× |
| create 1to1000 (10 sources) | 0.90× | 0.93× |
| create+dispose 10k ref/computed/effect | 1.42× | 1.42× |
| update 1to1 (1 source, 1 computed) | 1.99× | 1.92× |
| update 1to4 | 1.96× | 2.29× |
| update 1to1000 | 1.86× | 1.82× |
| update 4to1 | 2.23× | 2.40× |
| update 1000to1 | 2.85× | 2.84× |
| 100k writes, 1 effect | 1.49× | 1.57× |
| computed chain x100, 2k writes | 2.43× | 2.37× |
| 1000 computeds+effects, 100 writes | 1.84× | 1.79× |
| unobserved computed chain, write+read | 1.46× | 1.40× |
| dynamic: simple component (pull) | 1.24× | 1.24× |
| dynamic: dynamic component (pull) | 1.92× | 1.94× |
| dynamic: large web app (pull) | 1.72× | 1.87× |
| dynamic: wide dense (effects) | 1.95× | 1.88× |
| dynamic: deep (effects) | 1.81× | 1.79× |
| dynamic: very dynamic (effects) | 1.77× | 1.72× |
| dynamic: simple component, 2 leaves (pull) † | 1.50× | 1.49× |
| create 10k effects declaring onCleanup † | 0.96× | 0.93× |
| 100k writes, 1 effect declaring onCleanup † | 1.52× | 1.58× |
Geometric mean over the 30 cases: 1.70× with Roots loaded first (≥ 2×: 5; 1–2×: 24; below 1: 1) and 1.72× with Vue loaded first (≥ 2×: 7; 1–2×: 22; below 1: 1). The case below 1 is create 1to1000 in both (0.90× and 0.93×), next to a spread of ±11–26% on Vue's 0.84 ms; this tool has no control. In the six processes of the field runs below, which have one, it reads 0.97 [0.83–1.34] inside a band of 0.87–1.15: level with Vue, not resolved either way (0.3.0: 0.66–0.67×). create 1to1 is 1.11–1.12× here and 1.02 [0.87–1.40] in the six processes: level as well (0.3.0: 0.81–0.82×). The times of the two halves are not comparable with each other; the ratios are.
The field. The same cases against nine more libraries, each with its own notation (see "Method"): three groups of six (the control included, Roots and Vue 3.5 in every group), each group once in each load order, so six processes. Group A (Vue 3.6, alien-signals, Preact signals) 13 samples after 4 warm-up rounds; group C (TC39 polyfill, S, oby) 9 after 3; group B (Solid, Svelte, reactively) 7 after 2. Each cell is the median over the processes of library time / Roots time, then [min–max over the processes]: above 1, Roots is faster. Bold marks the fastest of the row. "A/A band" is the widest range the control (the same Roots build loaded a second time) took in any of the six processes; in group A alone it is 0.94–1.07 or narrower on 28 of the 33 cases and never wider than 0.86–1.17. Versions: @vue/reactivity 3.5.44; Vue 3.6 is 3.6.0-rc.14, a release candidate (its reactivity is a port of alien-signals); alien-signals 3.2.1; @preact/signals-core 1.14.5; solid-js 1.9.18; svelte 5.57.3; @reactively/core 0.0.8; signal-polyfill 0.2.2; S.js 0.4.9; oby 15.1.2.
| case | Roots ms | A/A band | Vue 3.5 | Vue 3.6 rc | alien | preact | Solid | Svelte | reactively | TC39 polyfill | S | oby |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| avoidable propagation | 3.93 | 0.88–1.14 | 1.82 [1.73–1.92] | 0.78 [0.75–0.81] | 0.94 [0.90–0.99] | 0.98 [0.95–1.02] | 4.32 [4.21–4.44] | 8.28 [8.13–8.42] | 1.65 [1.64–1.66] | 3.79 [3.71–3.86] | 2.90 [2.86–2.94] ⚠ | 2.37 [2.32–2.42] |
| broad propagation | 3.49 | 0.90–1.12 | 1.68 [1.57–1.75] | 1.09 [1.07–1.10] | 1.15 [1.13–1.17] | 1.28 [1.23–1.33] | 5.26 [5.20–5.31] | 5.84 [5.73–5.96] | 1.50 [1.50–1.50] | 5.55 [5.34–5.76] | 1.33 [1.25–1.41] | 3.15 [3.02–3.27] |
| deep propagation | 4.69 | 0.93–1.07 | 1.97 [1.81–2.49] | 0.95 [0.95–0.95] | 0.96 [0.94–0.98] | 1.41 [1.36–1.46] | 5.77 [5.72–5.81] | 6.57 [6.43–6.71] | 1.53 [1.51–1.55] | 4.70 [4.67–4.72] | 1.28 [1.25–1.32] | 3.72 [3.46–3.97] |
| diamond | 2.63 | 0.77–1.29 | 1.80 [1.61–2.10] | 1.02 [1.00–1.04] | 1.30 [1.28–1.33] | 1.36 [1.34–1.39] | 4.12 [3.97–4.26] | 5.38 [5.36–5.40] | 1.42 [1.39–1.45] | 3.91 [3.89–3.92] | 1.39 [1.31–1.46] | 2.47 [2.32–2.62] |
| mux | 4.09 | 0.94–1.06 | 1.25 [1.23–1.32] | 0.91 [0.88–0.94] | 0.94 [0.93–0.94] | 1.04 [1.00–1.08] | 2.18 [2.17–2.18] | 1.88 [1.88–1.89] | 1.04 [1.02–1.05] | 4.35 [4.14–4.56] | 1.53 [1.52–1.55] ⚠ | 1.59 [1.58–1.60] |
| repeated observers | 2.48 | 0.87–1.15 | 1.42 [1.39–1.55] | 1.06 [1.02–1.09] | 1.94 [1.88–2.01] | 1.00 [0.98–1.03] | 5.75 [5.40–6.09] | 6.97 [6.48–7.46] | 2.23 [2.13–2.33] | 13.09 [12.82–13.37] | 3.55 [3.44–3.67] | 4.32 [4.22–4.42] |
| triangle | 4.13 | 0.89–1.13 | 1.94 [1.66–2.17] | 1.11 [1.07–1.14] | 1.11 [1.08–1.14] | 1.53 [1.47–1.58] | 3.68 [3.58–3.78] | 5.61 [5.48–5.73] | 1.36 [1.34–1.39] | 5.93 [5.72–6.13] | 1.82 [1.70–1.94] | 3.27 [3.18–3.35] |
| unstable | 1.57 | 0.87–1.14 | 1.43 [1.07–1.58] | 1.28 [1.18–1.38] | 1.58 [1.51–1.64] | 1.03 [0.96–1.11] | 2.20 [1.99–2.41] | 3.18 [3.05–3.32] | 1.71 [1.56–1.85] | 18.73 [18.01–19.44] | 2.49 [2.48–2.51] | 2.86 [2.73–2.99] |
| cellx 1000 layers | 26.3 | 0.94–1.06 | 1.62 [1.58–1.94] | 1.05 [0.99–1.10] | 1.02 [1.00–1.04] | 1.23 [1.20–1.26] | 3.84 [3.78–3.90] | 13.83 [13.68–13.98] | 1.38 [1.32–1.44] | 11.06 [10.44–11.69] | 1.65 [1.60–1.70] | 2.88 [2.75–3.02] |
| cellx 2500 layers | 36.9 | 0.89–1.13 | 1.72 [1.59–1.95] | 1.10 [1.03–1.17] | 1.18 [1.17–1.19] | 1.18 [1.18–1.19] | 5.22 [5.21–5.23] | 8.70 [8.65–8.75] | 1.55 [1.50–1.61] | 12.18 [12.12–12.24] | 2.19 [2.11–2.26] | 4.02 [3.83–4.21] |
| create 100000 signals | 2.00 | 0.96–1.04 | 1.39 [1.35–1.44] | 1.09 [1.08–1.09] | 1.18 [1.17–1.19] | 1.05 [1.03–1.08] | 2.13 [2.10–2.16] | 1.01 [0.98–1.03] | 1.30 [1.27–1.33] | 15.84 [15.14–16.53] | 1.92 [1.90–1.95] | 2.38 [2.34–2.41] |
| create 1to1 (10k effects) | 1.93 | 0.73–1.37 | 1.02 [0.87–1.40] | 1.13 [1.13–1.14] | 1.28 [1.20–1.37] | 1.04 [0.97–1.12] | 1.18 [1.13–1.23] | 1.44 [1.30–1.58] | 0.86 [0.66–1.05] | 20.85 [19.81–21.88] | 1.32 [0.99–1.65] | 2.49 [2.35–2.63] |
| create 1000to1 (100 effects) | 4.83 | 0.86–1.16 | 1.36 [0.87–1.48] | 1.04 [1.02–1.07] | 1.31 [1.31–1.32] | 1.27 [1.27–1.27] | 1.80 [1.16–2.43] | 1.39 [0.96–1.81] | 0.87 [0.74–0.99] | 22.13 [21.93–22.33] | 1.44 [1.40–1.47] | 2.54 [2.32–2.75] |
| create 1to1000 (10 sources) | 0.98 | 0.87–1.15 | 0.97 [0.83–1.34] | 1.12 [1.09–1.15] | 1.12 [1.11–1.13] | 1.00 [0.97–1.03] | 1.51 [1.47–1.54] | 2.77 [2.77–2.77] | 0.88 [0.84–0.91] | 34.89 [33.78–35.99] | 1.07 [1.06–1.08] | 3.14 [3.05–3.24] |
| create+dispose 10k ref/computed/effect | 2.14 | 0.72–1.39 | 1.51 [1.29–1.69] | 1.10 [1.05–1.16] | 1.11 [1.08–1.14] | 1.42 [1.36–1.49] | 1.81 [1.30–2.32] | 2.60 [1.99–3.22] | 0.74 [0.65–0.82] | 28.19 [24.76–31.61] | 1.49 [1.44–1.53] | 4.35 [4.24–4.45] |
| update 1to1 (1 source, 1 computed) | 11.0 | 0.92–1.08 | 1.93 [1.82–1.93] | 1.31 [1.25–1.36] | 1.58 [1.54–1.62] | 1.32 [1.29–1.34] | 6.38 [6.36–6.40] | 19.03 [19.01–19.05] | 2.15 [2.14–2.16] | 6.65 [6.64–6.65] | 1.59 [1.55–1.63] | 3.64 [3.48–3.79] |
| update 1to4 | 17.0 | 0.91–1.10 | 2.09 [1.91–2.16] | 1.49 [1.45–1.52] | 1.73 [1.68–1.77] | 1.47 [1.43–1.51] | 5.46 [5.29–5.64] | 9.59 [9.27–9.91] | 1.80 [1.74–1.86] | 7.62 [7.53–7.72] | 1.69 [1.65–1.74] | 3.63 [3.60–3.67] |
| update 1to1000 | 43.7 | 0.86–1.17 | 1.83 [1.65–2.04] | 1.62 [1.57–1.67] | 1.77 [1.66–1.88] | 1.43 [1.37–1.49] | 4.67 [4.61–4.73] | 4.53 [4.45–4.61] | 1.30 [1.29–1.31] | 8.83 [8.33–9.33] | 1.38 [1.34–1.43] | 3.15 [3.08–3.21] |
| update 4to1 | 13.8 | 0.92–1.09 | 2.32 [2.16–2.50] | 1.31 [1.27–1.36] | 1.72 [1.68–1.76] | 1.66 [1.60–1.72] | 7.81 [7.53–8.10] | 14.54 [14.20–14.87] | 1.97 [1.87–2.06] | 8.44 [8.25–8.62] | 1.76 [1.76–1.76] | 3.29 [3.29–3.29] |
| update 1000to1 | 18.0 | 0.97–1.03 | 2.86 [2.82–3.04] | 1.12 [1.12–1.13] | 2.00 [1.96–2.04] | 2.53 [2.52–2.53] | 13.47 [13.14–13.81] | 2.67 [2.64–2.69] | 1.17 [1.17–1.17] | 13.88 [13.60–14.16] | 2.12 [2.09–2.14] | 3.76 [3.71–3.81] |
| 100k writes, 1 effect | 5.11 | 0.94–1.07 | 1.40 [1.19–1.46] | 1.16 [1.15–1.18] | 1.95 [1.94–1.95] | 1.18 [1.18–1.18] | 6.22 [5.86–6.59] | 32.57 [30.88–34.25] | 2.59 [2.42–2.77] | 8.61 [8.43–8.78] | 2.07 [2.06–2.08] | 3.13 [3.07–3.18] |
| computed chain x100, 2k writes | 8.23 | 0.96–1.04 | 2.58 [2.32–2.70] | 0.93 [0.92–0.94] | 1.13 [1.13–1.13] | 1.65 [1.64–1.66] | 7.10 [7.09–7.11] | 6.81 [6.80–6.82] | 1.83 [1.70–1.96] | 6.55 [6.44–6.67] | 1.39 [1.38–1.40] | 3.98 [3.96–4.00] |
| 1000 computeds+effects, 100 writes | 8.48 | 0.94–1.07 | 1.89 [1.82–2.24] | 1.63 [1.58–1.68] | 1.80 [1.77–1.82] | 1.42 [1.38–1.45] | 5.19 [5.07–5.30] | 4.63 [4.53–4.72] | 1.47 [1.47–1.47] | 9.24 [8.73–9.75] | 1.61 [1.55–1.67] | 3.20 [3.09–3.31] |
| unobserved computed chain, write+read | 3.95 | 0.96–1.04 | 1.78 [1.75–1.86] | 0.97 [0.93–1.01] | 1.23 [1.21–1.25] | 1.18 [1.15–1.20] | 7.51 [7.47–7.55] | 8.17 [8.02–8.32] | 1.68 [1.66–1.69] | 5.81 [5.76–5.86] | 1.52 [1.47–1.57] | 3.51 [3.50–3.52] |
| dynamic: simple component (pull) | 2.44 | 0.89–1.13 | 1.26 [1.09–1.43] | 0.75 [0.75–0.76] | 1.48 [1.45–1.52] | 1.01 [1.00–1.02] | 760.84 [715.94–805.75] | 119.27 [114.15–124.38] | 4.70 [4.68–4.73] | 5.85 [5.80–5.90] | 310.79 [304.47–317.11] | 2.43 [2.43–2.43] |
| dynamic: dynamic component (pull) | 27.6 | 0.92–1.09 | 1.98 [1.94–2.00] | 1.31 [1.29–1.33] | 1.52 [1.48–1.56] | 1.40 [1.38–1.43] | 6.37 [6.35–6.40] | not run | 1.61 [1.61–1.62] | 7.22 [7.03–7.40] | 3.06 [2.97–3.15] | 3.10 [3.09–3.10] |
| dynamic: large web app (pull) | 62.3 | 0.94–1.06 | 1.73 [1.58–1.81] | 1.39 [1.39–1.39] | 1.69 [1.67–1.70] | 1.21 [1.17–1.25] | 5.98 [5.44–6.53] | not run | 1.30 [1.27–1.33] | 4.94 [4.66–5.22] | 3.64 [3.61–3.68] | 2.48 [2.47–2.50] |
| dynamic: wide dense (effects) | 116.6 | 0.94–1.06 | 1.97 [1.92–2.15] | 1.19 [1.18–1.21] | 1.45 [1.37–1.53] | 1.62 [1.59–1.65] | 3.77 [3.70–3.84] | 2.53 [2.41–2.64] | 0.69 [0.65–0.72] | 3.53 [3.23–3.83] | 2.19 [2.07–2.32] | 1.35 [1.21–1.48] |
| dynamic: deep (effects) | 31.6 | 0.97–1.03 | 1.80 [1.76–1.90] | 1.13 [1.12–1.14] | 1.28 [1.27–1.29] | 1.40 [1.38–1.42] | 3.80 [3.66–3.94] | 13.12 [12.02–14.23] | 1.29 [1.28–1.29] | 5.02 [4.87–5.18] | 2.00 [1.99–2.01] | 2.37 [2.35–2.40] |
| dynamic: very dynamic (effects) | 85.4 | 0.94–1.07 | 1.76 [1.67–1.88] | 1.19 [1.15–1.22] | 1.42 [1.34–1.50] | 1.47 [1.45–1.50] | 3.79 [3.75–3.82] | 12.64 [12.48–12.80] | 1.08 [1.07–1.08] | 4.60 [4.57–4.64] | 2.16 [2.15–2.18] | 2.13 [2.13–2.14] |
| dynamic: simple component, 2 leaves (pull) † | 24.3 | 0.85–1.18 | 1.64 [1.28–1.78] | 0.83 [0.83–0.83] | 1.03 [1.03–1.04] | 1.15 [1.13–1.16] | 8.83 [8.40–9.27] | 5.47 [5.24–5.70] | 1.07 [0.99–1.14] | 6.54 [5.93–7.15] | 3.01 [2.67–3.34] | 1.95 [1.76–2.13] |
| create 10k effects declaring onCleanup † | 0.91 | 0.90–1.11 | 0.94 [0.87–1.34] | 1.17 [1.12–1.21] | 1.22 [1.19–1.26] | 1.00 [0.96–1.04] | 1.68 [1.62–1.75] | 6.26 [5.99–6.52] | 0.86 [0.82–0.90] | 49.43 [48.77–50.09] | 1.01 [0.90–1.11] | 4.20 [3.83–4.57] |
| 100k writes, 1 effect declaring onCleanup † | 5.09 | 0.79–1.26 | 1.49 [1.33–1.58] | 1.27 [1.24–1.31] | 2.06 [1.96–2.15] | 1.32 [1.31–1.32] | 6.68 [6.53–6.83] | 32.72 [30.47–34.97] | 2.62 [2.47–2.77] | 10.11 [10.10–10.13] | 2.10 [2.04–2.15] | 2.92 [2.87–2.97] |
| geometric mean (30 cases) | 1 | 1.69 | 1.13 | 1.36 | 1.30 | 4.93 | 6.09 (28) | 1.43 | 8.45 | 2.16 | 2.92 |
On the geometric mean of these 30 cases Roots is ahead of every library measured; the nearest is Vue 3.6 rc at 1.13 (both of its processes above 1.1), then Preact signals 1.30, alien-signals 1.36, reactively 1.43, Vue 3.5 1.69, S 2.16, oby 2.92, Solid 4.93, Svelte 6.09 (28 cases), the TC39 polyfill 8.45. Roots has the lowest time in 19 of the 30 rows. It is not the fastest in every case; where a library is ahead by more than its row's band:
| case | who | library / Roots | A/A band of its group |
|---|---|---|---|
| dynamic: simple component (pull) | Vue 3.6 rc | 0.75 [0.75–0.76] | 0.98–1.02 |
| avoidable propagation | Vue 3.6 rc | 0.78 [0.75–0.81] | 0.95–1.06 |
| dynamic: simple component, 2 leaves (pull) † | Vue 3.6 rc | 0.83 [0.83–0.83] | 0.99–1.01 |
| mux | Vue 3.6 rc, alien | 0.91 [0.88–0.94], 0.94 [0.93–0.94] | 0.98–1.02 |
| computed chain x100, 2k writes | Vue 3.6 rc | 0.93 [0.92–0.94] | 0.99–1.01 |
| deep propagation | Vue 3.6 rc, alien | 0.95 [0.95–0.95], 0.96 [0.94–0.98] | 0.98–1.02 |
| dynamic: wide dense (effects) | reactively | 0.69 [0.65–0.72] | 0.96–1.04 |
| creating effects that declare onCleanup † | reactively | 0.86 [0.82–0.90] | 0.90–1.11 |
Inside the band, so not differences: alien on avoidable propagation (0.94 [0.90–0.99]), Vue 3.6 on unobserved computed chain (0.97 [0.93–1.01]), Preact on avoidable propagation (0.98) and repeated observers (1.00), Svelte on create 100000 signals (1.01), Vue 3.5 on create 1to1000, and reactively by the median on the four creation cases (create+dispose 0.74 [0.65–0.82] in a band of 0.72–1.39, create 1to1 0.86 [0.66–1.05], create 1000to1 0.87 [0.74–0.99], create 1to1000 0.88 [0.84–0.91] in 0.87–1.15). Creating effects that declare onCleanup against Vue 3.5 is not resolved either: 0.87–0.96 in the four processes of groups A and C, 1.18 and 1.34 in the two of group B. Preact signals, Solid, Svelte, S, oby and the polyfill are ahead in no case. The reasons are under "What is still slower".
What the comparison is and is not:
- reactively has no disposal (
stop()does nothing), andcreate 1to1000andcreate+disposetime the disposal as well; its numbers there are not comparable. Its lead onwide denseis real. - Solid and S recompute memos eagerly:
dynamic: simple component (pull)(200,000 writes, nothing read) costs them 761× and 311× Roots' time. Without that one case Solid's mean is 4.2 and S's 1.8. - Svelte does not finish two pull cases (
dynamic component (pull),large web app (pull): a derived read outside an effect re-walks everything upstream on every read), so its mean is over 28 cases. Svelte, reactively and the polyfill run their effects outside the write; the adapter flushes after every write, because the cases do not batch. That is the same work per write and not how Svelte is normally used. - S differs in two checksums (
avoidable propagation,mux, ⚠): it has no equality cut-off on computations and runs more effects. Every other library produces Roots' checksums in every case. - The TC39 polyfill has no effect; the adapter builds one from a
Computedand aWatchereach, which is not part of the proposal. - Groups B and C have few samples and wide bands on the creation cases (up to 0.72–1.39): for their libraries only the large ratios mean something.
Against Roots 0.3.0 (ab-reactivity.mjs, 4 processes, 2 per load order, 11 samples after 3 warm-up rounds; the frozen tree of 0.3.0 as reference). The ratio is the 0.3.0 median over the tree's median per process, min / median / max over the processes; bold where the whole range lies outside the control's range for that case:
| case | 0.3.0 ms | A/A (0.3.0 twice) | 0.3.0 / tree |
|---|---|---|---|
| avoidable propagation | 4.23 | 0.996 / 1.000 / 1.011 | 1.188 / 1.236 / 1.253 |
| broad propagation | 3.69 | 0.979 / 0.997 / 1.014 | 1.187 / 1.221 / 1.261 |
| deep propagation | 4.69 | 0.988 / 0.999 / 1.019 | 1.119 / 1.153 / 1.172 |
| diamond | 2.52 | 1.006 / 1.021 / 1.027 | 1.234 / 1.288 / 1.339 |
| mux | 4.04 | 1.001 / 1.011 / 1.051 | 1.084 / 1.097 / 1.145 |
| repeated observers | 3.05 | 0.979 / 0.995 / 1.021 | 1.400 / 1.415 / 1.438 |
| triangle | 4.06 | 0.976 / 1.014 / 1.054 | 1.168 / 1.286 / 1.366 |
| unstable | 1.58 | 0.973 / 1.029 / 1.074 | 1.314 / 1.411 / 1.451 |
| cellx 1000 layers | 23.6 | 0.988 / 0.996 / 1.034 | 1.136 / 1.166 / 1.203 |
| cellx 2500 layers | 32.6 | 0.965 / 0.991 / 0.996 | 1.134 / 1.152 / 1.181 |
| create 100000 signals | 1.83 | 0.987 / 0.997 / 1.007 | 0.966 / 0.989 / 1.017 |
| create 1to1 (10k effects) | 1.53 | 0.937 / 0.996 / 1.008 | 1.236 / 1.271 / 1.332 |
| create 1000to1 (100 effects) | 3.65 | 0.888 / 0.948 / 1.019 | 1.010 / 1.075 / 1.127 |
| create 1to1000 (10 sources) | 1.06 | 0.888 / 0.950 / 0.988 | 1.251 / 1.340 / 1.387 |
| create+dispose 10k ref/computed/effect | 2.14 | 0.987 / 1.028 / 1.041 | 1.185 / 1.280 / 1.346 |
| update 1to1 (1 source, 1 computed) | 12.0 | 0.985 / 0.999 / 1.022 | 1.243 / 1.281 / 1.299 |
| update 1to4 | 19.9 | 0.901 / 1.010 / 1.045 | 1.292 / 1.304 / 1.322 |
| update 1to1000 | 49.2 | 1.022 / 1.026 / 1.058 | 1.174 / 1.238 / 1.295 |
| update 4to1 | 15.6 | 0.977 / 0.999 / 1.034 | 1.146 / 1.279 / 1.350 |
| update 1000to1 | 20.0 | 0.991 / 1.008 / 1.013 | 1.234 / 1.256 / 1.299 |
| 100k writes, 1 effect | 5.39 | 0.948 / 0.955 / 1.028 | 1.187 / 1.226 / 1.255 |
| computed chain x100, 2k writes | 8.35 | 0.986 / 1.007 / 1.013 | 1.127 / 1.160 / 1.181 |
| 1000 computeds+effects, 100 writes | 8.98 | 0.922 / 0.993 / 1.035 | 1.104 / 1.169 / 1.242 |
| unobserved computed chain, write+read | 4.06 | 0.965 / 0.999 / 1.042 | 1.140 / 1.162 / 1.212 |
| dynamic: simple component (pull) | 2.00 | 0.992 / 1.004 / 1.007 | 0.877 / 0.967 / 0.978 |
| dynamic: dynamic component (pull) | 34.0 | 0.994 / 1.004 / 1.013 | 1.351 / 1.353 / 1.374 |
| dynamic: large web app (pull) | 64.1 | 0.985 / 1.018 / 1.023 | 1.122 / 1.142 / 1.233 |
| dynamic: wide dense (effects) | 118 | 0.844 / 0.974 / 0.997 | 0.963 / 1.100 / 1.127 |
| dynamic: deep (effects) | 34.9 | 0.967 / 0.998 / 1.020 | 1.166 / 1.177 / 1.189 |
| dynamic: very dynamic (effects) | 91.0 | 0.960 / 0.992 / 0.995 | 1.151 / 1.157 / 1.167 |
| dynamic: simple component, 2 leaves (pull) † | 25.5 | 0.998 / 1.012 / 1.016 | 1.230 / 1.247 / 1.264 |
| create 10k effects declaring onCleanup † | 3.63 | 1.003 / 1.014 / 1.017 | 4.817 / 4.927 / 4.947 |
| 100k writes, 1 effect declaring onCleanup † | 5.93 | 0.794 / 0.961 / 1.042 | 1.285 / 1.350 / 1.379 |
Geometric mean over the 30 cases: 1.192 / 1.202 / 1.219, control 0.986 / 0.997 / 1.005. 26 cases are resolved faster, three are not resolved (create 100000 signals, create 1000to1, wide dense), and one is resolved slower: dynamic: simple component (pull), 0.877 / 0.967 / 0.978 against a control of 0.992–1.007 (200,000 writes to refs nobody reads; 2.00 → about 2.07 ms). The gate of 0.3.1 read that case as 0.95–0.99 and not resolved; this session resolves it, at about 3% by the median. An effect that declares onCleanup is created 4.8–4.9× faster and re-run 1.29–1.38× faster.
Memory, exact (bytes per object, round nine's counts): a ref 72 B, a computed 112 B, an effect 96 B (144 B when its function declares onCleanup), a link 80 B. Creating an effect on a shared ref allocated 168 B in 0.3.0 against 208–299 B for alien-signals, Preact signals, Vue 3.5, Vue 3.6, Solid, S and reactively; the effect has grown by 8 B since. A computed nobody observes is in no subscriber list and costs nothing once it is dropped; Vue 3.6 and alien-signals keep 256–280 B per dropped computed alive for as long as its source lives.
Results of 0.3.0 (round eight), for the record #
Measured on 10 November 2026 on the same machine under much more load (load average 27 and 10 at the start of the two runs), so the milliseconds are not comparable with the tables above; the ratios are what that release measured. Against Vue 3.5.44, 21 samples after 5 warm-up rounds, a scavenge before each, one process per load order. Geometric mean 1.39× over 30 cases with Roots loaded first (≥ 2×: 2; 1–2×: 26; slower than Vue: 2, create 1to1 and create 1to1000) and 1.41× with Vue loaded first (≥ 2×: 1; 1–2×: 27; slower: the same 2). The case outside the mean, dynamic: simple component, 2 leaves (pull): 1.23× and 1.22×.
| case | Roots first: Roots ms | Vue ms | speedup | Vue first: Roots ms | Vue ms | speedup |
|---|---|---|---|---|---|---|
| avoidable propagation | 10.5 ±7% | 14.9 ±8% | 1.42× | 9.98 ±4% | 14.5 ±1% | 1.45× |
| broad propagation | 8.74 ±1% | 12.9 ±5% | 1.48× | 8.63 ±4% | 12.7 ±4% | 1.47× |
| deep propagation | 12.8 ±12% | 21.9 ±6% | 1.71× | 11.1 ±5% | 19.4 ±5% | 1.74× |
| diamond | 7.09 ±10% | 9.91 ±4% | 1.40× | 6.36 ±14% | 9.96 ±4% | 1.57× |
| mux | 10.8 ±13% | 13.3 ±9% | 1.23× | 9.44 ±3% | 11.1 ±3% | 1.18× |
| repeated observers | 8.05 ±2% | 8.41 ±3% | 1.05× | 7.55 ±6% | 7.63 ±6% | 1.01× |
| triangle | 13.6 ±2% | 16.4 ±6% | 1.21× | 9.81 ±3% | 14.9 ±5% | 1.52× |
| unstable | 3.79 ±6% | 4.25 ±5% | 1.12× | 3.42 ±4% | 3.92 ±6% | 1.15× |
| cellx 1000 layers | 57.1 ±4% | 82.5 ±6% | 1.45× | 53.8 ±3% | 80.9 ±4% | 1.50× |
| cellx 2500 layers | 69.7 ±4% | 114 ±9% | 1.64× | 78.6 ±24% | 123 ±18% | 1.56× |
| create 100000 signals | 3.33 ±3% | 4.73 ±4% | 1.42× | 4.47 ±13% | 6.49 ±9% | 1.45× |
| create 1to1 (10k effects) | 3.14 ±8% | 2.56 ±10% | 0.82× | 3.51 ±9% | 2.86 ±11% | 0.81× |
| create 1000to1 (100 effects) | 6.27 ±1% | 8.62 ±2% | 1.37× | 7.74 ±3% | 10.6 ±2% | 1.37× |
| create 1to1000 (10 sources) | 1.97 ±4% | 1.32 ±8% | 0.67× | 2.52 ±10% | 1.66 ±14% | 0.66× |
| create+dispose 10k ref/computed/effect | 3.99 ±2% | 4.36 ±3% | 1.09× | 4.62 ±4% | 5.22 ±3% | 1.13× |
| update 1to1 (1 source, 1 computed) | 27.2 ±1% | 36.6 ±1% | 1.35× | 27.6 ±3% | 43.5 ±3% | 1.58× |
| update 1to4 | 38.5 ±1% | 62.8 ±0% | 1.63× | 40.4 ±3% | 67.8 ±2% | 1.68× |
| update 1to1000 | 89.8 ±3% | 134 ±2% | 1.49× | 90.2 ±2% | 143 ±3% | 1.59× |
| update 4to1 | 30.6 ±6% | 54.5 ±6% | 1.78× | 29.8 ±2% | 52.7 ±2% | 1.77× |
| update 1000to1 | 42.9 ±3% | 102 ±4% | 2.37× | 37.3 ±1% | 85.8 ±2% | 2.30× |
| 100k writes, 1 effect | 11.9 ±2% | 15.3 ±1% | 1.29× | 10.5 ±3% | 11.4 ±2% | 1.09× |
| computed chain x100, 2k writes | 19.0 ±3% | 39.0 ±2% | 2.05× | 16.9 ±2% | 33.4 ±3% | 1.97× |
| 1000 computeds+effects, 100 writes | 19.6 ±2% | 32.3 ±1% | 1.65× | 17.5 ±3% | 27.9 ±4% | 1.60× |
| unobserved computed chain, write+read | 9.38 ±1% | 11.7 ±1% | 1.24× | 8.37 ±4% | 12.3 ±3% | 1.47× |
| dynamic: simple component (pull) | 4.45 ±2% | 5.78 ±2% | 1.30× | 4.06 ±5% | 4.92 ±2% | 1.21× |
| dynamic: dynamic component (pull) | 82.8 ±4% | 121 ±6% | 1.46× | 62.7 ±2% | 91.5 ±2% | 1.46× |
| dynamic: large web app (pull) | 162 ±2% | 244 ±2% | 1.51× | 126 ±4% | 192 ±3% | 1.53× |
| dynamic: wide dense (effects) | 331 ±4% | 590 ±5% | 1.78× | 271 ±6% | 487 ±3% | 1.80× |
| dynamic: deep (effects) | 81.7 ±2% | 125 ±1% | 1.53× | 62.7 ±2% | 95.6 ±2% | 1.52× |
| dynamic: very dynamic (effects) | 224 ±5% | 342 ±4% | 1.52× | 169 ±2% | 259 ±3% | 1.53× |
The times of the two halves are not comparable with each other (the processes ran under different load); the ratios are. They agree within ±0.05 on 20 of the 30 cases; triangle (1.21 / 1.52), unobserved computed chain (1.24 / 1.47), update 1to1 (1.35 / 1.58) and 100k writes (1.29 / 1.09) moved most.
Against Roots 0.2.1 and against the tree after round five (ab-reactivity.mjs, 6 processes, 3 per load order, 11 samples after 3 warm-up rounds, load average 6–16). The ratio is the 0.2.1 median over the variant's median per process, above 1 when the variant is faster, as min / median / max over the processes; bold where the whole range lies outside the control's range for that case:
| case | 0.2.1 ms | A/A (0.2.1 twice) | 0.2.1 / round five | 0.2.1 / 0.3.0 |
|---|---|---|---|---|
| avoidable propagation | 8.03 | 0.969 / 1.012 / 1.051 | 0.947 / 1.008 / 1.032 | 0.950 / 0.995 / 1.054 |
| broad propagation | 7.95 | 0.913 / 1.003 / 1.037 | 0.957 / 1.007 / 1.078 | 1.070 / 1.094 / 1.113 |
| deep propagation | 9.63 | 0.811 / 0.981 / 1.090 | 0.956 / 1.022 / 1.093 | 1.018 / 1.051 / 1.114 |
| diamond | 5.41 | 0.964 / 0.999 / 1.074 | 1.012 / 1.104 / 1.238 | 1.002 / 1.051 / 1.192 |
| mux | 8.13 | 0.974 / 1.025 / 1.072 | 0.936 / 1.033 / 1.092 | 0.807 / 1.008 / 1.063 |
| repeated observers | 6.85 | 0.962 / 1.028 / 1.102 | 1.010 / 1.040 / 1.101 | 0.988 / 1.096 / 1.178 |
| triangle | 8.89 | 0.926 / 1.006 / 1.048 | 0.989 / 1.017 / 1.028 | 0.904 / 1.038 / 1.106 |
| unstable | 3.22 | 0.884 / 0.962 / 1.068 | 0.982 / 1.033 / 1.128 | 0.882 / 1.047 / 1.087 |
| cellx 1000 layers | 51.7 | 0.894 / 0.979 / 1.047 | 0.955 / 0.979 / 1.100 | 1.035 / 1.060 / 1.111 |
| cellx 2500 layers | 82.1 | 0.926 / 0.996 / 1.056 | 0.923 / 0.973 / 1.064 | 0.963 / 1.056 / 1.121 |
| create 100000 signals | 3.58 | 0.985 / 1.020 / 1.046 | 0.976 / 1.034 / 1.097 | 0.998 / 1.027 / 1.040 |
| create 1to1 (10k effects) | 3.21 | 0.986 / 1.017 / 1.204 | 0.779 / 0.889 / 1.005 | 0.895 / 1.019 / 1.174 |
| create 1000to1 (100 effects) | 6.80 | 0.970 / 1.016 / 1.055 | 0.940 / 0.989 / 1.113 | 0.914 / 1.027 / 1.040 |
| create 1to1000 (10 sources) | 2.32 | 0.932 / 1.069 / 1.134 | 0.781 / 0.844 / 0.961 | 0.999 / 1.026 / 1.087 |
| create+dispose 10k ref/computed/effect | 5.20 | 0.884 / 0.997 / 1.068 | 0.967 / 1.024 / 1.225 | 1.075 / 1.160 / 1.404 |
| update 1to1 (1 source, 1 computed) | 26.2 | 0.915 / 0.985 / 1.027 | 0.950 / 1.028 / 1.072 | 1.008 / 1.097 / 1.136 |
| update 1to4 | 46.3 | 0.916 / 0.974 / 1.059 | 0.981 / 1.022 / 1.075 | 1.020 / 1.073 / 1.151 |
| update 1to1000 | 103 | 0.873 / 1.017 / 1.075 | 0.907 / 0.955 / 0.998 | 0.876 / 0.996 / 1.064 |
| update 4to1 | 39.0 | 0.928 / 1.009 / 1.029 | 0.937 / 0.986 / 1.051 | 1.036 / 1.071 / 1.141 |
| update 1000to1 | 46.1 | 0.979 / 1.019 / 1.049 | 0.961 / 1.004 / 1.042 | 0.945 / 1.015 / 1.080 |
| 100k writes, 1 effect | 12.8 | 0.908 / 0.982 / 1.046 | 0.967 / 1.003 / 1.100 | 1.061 / 1.159 / 1.273 |
| computed chain x100, 2k writes | 18.5 | 0.961 / 0.998 / 1.011 | 1.006 / 1.051 / 1.087 | 1.013 / 1.034 / 1.061 |
| 1000 computeds+effects, 100 writes | 20.5 | 0.972 / 1.016 / 1.052 | 0.934 / 1.029 / 1.060 | 1.032 / 1.103 / 1.208 |
| unobserved computed chain, write+read | 8.02 | 0.990 / 1.012 / 1.042 | 0.949 / 0.972 / 0.999 | 0.933 / 0.966 / 1.010 |
| dynamic: simple component (pull) | 4.15 | 0.967 / 0.975 / 1.086 | 0.983 / 1.025 / 1.100 | 0.989 / 1.031 / 1.054 |
| dynamic: dynamic component (pull) | 69.5 | 0.952 / 1.021 / 1.074 | 1.037 / 1.054 / 1.136 | 1.043 / 1.068 / 1.134 |
| dynamic: large web app (pull) | 123 | 0.895 / 0.990 / 1.062 | 0.936 / 1.045 / 1.082 | 0.970 / 1.067 / 1.122 |
| dynamic: wide dense (effects) | 287 | 0.966 / 1.014 / 1.182 | 0.954 / 1.097 / 1.225 | 0.959 / 1.084 / 1.209 |
| dynamic: deep (effects) | 64.2 | 0.987 / 1.007 / 1.053 | 0.902 / 0.955 / 1.064 | 0.924 / 0.943 / 1.071 |
| dynamic: very dynamic (effects) | 200 | 0.915 / 1.000 / 1.173 | 1.011 / 1.030 / 1.172 | 0.977 / 1.022 / 1.190 |
| dynamic: simple component, 2 leaves (pull) | 51.5 | 0.907 / 1.020 / 1.043 | 1.016 / 1.036 / 1.076 | 0.988 / 1.022 / 1.045 |
Geometric means over the 31 cases, min / median / max over the processes: the control 0.980 / 1.006 / 1.020; round five 0.995 / 1.014 / 1.021, no difference shown; 0.3.0 1.037 / 1.047 / 1.056, outside the control's range. Five cases are resolved one by one (bold), all of them faster: create+dispose 1.16×, 100k writes 1.16×, broad propagation 1.09×, update 4to1 1.07×, computed chain 1.03×. No case is resolved as slower; the two lowest medians of 0.3.0 are dynamic: deep 0.943 and unobserved computed chain 0.966, both inside their controls. In the same processes Vue 3.5.44 measured 0.732 / 0.744 / 0.758 against 0.2.1 (0.2.1 was 1.34× faster by the median), and was faster than 0.2.1 on create 1to1000 (1.30 / 1.57 / 1.72, resolved) and by the median on create 1to1 (1.11 / 1.27 / 1.51, not resolved against a control of 0.99–1.20).
What made the difference #
Round nine (0.3.1) is the A/B against 0.3.0 above. It came out of the comparison with the field, which showed Vue 3.6, alien-signals and Preact signals ahead of 0.3.0 on 6 to 15 cases each, and it changed no algorithm:
- Strict null tests in the graph functions.
link,propagate, the validation walk,trigger, bothvaluegetters and the subscribe and unsubscribe paths askedif (link)of variables that are eithernullor an object. For a truthiness test V8 emits the checks for every kind of value; forlink !== nullit emits one comparison. The dialect got two predicates for it,(some? x)and(none? x), and every pointer field is initialised tonull(a test per class holds that invariant). V8's optimized code for the hot functions is 17–44% smaller (exact); on the prototype this step alone measured 1.15–1.22 on the geometric mean of 11 cases against a control of 1.00–1.03. EffectBaseassigns its fields in the constructor (the newdefine-classoption(:constructor-fields t)). A derived class whose base has class fields is constructed through a generic stub; one whose constructors only assign is inlined. That was the cost of the two creation cases 0.3.0 listed as slower than Vue 3.5.- The
onCleanupregistrar of an effect is a field, where it was an entry in a WeakMap. Only effects whose function declares the parameter have one (every componentwatchEffectdoes): creating such an effect is 4–5× faster, a write that re-runs it 1.2–1.4× (the two cases outside the mean, under the tables). - Measured and dropped, because their gates were not met: two variants of the ref setter (aimed at
dynamic: simple component (pull)) and three variants of the validation walk (aimed atavoidable propagation,muxandcomputed chain). Those four cases are in "What is still slower".
Rounds four to seven changed the core in small ways; their sum is the A/B against 0.2.1 in the tables of 0.3.0 (1.05×). None of the single steps was resolved on its own in the session that made it (the machine was loaded then too), so they are listed with what is exact about them:
- Round seven: a scope stops its effects without iterators (
stopOwned: index loops over effects, child scopes and cleanups, wherefor…ofloops allocated an iterator per stopped effect and madestopdeoptimize).create+disposeis the case the A/B resolves most clearly. - Round seven: no variable for "the effect running now".
runandrerunsaved and restored it around every effect run; the sharedonCleanupregistrar now goes by the node that is collecting dependencies. That is the write path (100k writes,update 4to1), and it is also the one behaviour change of the core in this release (see "Semantics notes":R106insideuntrack, a computed's getter and awatchgetter). - Round five:
EffectBase. Render effects extend an effect class without a scheduler slot and count their runs per render flush with the core'sranInstamp, so the base effect carries nothing for them. - Round four: one pass over the flush queue. An effect that ran is stamped with the flush's id (
ranIn, a small integer) instead of being marked and cleared in a second loop; the write path checks a transaction depth counter instead of a list. - Round four:
effect(fn, { cleanup: "run" })promises thatfnusesonCleanuponly during its run, so the effect hands it the shared registrar without readingfn.length.watch's runner uses it, hencewatch,createSelectorand the component runtime's watchers;effect(fn)andwatchEffectstill readfn.lengthfor user functions. - Round four: a child scope leaves its parent in O(1) (its index, and the last sibling takes the slot), and scopes do not own computeds: a computed leaves its sources' subscriber lists with its last subscriber, so stopping the scope's effects is enough.
scopeRemoveEffect(scope, effect)is new (round seven), for the component runtime's release of bindings that read nothing reactive.
The earlier rounds, measured on the earlier machine (the numbers in them are that machine's):
Round three (0.1.8), internal only (no behaviour change; 1 test added):
- Effects have 7 fields (were 10). The R102 accounting marks an effect that ran in the flush in progress in its flags and counts only the effects that run again, in a map that exists while some effect re-runs; the queue keeps its entries until the end of the flush, which clears the marks. An effect whose function declares
onCleanupkeeps its own in aWeakMap. Render effects keep their R220 counters themselves. Acreate 1to1sample (10,000 refs and effects, with the case's own arrays and closures) allocates 5.7 MB instead of 6.3 MB (Vue: 7.4 MB). - Building a graph skips the duplicate search. A new read searches the last 8 links for the same dep (
a, b, a); a connected node's links are all in their deps' subscriber lists, so a dep without subscribers cannot have one of them, and the search is skipped. Creating an effect that reads 1,000 fresh refs no longer walks 8 links per read (create 1000to10.95× → 1.2×). connect/disconnectdo not push the computed itself on their stack.- Alternating reads are found without a call.
linkchecks the link before the last confirmed one (a, b, a) itself before calling the 8-link search:unstable(20 alternating reads per run) went from 0.87–0.95× to 1.07–1.13× in the whole suite. Vue's equivalent check (the dep's last subscriber link) does not apply: Roots keeps no per-run pointer on the dep, and inunstablethe dep's last subscriber is another computed.
Round two (0.1.7), internal only (no API change except the new createSelector; the 37 behaviour tests pass untouched, 3 were added for createSelector):
- A computed is its own dependency node (as in alien-signals, where a computed is both a dependency and a subscriber): no separate dep object per computed, one indirection less on every read and every propagation step, one allocation less per computed.
- Fields at the same positions. Every dependency (
RefImpl, a reactive property's dep,Computed, a selector key) hasversion, subs, subsTail, flagsas its first four fields, and every subscriber hasflags, deps, depsTailas its 4th to 6th (a computed is both). The hot paths read these fields from objects of several classes; V8 compiles such a load to one instruction after the class check when the position is the same in every class. - Machinery only where it is needed. A write no longer opens a batch and a
try/finally: only the schedulers of a write (sync watchers, render effects) run user code there, so only they are batched. The flush checks an effect's dirty flag inline before validating it. Validation scans a node's own links without a stack and starts the stack walk only at the first computed that must be validated through its own sources. A connected computed no longer writes its pull-mode validation stamp. Effect scopes allocate their lists on first use. Render effects skip thefn.lengthcheck (their functions never takeonCleanup) and reuse the base effect's fields for R220. - Compiler output: module-level variables are
var. Every access to a module-levelletfrom a function pays a temporal-dead-zone check (a load and a compare with the hole value); the reactive core readsactiveSub, the batch depth and the queues on every operation. Top-leveldefvar/defparameternow compile tovar(inside a function they staylet): 3–5% over the whole suite.
Round one (0.1.4), the rewrite:
- Doubly-linked dependency links (as in Vue 3.5 and alien-signals). A
Linksits in the subscriber's dependency list (read order) and in the dep's subscriber list. A re-run walks the list in read order and reuses links, so a run that reads the same sources in the same order allocates nothing and touches no subscriber list; repeated reads in a row, and reads repeated within the last 8 links (a, b, a), keep one link. Before, every run rebuilt two arrays and diffed them through aSetwhen they changed. - Push without recursion, pull without recursion. A write marks direct subscribers DIRTY (exact: they read the changed source) and walks downstream with an explicit stack, marking connected computeds PENDING once and queueing effects. Validation walks pending computeds depth-first with an explicit stack, recomputing what turned out stale, so a 20,000-deep chain needs no deep recursion (there is a test).
- Versions decide, flags shortcut. Links keep the dep's version at the first read in a run; a node is stale when a version differs (an effect that read, wrote, then read a source again is still a runaway, R102, as before). DIRTY skips the walk for direct subscribers; a connected, non-pending computed is returned without any check; an unconnected one checks the global version first.
- Lazy subscription. A computed without subscribers is in no subscriber list (nothing keeps it alive) and validates by version; it joins its sources' lists when it gets its first subscriber and leaves them with the last one (iteratively, upstream included).
- Allocation-free hot paths. The effect queue, the scheduler queue and the stacks are arrays with explicit lengths: emptying an array with
length = 0drops its backing store, so the next push allocated a new one on every write; that alone made a write plus effect run 2× slower. Undo closures for transactions are made in helper functions: a closure capturing a writer's locals made V8 allocate a context on every write even when no transaction ran. The effect constructor no longer contains a closure (same reason), andactive/queuedlive in the flags. - Flat classes.
DepandReactiveNodeare interfaces;RefImpl(a ref is its own dep),KeyDep,ReactiveEffectandComputeddeclare the shared fields first themselves. V8 builds objects of a class without a base class markedly faster (measured ~30%). - Compiler output. Numeric
defconstants are inlined at their references (each reference was a module-variable load plus a TDZ check in every loop iteration); a leadingthisparameter typesthisin TypeScript and is dropped from JavaScript (for the shared array instrumentations);for-initerates keys without an array.
alien-signals #
The design suggested for round two was a port of alien-signals 2.x/3.x (MIT, Johnson Chu), the algorithm Vue 3.6 adopted: no per-link version bookkeeping, signals that keep a pending value, Pending/Dirty flags pushed downstream and resolved by a checkDirty walk, effects in a flat queue, link reuse with a tail pointer. Roots has had most of that structure since 0.1.4 — one flag word per node with Pending/Dirty, an iterative propagate that marks direct subscribers dirty and everything below them pending, an explicit-stack validation walk that only descends into pending computeds, a flat effect queue, in-order link reuse from a tail pointer — and this round took the computed-as-its-own-node idea from it. It keeps per-link versions: they let a computed without subscribers stay out of every subscriber list (nothing keeps it alive, no leak) and still validate cheaply when read, where alien-signals keeps unobserved computeds linked into their sources.
The decision was measured, not assumed (the earlier machine, the same cases, write → computed → effect, ns per write, medians of 7 processes): a minimal push-pull core with none of Roots' features (no versions, no errors, no scopes, no cleanups, no R102, no schedulers, no transactions) does it in 27 ns; Roots in 64 ns; Vue 3.5 in 90 ns; alien-signals 3.2.1 behind a .value adapter (two extra calls per operation) in 57 ns. Ablating Roots' features one at a time (versions, R102 accounting, onCleanup bookkeeping, error routing, the scheduler queue, the transaction check, the shallow flag…) costs 1–4 ns each, none dominant: the gap to the floor is the feature set, not the algorithm, so swapping the algorithm would not have bought 2×.
Since round nine alien-signals 3.2.1 and its port in Vue 3.6 are measured directly, in their own notation and without an adapter object (the field table above): 0.3.0 was behind Vue 3.6 rc on the mean (0.95) and ahead of alien-signals by 1.14; the tree is ahead by 1.13 and 1.36. The cases where their design still wins are the ones the paragraph above predicts: validation walks without versions, and computeds that stay linked.
What is still slower, and why #
The goal against Vue 3.5 was 2× on the geometric mean and no case slower. The mean is 1.70–1.72×: still missed. No case is resolved slower than Vue 3.5 any more. Against the field Roots leads the mean and 19 of 30 rows, and is behind in these:
- Writes to refs nobody reads:
dynamic: simple component (pull), 0.75 against Vue 3.6 rc (Preact 1.01, alien 1.48, Vue 3.5 1.26). The case is 200,000 writes without a subscriber, so it measures the ref setter alone: a call ofObject.is, the test for a shallow ref, two counters and the call oftrigger. A prototype with===and no shallow test reached 1.20–1.27 on this case, which would still leave Vue 3.6 ahead, and is not shippable (NaN, and deep refs). The two setter variants of round nine did not leave their control. This is also the one case where 0.3.1 is slower than 0.3.0, by about 3% (above); the cause has not been looked for. avoidable propagation, 0.78 against Vue 3.6 rc (alien 0.94, Preact 0.98, both inside the band). Not attributed: the two libraries run the same getters the same number of times (exact), so the difference is the cost per step of the validation walk.mux0.91 (alien 0.94),computed chain0.93,deep propagation0.95 (alien 0.96) are the same walk. Three variants of it were measured in round nine and dropped; removing a single feature from the walk (the R102 accounting, thetry/finally, the cleanup test, the transaction check) is worth at most 4% each.- Reading computeds nobody observes:
dynamic: simple component, 2 leaves (pull), 0.83 against Vue 3.6 rc (alien 1.03). This one is a choice. Vue 3.6 and alien-signals keep an unobserved computed subscribed to its sources, so a read finds its flags current; Roots keeps it out of every subscriber list and validates it by version on each read. The other side of it: in Vue 3.6 a live source keeps every computed that ever read it, 256–280 B each, until the source dies; in Roots a dropped computed costs nothing. A prototype of the linked design gained 1.26 on this case and lost 5–8% on two others. Not closing this gap is the decision. - Wide graphs:
dynamic: wide dense (effects), 0.69 against reactively (Vue 3.6 1.19, alien 1.45). reactively keeps a node's sources in an array and compares positions; Roots walks linked nodes. Closing it means array dependency lists, a rewrite of the core's data structure. reactively cannot dispose anything, and is 1.43 behind on the mean. - Creating effects: level, not ahead.
create 1to1000is 0.97 [0.83–1.34] against Vue 3.5 and 1.00 against Preact,create 1to11.02 and 1.04; Vue 3.6 and alien are behind on both (1.12–1.28).effect()still readsfn.lengthonce per effect, to give a function that declaresonCleanupa registrar it may keep and call after the run, asR106's message promises. Without the read a prototype measured 1.46–1.63 over 0.3.0 oncreate 1to1000where the shipped core measures 1.25–1.39. Removing it means changing whatonCleanuppromises (valid during the run only, as Vue'sonEffectCleanup); the decision is to keepR106as documented, andeffect(fn, { cleanup: "run" })is the opt-out for callers that can make the promise themselves. - Why not 2× over Vue 3.5: Vue 3.5 already uses linked dependency nodes and version counters, so the differences are constant factors. Roots is at 2× or more where Vue does extra passes or recurses (
update 1000to12.9×,computed chain2.4–2.6×,update 4to12.2–2.4×,triangle,diamond,deep propagation2.0–2.2×) and at 1.2–1.6× where one tracked read or one write plus effect run is all there is (mux,dynamic: simple component (pull),create 100000 signals,100k writes,unstable). - What is not claimed: "fastest in every case" (the table above), and anything about libraries that were not measured. Vue 3.6 is a release candidate; its final release may measure differently.
Rendering #
Method #
runtime/bench/run-render.mjs builds three versions of the js-framework-benchmark keyed app and drives them in one headless Chromium:
- Roots:
runtime/bench/render/app(onesrc/App.vue), built with the real toolchain,roots build --target web: one bundle, tree-shaken, minified,DEVfalse, strict CSP. The row class is:class="{ danger: isSelected(row.id) }"withisSelected = createSelector(selected). - Vue 3.5.44: the same
App.vuecompiled with@vue/compiler-sfc(<script setup>, inline template, production mode) againstvue.runtime.esm-browser.prod.js. The only edits: the import specifier, and the selection test, which becomesrow.id === selectedplus thev-memothat Vue's own benchmark entry uses (v-memo="[row.label.value, row.id === selected]"), i.e. Vue at its best (Vue has no selector primitive;v-memois its tool for the same job). Data model as in that entry: ashallowRefarray of rows whose labels areshallowRefs. - vanilla: hand-written DOM code (template
cloneNode, one delegated listener). - vanilla with a template (
vanillatpl): the same code with one line more, which moves its row prototype into the content of a<template>, an inert document. In Chromium a clone from there is cheaper, so on the operations that create rows this is the floor, notvanilla; on the others the two are the same code. - The field (
--frameworks, since round nine): the js-framework-benchmark keyed entries of Solid 1.9.18, Svelte 5.57.3, Preact 11.1.1, ivi 5.1.0, lit-html 3.3.3, Inferno 10.0.0 and Vue Vapor 3.6.0-rc.14 (a release candidate), ported from upstream with this harness's seeded label generator (so the DOMs can be compared), each compiled by its own compiler and bundled and minified with esbuild (render/build-field.mjs). lit-html as shipped leaves one comment node in the table per removed row and gets slower with every operation until a page that lives as long as this harness's pages no longer finishes; the tables uselitfix, the same app with that one line of lit-html patched, and say so. A session has at most five pages, with Roots, its A/A copy andvanillain each. - A/A (
--aa): a copy of the Roots build as one more page; base (--baseline <checkout>): the same app built by another checkout of the toolchain.
Every measurement resets the app (Clear), prepares the state (untimed, e.g. Create 1,000 rows), then clicks the button or row link like js-framework-benchmark, waits for the framework's update (nextTick), and forces style and layout (offsetHeight): the time is performance.now() around click → update → layout, in the page. The pages are served with COOP/COEP, so the timer has 5 µs resolution instead of 100 µs. A second table reports the script part alone (click → update applied, before style and layout). All pages are open at once and measured in turn, the order rotated by one every second round and reversed on every other round, after warm-up rounds; medians, with half the IQR as spread. After every run the DOMs are compared (row count, ids and labels of rows 1, 2, 11, 998, 999, selected rows); none differed. Memory is the JS heap after two forced GCs (CDP), median of 5 page loads; size is the JavaScript the page loads, gzipped file by file, and for reference esbuild --bundle --minify over each app's unminified modules (for Roots the modules of roots build --no-bundle --no-minify, so that row compares Roots' tree shaker and minifier with esbuild on the same input).
Results #
Measured on 11 November 2026 on the tree of 0.3.3, Chromium 143 unless a table says otherwise, 15 runs after 3 warm-up rounds, load average 5–11. Every session has Roots, its A/A copy and the hand-written app; a framework is bold in a row when its ratio lies outside the control of that row in every session it ran in (the control's own deviation, at least ±5%).
The field, script time (click → update applied). Six sessions of five pages: Solid + ivi, Svelte + Vapor, Inferno + Preact, lit-html + Vue 3.5, the template version of the hand-written app + Vapor, Solid + the template version. Each cell is that framework's median over Roots' median in the same session: above 1, Roots is faster. Two numbers are two sessions. "Roots ms" and "A/A" are ranges over the six sessions, vanilla too. Vue Vapor is 3.6.0-rc.14, a release candidate; lit-html is the patched build (see "Method").
| operation | Roots ms | A/A | Vue Vapor 3.6 rc | Solid | Inferno | ivi | Svelte 5 | lit-html (patched) | Vue 3.5 | Preact | vanilla | vanilla with a template |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| create 1,000 | 4.41–5.14 | 0.96–1.01 | 0.99 / 1.06 | 1.02 / 1.04 | 1.32 | 1.26 | 1.12 | 1.64 | 2.18 | 3.03 | 0.81–0.92 | 0.65 / 0.63 |
| replace 1,000 | 9.04–11.0 | 0.98–1.01 | 1.00 / 1.04 | 1.11 / 1.09 | 1.19 | 1.12 | 1.17 | 1.45 | 1.82 | 2.55 | 0.90–0.94 | 0.88 / 0.84 |
| partial update | 0.22–0.27 | 0.96–1.02 | 1.06 / 0.96 | 1.30 / 1.30 | 1.58 | 1.53 | 1.38 | 1.55 | 3.62 | 14.30 | 0.66–0.74 | 0.68 / 0.65 |
| select row | 0.09–0.10 | 0.95–1.05 | 0.90 / 0.89 | 1.21 / 1.16 | 1.48 | 2.37 | 4.55 | 2.63 | 4.74 | 33.48 | 0.42–0.48 | 0.42 / 0.42 |
| swap rows | 0.13–0.15 | 0.94–1.14 | 1.45 / 1.48 | 3.19 / 3.22 | 2.00 | 2.41 | 2.84 | 1.90 | 4.55 | 23.50 | 0.52–0.61 | 0.52 / 0.56 |
| remove row | 0.13–0.14 | 0.96–1.04 | 1.43 / 1.77 | 2.96 / 2.85 | 1.21 | 2.04 | 2.46 | 2.19 | 16.42 | 24.29 | 0.54–0.62 | 0.58 / 0.56 |
| create 10,000 | 37.4–49.5 | 1.00–1.04 | 1.00 / 1.07 | 1.06 / 1.03 | 1.38 | 1.12 | 1.26 | 1.83 | 2.52 | 3.41 | 0.90–1.04 | 0.79 / 0.76 |
| append 1,000 | 3.77–4.74 | 0.99–1.01 | 1.08 / 1.07 | 1.10 / 1.11 | 1.44 | 1.18 | 1.24 | 1.85 | 2.61 | 4.19 | 0.82–0.90 | 0.76 / 0.76 |
| clear 1,000 | 4.78–6.71 | 0.95–1.10 | 0.97 / 1.02 | 1.02 / 1.02 | 1.02 | 1.19 | 1.02 | 1.19 | 1.23 | 1.23 | 0.97–1.04 | 1.07 / 1.07 |
| geometric mean | 0.99–1.02 | 1.08 / 1.12 | 1.40 / 1.38 | 1.38 | 1.51 | 1.65 | 1.76 | 3.26 | 6.94 | 0.72–0.76 | 0.68 / 0.67 |
On the geometric mean of the nine operations Roots has the lowest script time of the frameworks measured. The nearest is Vue Vapor 3.6 rc at 1.08 and 1.12, then Inferno 1.38, Solid 1.38–1.40, ivi 1.51, Svelte 1.65, lit-html 1.76, Vue 3.5 3.26 (3.35 in the session against 0.3.0 below), Preact 6.94. Operation by operation:
- Roots is not the fastest on select: Vapor is, 0.90 and 0.89, outside the control in both sessions, and 0.94 in a third session of 41 runs (control 1.00). At 0.08–0.10 ms that is one or two 5 µs timer steps, and it has the same sign every time.
- Level with Vapor, inside the control or with one session on each side of it: create 1,000 (0.99, 1.06), replace (1.00, 1.04), partial update (1.06, 0.96), create 10,000 (1.00, 1.07), clear (0.97, 1.02). Ahead of Vapor on append (1.08, 1.07), swap (1.45, 1.48) and remove (1.43, 1.77).
- Level with Solid on creating rows: create 1,000 1.02 and 1.04, create 10,000 1.06 and 1.03, each inside the control in at least one session; ahead on append (1.10, 1.11) and on every other operation but clear. At 0.3.0 Solid was ahead on all three (0.80, 0.75, 0.85).
- Ahead of Inferno, ivi, Svelte, lit-html, Vue 3.5 and Preact on every operation that is resolved; clear is level with Inferno and Svelte.
- Hand-written code is faster on everything but clear, and its template version is the floor on creation: Roots needs 1.54–1.59× its script time to create 1,000 rows, 1.27–1.32× for 10,000, 1.31× to append, 1.14–1.19× to replace; and 1.5–1.9× on partial update, swap and remove, 2.4× on select, where both take 0.04–0.2 ms. The hand-written app with a template is itself 0.71–0.88× of the one without on create, create 10,000 and append (2.9 against 4.4–4.5 ms for Roots on create 1,000).
The field, totals (script + style + layout), same sessions and rules:
| operation | Roots ms | A/A | Vue Vapor 3.6 rc | Solid | Inferno | ivi | Svelte 5 | lit-html (patched) | Vue 3.5 | Preact | vanilla | vanilla with a template |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| create 1,000 | 44.2–53.8 | 0.94–1.04 | 0.94 / 0.96 | 0.83 / 0.96 | 1.01 | 1.07 | 0.93 | 1.09 | 1.06 | 1.12 | 0.93–0.99 | 0.94 / 0.94 |
| replace 1,000 | 50.0–59.7 | 0.97–1.02 | 0.95 / 0.93 | 0.94 / 0.95 | 1.01 | 1.01 | 0.99 | 1.12 | 1.10 | 1.24 | 0.92–0.98 | 0.92 / 1.01 |
| partial update | 4.43–5.65 | 0.97–1.06 | 1.02 / 0.93 | 1.00 / 0.98 | 0.99 | 1.07 | 1.06 | 1.14 | 1.15 | 1.64 | 0.93–1.01 | 0.95 / 0.97 |
| select row | 0.14–0.16 | 0.96–1.03 | 0.93 / 0.93 | 1.10 / 1.11 | 1.28 | 1.86 | 3.40 | 2.03 | 3.41 | 22.38 | 0.62–0.67 | 0.62 / 0.64 |
| swap rows | 5.03–5.98 | 0.94–1.06 | 0.95 / 0.96 | 1.06 / 1.05 | 1.06 | 1.02 | 1.06 | 1.05 | 1.05 | 1.58 | 1.01–1.11 | 1.06 / 1.09 |
| remove row | 5.65–6.45 | 0.94–1.03 | 0.98 / 0.96 | 1.02 / 1.01 | 1.00 | 1.04 | 1.02 | 1.04 | 1.32 | 1.49 | 0.97–1.11 | 1.13 / 0.97 |
| create 10,000 | 428–557 | 0.98–1.03 | 0.94 / 0.99 | 0.96 / 0.99 | 1.01 | 0.97 | 1.01 | 1.12 | 1.13 | 1.25 | 1.00–1.07 | 1.00 / 1.04 |
| append 1,000 | 45.3–57.5 | 0.97–1.04 | 0.98 / 0.99 | 0.96 / 0.99 | 1.05 | 1.02 | 0.98 | 1.11 | 1.13 | 1.27 | 0.95–1.14 | 0.99 / 1.04 |
| clear 1,000 | 4.86–6.82 | 0.96–1.10 | 0.97 / 1.02 | 1.02 / 1.02 | 1.02 | 1.19 | 1.02 | 1.19 | 1.23 | 1.23 | 0.97–1.04 | 1.07 / 1.07 |
| geometric mean | 0.97–1.02 | 0.96 / 0.96 | 0.98 / 1.01 | 1.04 | 1.11 | 1.15 | 1.18 | 1.29 | 1.83 | 0.94–0.97 | 0.95 / 0.97 |
With style and layout in the window the field is within a few percent: on seven of the nine operations layout is 80–97% of Roots' total. In totals Roots is not first: Vue Vapor reads 0.96 in both sessions against controls with a geometric mean of 1.00, outside the control on replace (0.95, 0.93) and select (0.93, 0.93); Solid is level (0.98, 1.01), ahead on replace (0.94, 0.95). Roots is ahead of Vue 3.5 by 1.29× (1.34× in the session below), of Preact by 1.83×, of lit-html, Svelte and ivi by 1.11–1.18×, and level with Inferno (1.04). Where the script time is lower and the total is not, the difference is in style and layout, which no framework code runs in; why Vapor's and Solid's totals are 5–7% lower on replace has not been looked into.
Firefox 144, two sessions. The first (Roots, its A/A copy, Vue 3.5, Solid, vanilla; the timer steps by 20 µs there, so select, swap, remove and partial update are a few steps):
| operation | Roots script ms | A/A | Vue 3.5 / Roots | Solid / Roots | vanilla / Roots | Roots total ms | A/A | Vue 3.5 / Roots | Solid / Roots | vanilla / Roots |
|---|---|---|---|---|---|---|---|---|---|---|
| create 1,000 | 8.80 | 1.04 | 4.15 | 1.19 | 0.66 | 62.2 | 1.01 | 1.38 | 0.94 | 0.87 |
| replace 1,000 | 30.6 | 0.91 | 2.38 | 0.69 | 0.50 | 82.5 | 0.97 | 1.47 | 0.84 | 0.78 |
| partial update | 0.50 | 0.96 | 2.32 | 1.08 | 0.56 | 6.16 | 1.03 | 1.16 | 1.05 | 0.98 |
| select row | 0.20 | 1.10 | 2.30 | 0.80 | 0.30 | 0.32 | 1.06 | 1.69 | 0.81 | 0.50 |
| swap rows | 0.38 | 0.95 | 2.21 | 1.42 | 0.53 | 26.8 | 0.99 | 0.88 | 0.87 | 0.85 |
| remove row | 0.28 | 1.07 | 10.93 | 1.29 | 0.64 | 26.4 | 0.99 | 1.06 | 0.87 | 0.88 |
| create 10,000 | 104 | 0.94 | 3.54 | 1.11 | 0.55 | 643 | 0.96 | 1.39 | 1.01 | 0.93 |
| append 1,000 | 8.18 | 0.99 | 4.15 | 1.27 | 0.62 | 71.7 | 0.99 | 1.29 | 0.99 | 0.90 |
| clear 1,000 | 10.4 | 0.99 | 2.36 | 1.05 | 0.96 | 10.7 | 0.98 | 2.33 | 1.04 | 0.96 |
| geometric mean | 3.28 | 1.08 | 0.57 | 1.36 | 0.93 | 0.84 |
A second session with Solid and vanilla only read, for Solid / Roots in script: create 1.15, replace 0.84, partial update 1.22, select 0.89, swap 1.53, remove 1.13, create 10,000 1.08, append 1.63, clear 1.02; geometric mean 1.14. Together: in Firefox Roots is ahead of Solid on create, append, swap, remove and partial update, and behind on replace (0.69 and 0.84: 28–31 ms against 21–23 ms); select reads 0.80 and 0.89, one or two 20 µs steps; against Vue 3.5 the script mean is 3.28×. In totals Solid is ahead (0.93 and 0.94), and Roots' totals for swap and remove are 12–15% above everyone's in both sessions, the hand-written app's and Vue's included (26–27 ms against 22–24 ms; A/A 0.97–0.99). The gate's Firefox session measured 0.3.2 with the same totals as the tree (1.02 and 1.05), so this is not new in round ten; it had not been measured before, because nothing before round nine ran in Firefox. Neither it nor the replace time is explained. Template prototypes are not homed in Firefox, so there Roots is compared with the hand-written app as written: 1.5–1.7× its script time on create and append.
Against Roots 0.3.0 (the frozen tree as base; two sessions, the first with Vue and vanilla as well):
| operation | 0.3.0 script ms | tree script ms | 0.3.0 / tree, script | A/A | 0.3.0 / tree, total | A/A |
|---|---|---|---|---|---|---|
| create 1,000 | 5.80 | 4.47 | 1.30 / 1.29 | 0.97 / 0.97 | 1.05 / 1.02 | 1.01 / 1.05 |
| replace 1,000 | 10.2 | 8.93 | 1.14 / 1.16 | 0.99 / 1.02 | 1.01 / 1.02 | 0.99 / 0.99 |
| partial update | 0.23 | 0.21 | 1.07 / 1.02 | 1.05 / 1.00 | 0.98 / 0.97 | 1.02 / 0.95 |
| select row | 0.09 | 0.09 | 1.00 / 0.95 | 1.12 / 1.00 | 1.00 / 0.93 | 1.04 / 0.97 |
| swap rows | 0.21 | 0.14 | 1.45 / 1.61 | 0.97 / 0.97 | 1.06 / 1.02 | 1.04 / 1.02 |
| remove row | 0.18 | 0.13 | 1.35 / 1.19 | 1.00 / 0.96 | 1.07 / 1.03 | 1.04 / 1.03 |
| create 10,000 | 53.1 | 37.8 | 1.40 / 1.39 | 1.02 / 1.01 | 1.07 / 1.04 | 1.01 / 1.00 |
| append 1,000 | 5.05 | 3.86 | 1.31 / 1.30 | 1.03 / 1.01 | 1.06 / 1.03 | 1.01 / 0.99 |
| clear 1,000 | 4.99 | 5.05 | 0.99 / 1.01 | 0.98 / 0.98 | 0.99 / 1.01 | 0.98 / 0.98 |
Script geometric mean 1.21 and 1.20 (controls 1.01 and 0.99): creating rows is 1.29–1.40× faster, swap 1.45–1.61×, remove 1.19–1.35×, replace 1.14–1.16×. Partial update (1.07 next to a control of 1.05, then 1.02), select (1.00, then 0.95 at one timer step) and clear are not resolved; the gate's sessions of 101 and 151 runs against 0.3.2 had select's medians equal. Totals 1.03 and 1.01 (controls 1.02 and 1.00): no difference shown, as before. Against 0.3.2 the gate's sessions measured the same gains (script geometric mean 1.18–1.20 in four sessions): the rendering work is all in round ten.
Against Vue 3.5.44 (the first of the two sessions above: Roots, its A/A copy, 0.3.0, Vue, vanilla; load average 5–6). Totals, medians ± half the IQR:
| operation | Roots ms | Vue ms | vanilla ms | speedup (Vue/Roots) | Roots/vanilla |
|---|---|---|---|---|---|
| create 1,000 rows | 45.5 ±13% | 49.4 ±10% | 43.6 ±2% | 1.09× | 1.04 |
| replace all 1,000 rows | 49.3 ±3% | 55.6 ±3% | 45.8 ±1% | 1.13× | 1.08 |
| partial update (every 10th of 1,000) | 4.53 ±7% | 5.36 ±7% | 4.47 ±5% | 1.18× | 1.01 |
| select row | 0.13 ±6% | 0.48 ±7% | 0.09 ±6% | 3.56× | 1.59 |
| swap rows | 4.96 ±8% | 5.57 ±7% | 5.53 ±14% | 1.12× | 0.90 |
| remove row | 5.55 ±6% | 8.16 ±4% | 5.91 ±9% | 1.47× | 0.94 |
| create 10,000 rows | 436 ±2% | 491 ±5% | 455 ±4% | 1.13× | 0.96 |
| append 1,000 to 1,000 rows | 45.6 ±3% | 51.8 ±5% | 46.8 ±2% | 1.13× | 0.98 |
| clear 1,000 rows | 5.12 ±2% | 6.42 ±3% | 4.96 ±10% | 1.25× | 1.03 |
Geometric mean speedup over Vue: 1.34×; faster than Vue on 9 of 9 operations (A/A 0.98–1.04 per operation). Script only (click → update applied, before style and layout):
| operation | Roots ms | Vue ms | vanilla ms | speedup (Vue/Roots) |
|---|---|---|---|---|
| create 1,000 rows | 4.47 | 10.2 | 3.66 | 2.28× |
| replace all 1,000 rows | 8.93 | 16.4 | 8.11 | 1.84× |
| partial update (every 10th of 1,000) | 0.21 | 0.84 | 0.16 | 3.98× |
| select row | 0.09 | 0.43 | 0.04 | 5.12× |
| swap rows | 0.14 | 0.71 | 0.08 | 4.86× |
| remove row | 0.13 | 2.11 | 0.07 | 16.19× |
| create 10,000 rows | 37.8 | 95.8 | 37.4 | 2.53× |
| append 1,000 to 1,000 rows | 3.86 | 9.75 | 3.47 | 2.52× |
| clear 1,000 rows | 5.05 | 6.33 | 4.89 | 1.25× |
Geometric mean script speedup over Vue: 3.35× (A/A 0.97–1.12 per operation); the field session with Vue read 3.26× in script and 1.29× in totals (0.3.0: 2.89× and 1.29×). Faster on all nine operations in both: remove 16×, select 4.7–5.1×, swap 4.6–4.9×, partial update 3.6–4.0×, append 2.5–2.6×, create 10,000 2.5×, create 1,000 2.2–2.3×, replace 1.8×, clear 1.2×. In totals Roots is within 0.90–1.08 of the hand-written app.
What is exact:
JS heap after GC (run-render.mjs) | ready (app loaded) | after creating 1,000 rows | live JS objects per row (heap-objects.mjs) | JS bytes per row |
|---|---|---|---|---|
| vanilla | 1.06 MB | 1.24 MB | 4.9 | 116 |
| ivi | 1.13 MB | 1.67 MB | 17.4 | 485 |
| lit-html (patched) | 1.13 MB | 1.81 MB | 12.6 | 530 |
| Inferno | 1.13 MB | 1.99 MB | 23.7 | 773 |
| Roots 0.3.3 | 1.32 MB | 2.08 MB | 17.2 | 760 |
| Solid | 1.11 MB | 2.13 MB | 37.6 | 990 |
| Roots 0.3.0 | 1.29 MB | 2.28 MB | 24.4 | 1,042 |
| Svelte 5 | 1.25 MB | 2.44 MB | 32.0 | 1,219 |
| Vue Vapor 3.6 rc | 1.30 MB | 2.52 MB | 35.0 | 1,227 |
| Preact | 1.14 MB | 2.76 MB | 47.6 | 1,551 |
| Vue 3.5 | 1.44 MB | 3.49 MB | 48.9 | 2,115 |
A Roots row now keeps one list record (84 B), one render effect (60 B, the label), one selector binding (48 B), one ref (the label), two dependency links and one closure; at 0.3.0 it kept two render effects, four links, two refs, four closures and a selector key. The loaded app is 0.03 MB larger than at 0.3.0 and larger than every app of the field but Vue 3.5's.
DOM calls for "create 1,000 rows" (count-dom.mjs) | Roots | vanilla | Solid | Vapor | Svelte | ivi | lit-html | Inferno | Vue 3.5 | Preact |
|---|---|---|---|---|---|---|---|---|---|---|
cloneNode / importNode | 1,000 | 1,000 | 1,000 | 1,000 | 1,000 | 1,000 | 1,000 | 0 | 0 | 0 |
| elements, text nodes and comments created one by one | 0 | 0 | 0 | 0 | 0 | 0 | 4,000 | 8,000 | 8,000 | 10,000 |
| insertions | 1,000 | 1,000 | 1,000 | 1,000 | 2,000 | 1,000 | 5,000 | 8,000 | 8,000 | 10,000 |
| text, class and attribute writes | 2,000 | 2,001 | 3,000 | 3,000 | 3,000 | 2,000 | 2,000 | 8,000 | 9,000 | 7,000 |
addEventListener | 0 | 0 | 0 | 0 | 0 | 2,000 | 0 | 0 | 2,000 | 2,000 |
| writes | 4,000 | 4,001 | 5,000 | 5,000 | 6,000 | 6,000 | 12,000 | 24,000 | 27,000 | 29,000 |
reads (firstChild, nextSibling, parentNode) | 5,001 | 3,000 | 6,011 | 7,012 | 8,000 | 5,000 | 9,000 | 1 | 3 | 10,029 |
Roots asks the DOM for the least of all frameworks measured, as before; the other operations are unchanged (partial update 100 writes everywhere, select 1, swap 2, remove 1, clear 2 in Roots). Function calls of the runtime for the same 1,000 rows: 74,061 (0.3.2: 130,060; hand-written: about 4,000).
| code size | bench app, raw | gzip | notes |
|---|---|---|---|
| ivi | 9.4 KB | 4.4 KB | |
| lit-html | 11.9 KB | 5.1 KB | |
| Preact | 15.0 KB | 6.4 KB | |
| Solid | 16.5 KB | 6.8 KB | |
| Inferno | 28.0 KB | 10.2 KB | |
| Roots 0.3.3 | 57.3 KB | 19.6 KB | one file, tree-shaken and minified by roots build; 54.7 / 18.4 KB with --error-codes |
| Roots 0.3.2 | 61.1 KB | 19.7 KB | |
| Roots 0.3.0 | 59.3 KB | 19.3 KB | |
| Svelte 5 | 53.3 KB | 20.1 KB | esbuild build |
| Vue Vapor 3.6 rc | 76.1 KB | 28.7 KB | |
| Vue 3.5 | 117.9 KB | 43.7 KB | 3 files as built; 74.1 / 29.8 KB bundled and minified by esbuild |
The field's apps are bundled and minified by esbuild; KB are 1,024 bytes, gzip is per file. esbuild bundling Roots' own unbundled modules gives 57.4 KB / 20.4 KB, so Roots' bundler and minifier together now end slightly below it (at 0.3.0: equal in gzip, 10% above it raw); the shaker keeps less, the minifier alone is still 1.3–1.7% behind esbuild's (below). The unbundled layout is 5 files, 214.0 KB, 61.2 KB gzip (0.3.0: 188.1 / 53.4 KB): the runtime has grown by 14% since 0.3.0 (security hardening in 0.3.2, the new helpers in 0.3.3), and the minifier's new syntax rules hide that in the bundle.
| bundle bytes, raw / gzip -9 | 0.3.2 | 0.3.3 | with --error-codes |
|---|---|---|---|
| hello (one component) | 51,443 / 16,505 | 45,553 / 15,587 | 43,052 / 14,456 |
| bench app | 62,525 / 20,177 | 58,672 / 20,038 | 56,054 / 18,830 |
| todo example | 74,651 / 24,113 | 70,379 / 24,172 | 66,744 / 22,557 |
| gallery example | 156,691 / 45,996 | 147,828 / 45,674 | 144,636 / 44,286 |
Raw bytes fall by 5–11%; gzip by 0.7–5.6% except for todo, which is 59 B larger. Against esbuild --minify on the same shaken text the bundle is 1.3–1.7% larger raw. A hello world of the field, measured in round nine and not again: Solid 5.0 KB gzip, Preact 5.7, Svelte 17.0, Vue 3.5 22.5; Roots 15.2 KB.
Results of 0.3.0 (round eight), for the record #
Measured on 10 November 2026 on the same machine under more load, so the milliseconds are not comparable with the tables above. Session 1, 15 runs after 3 warm-up rounds, five pages (Roots, its A/A copy, the tree after round five as base, Vue, vanilla), load average 4.8 at the start and 11 at the end:
| operation | Roots ms | Vue ms | vanilla ms | speedup (Vue/Roots) | Roots/vanilla |
|---|---|---|---|---|---|
| create 1,000 rows | 76.4 ±21% | 83.7 ±13% | 74.7 ±19% | 1.10× | 1.02 |
| replace all 1,000 rows | 79.1 ±2% | 86.9 ±2% | 70.9 ±3% | 1.10× | 1.12 |
| partial update (every 10th of 1,000) | 6.47 ±5% | 7.60 ±5% | 6.37 ±3% | 1.17× | 1.02 |
| select row | 0.19 ±5% | 0.69 ±2% | 0.12 ±6% | 3.54× | 1.62 |
| swap rows | 8.27 ±5% | 8.47 ±6% | 7.90 ±4% | 1.02× | 1.05 |
| remove row | 9.12 ±5% | 11.9 ±5% | 8.27 ±3% | 1.30× | 1.10 |
| create 10,000 rows | 1205 ±11% | 1303 ±8% | 1067 ±14% | 1.08× | 1.13 |
| append 1,000 to 1,000 rows | 107 ±6% | 128 ±2% | 97.8 ±3% | 1.20× | 1.09 |
| clear 1,000 rows | 11.3 ±9% | 13.3 ±5% | 10.6 ±4% | 1.17× | 1.07 |
Geometric mean speedup over Vue: 1.29×; faster than Vue on 9 of 9 operations.
Script only (click → update applied, before style and layout), medians:
| operation | Roots ms | Vue ms | vanilla ms | speedup (Vue/Roots) |
|---|---|---|---|---|
| create 1,000 rows | 9.98 | 17.6 | 6.36 | 1.77× |
| replace all 1,000 rows | 15.5 | 25.7 | 12.7 | 1.66× |
| partial update (every 10th of 1,000) | 0.31 | 1.18 | 0.22 | 3.89× |
| select row | 0.13 | 0.62 | 0.06 | 4.96× |
| swap rows | 0.31 | 1.01 | 0.11 | 3.27× |
| remove row | 0.20 | 3.15 | 0.09 | 15.37× |
| create 10,000 rows | 144 | 274 | 95.2 | 1.90× |
| append 1,000 to 1,000 rows | 10.9 | 24.3 | 7.34 | 2.23× |
| clear 1,000 rows | 11.2 | 13.2 | 10.5 | 1.17× |
Geometric mean script speedup over Vue: 2.89×.
Session 2 ran right after it, with Roots 0.2.1 as the base and under more load (11 at the start, 27 at the end; spreads of ±5–26% on the totals, ±5–16% for Roots). The speedups over Vue of both sessions, each next to the A/A control of its cell (the A/A copy's median over Roots' median: what identical code measured there):
| operation | session 1: script speedup | A/A | total speedup | A/A | session 2: script speedup | A/A | total speedup | A/A |
|---|---|---|---|---|---|---|---|---|
| create 1,000 rows | 1.77× | 0.97 | 1.10× | 1.00 | 1.56× | 1.04 | 0.93× | 1.02 |
| replace all 1,000 rows | 1.66× | 1.03 | 1.10× | 0.99 | 1.59× | 1.09 | 1.02× | 0.99 |
| partial update (every 10th of 1,000) | 3.89× | 0.98 | 1.17× | 1.01 | 3.55× | 1.05 | 1.20× | 0.96 |
| select row | 4.96× | 0.96 | 3.54× | 0.97 | 4.82× | 1.00 | 3.50× | 1.02 |
| swap rows | 3.27× | 1.23 | 1.02× | 0.99 | 3.23× | 1.16 | 1.16× | 1.07 |
| remove row | 15.37× | 1.88 | 1.30× | 0.98 | 13.91× | 1.67 | 1.36× | 1.01 |
| create 10,000 rows | 1.90× | 0.84 | 1.08× | 0.85 | 1.84× | 1.05 | 1.16× | 0.94 |
| append 1,000 to 1,000 rows | 2.23× | 0.98 | 1.20× | 0.95 | 2.84× | 1.15 | 1.09× | 1.07 |
| clear 1,000 rows | 1.17× | 1.01 | 1.17× | 1.01 | 1.18× | 0.95 | 1.18× | 0.95 |
Geometric means: script 2.89× and 2.83×, totals 1.29× and 1.28× (session 2: faster on 8 of 9, create 1k 0.93×). The controls range over 0.84–1.88 (script) and 0.85–1.01 (totals) in session 1, 0.95–1.67 and 0.94–1.07 in session 2: the totals and the script times of the large operations are stable to about ±15%; remove row and swap rows take 0.2–0.5 ms of script and read up to 1.9× apart on two copies of the same page, so their speedups (13.9–15.4× and 3.2–3.3×) are known to that factor only: against the slower copy remove is 8× and swap 2.7×.
Against Roots 0.2.1 (session 2; 0.2.1 ships 5 unminified modules with its development checks on, 0.3.0 the bundle):
| operation | 0.2.1 script ms | 0.3.0 script ms | 0.2.1/0.3.0 | A/A | 0.2.1 total ms | 0.3.0 total ms | 0.2.1/0.3.0 | A/A |
|---|---|---|---|---|---|---|---|---|
| create 1,000 rows | 19.1 | 15.1 | 1.26× | 1.04 | 126 ±18% | 125 ±16% | 1.01× | 1.02 |
| replace all 1,000 rows | 27.8 | 24.7 | 1.13× | 1.09 | 122 ±10% | 131 ±7% | 0.94× | 0.99 |
| partial update (every 10th of 1,000) | 0.41 | 0.45 | 0.91× | 1.05 | 9.22 ±8% | 9.75 ±10% | 0.95× | 0.96 |
| select row | 0.17 | 0.17 | 1.00× | 1.00 | 0.26 ±11% | 0.26 ±5% | 1.00× | 1.02 |
| swap rows | 0.68 | 0.43 | 1.58× | 1.16 | 11.1 ±6% | 10.6 ±8% | 1.05× | 1.07 |
| remove row | 0.34 | 0.33 | 1.03× | 1.67 | 13.8 ±6% | 13.5 ±8% | 1.02× | 1.01 |
| create 10,000 rows | 228 | 192 | 1.19× | 1.05 | 1467 ±17% | 1516 ±16% | 0.97× | 0.94 |
| append 1,000 to 1,000 rows | 15.7 | 11.3 | 1.39× | 1.15 | 138 ±14% | 135 ±10% | 1.02× | 1.07 |
| clear 1,000 rows | 11.1 | 12.0 | 0.93× | 0.95 | 11.3 ±11% | 12.1 ±6% | 0.93× | 0.95 |
Geometric mean 0.2.1/0.3.0: script 1.14× (per operation 0.91–1.58), with a control of 0.95–1.67 per operation and a geometric mean of 1.12; totals 0.99× (0.93–1.05), control 0.94–1.07. On the geometric means, no difference shown. Two operations read below the control's range, that is, 0.2.1 measured faster: partial update's script 0.91× (0.04 ms), and clear, 0.93× in script and in totals (11.1 → 12.0 ms and 11.3 → 12.1 ms, 0.8–0.9 ms slower), just under the lower ends of 0.95 and 0.94 and next to an A/A of 0.95 in its own cell. Session 1 has clear with the same sign against the tree after round five (0.96× script, 0.97× total, inside that session's control). Clear was therefore listed as a possible regression of about 5%, not shown and not excluded by these sessions (in the two sessions of 11 November against 0.3.0, clear reads 0.99 and 1.01). Read cell by cell in the other direction, create 1k (1.26× next to a control of 1.04), create 10k (1.19×, 1.05), append (1.39×, 1.15) and swap (1.58×, 1.16) lean faster; it takes a quiet machine to say either. The same comparison against the tree after round five (session 1) gives script 0.98× (0.88–1.04, control 0.84–1.88) and totals 0.99× (0.91–1.10, control 0.85–1.01): inside the control except append's total, 1.10× (118 against 107 ms; A/A of that cell 0.95), which is above the control's upper end as run-render.mjs prints it and inside it when the range is mirrored (0.85–1.18). Round seven compiles this app to the same code, so nothing was expected to move here.
What was exact at 0.3.0 (heap sizes from session 2; session 1 read 2.28 MB for Roots, 2.27 MB for the tree after round five and 3.48 MB for Vue):
| memory (JS heap, after GC) | Roots 0.3.0 | Roots 0.2.1 | Vue | vanilla |
|---|---|---|---|---|
| ready (app loaded) | 1.28 MB | 1.31 MB | 1.44 MB | 1.06 MB |
| after creating 1,000 rows | 2.28 MB | 2.84 MB | 3.49 MB | 1.23 MB |
live objects kept by "create 1,000 rows" (heap-objects.mjs) | Roots | Vue | vanilla |
|---|---|---|---|
| JS objects per row | 24.4 | 48.9 | 4.9 |
| JS bytes per row | 1042 | 2115 | 116 |
A Roots row of 0.3.0 kept 4 dependency links, 4 closures, 2 render effects, 2 refs (the item and its label), one list record and one selector key; the 0.2.1 row also kept an owner with its effect scope and a render effect for the row's id, which is now written once.
DOM calls for "create 1,000 rows" (count-dom.mjs) | Roots | Vue | vanilla |
|---|---|---|---|
cloneNode / createElement | 1000 / 0 | 0 / 8000 | 1000 / 0 |
insertBefore + appendChild | 1000 | 8000 | 1000 |
textContent = | 2000 | 2000 | 2001 |
className = + setAttribute | 0 | 7000 | 0 |
addEventListener | 0 | 2000 | 0 |
| writes | 4000 | 27000 | 4001 |
reads (firstChild, nextSibling, parentNode) | 5001 | 3 | 3000 |
The other operations: partial update 100 writes in all three; select 1; swap 2; remove 1; clear 2 in Roots, 1,000 removeChild in Vue, 1 in vanilla.
| code size (bench app) | Roots 0.3.0 | Roots 0.2.1 | Vue | vanilla |
|---|---|---|---|---|
| as built: JS files | 1 | 5 | 3 | 1 |
| as built: JS, raw | 59.3 KB | 147.9 KB | 117.9 KB | 3.5 KB |
| as built: JS, gzip (per file) | 19.3 KB | 41.4 KB | 43.7 KB | 1.5 KB |
| bundled + minified with esbuild, raw (reference) | 53.7 KB | – | 74.1 KB | 2.0 KB |
| bundled + minified with esbuild, gzip (reference) | 19.3 KB | – | 29.8 KB | 1.0 KB |
Roots unbundled (--no-bundle --no-minify): files / raw / gzip | 5 / 188.1 KB / 53.4 KB |
At 0.3.0 Roots' own bundler and minifier ended at the same gzip size as esbuild on the same modules (19.3 KB) and 10% above it raw (59.3 against 53.7 KB): the minifier of that release renamed bindings and removed whitespace but did not rewrite expressions or mangle properties (docs/bundling.md §9, §10). The unbundled modules grew from 41.4 to 53.4 KB gzip since 0.2.1 (the runtime gained code in rounds four to seven), and round seven alone grew the bundle from 18.7 to 19.3 KB gzip (57.7 → 59.3 KB raw; unbundled 48.5 → 53.4 KB; session 1's size table for the tree after round five). The todo example ships 23.1 KB gzip as one bundle (71.4 KB raw) against 62.5 KB in 16 files for that release's own --no-bundle output; 0.2.1's build of it was not measured.
Template patterns of real components #
The benchmark's row is lowercase HTML with one bound class, which no Roots component looks like: real components are host primitives (Box, Text, Button), text with several interpolations, child components, slots, v-model, v-if, bound attributes. run-patterns.mjs measures seven list components, one per pattern, 1,000 rows each, four operations (fill, replace every 10th item, replace every item, clear): script time (change → nextTick). Vue is the same components compiled by @vue/compiler-sfc with the primitives written as the HTML the DOM backend creates for them; "0.3.0" is the same app built by the frozen tree of 0.3.0. Ratios are other/Roots on the medians, above 1 when Roots is faster. Two sessions on 11 November 2026, of 11 and of 21 runs after 3 warm-up rounds (load average 7–13); two numbers in a cell are the two sessions, bold when outside the A/A band in both. The bands are 0.80–1.26 and 0.86–1.16 (the widest control cell of each session, mirrored). Milliseconds are the first session's.
| pattern/operation | Roots ms | A/A | Vue ms | Vue/Roots | 0.3.0/Roots | Roots total ms | Vue total ms |
|---|---|---|---|---|---|---|---|
| prim/fill | 4.88 | 0.97 / 0.91 | 5.50 | 1.13 / 1.13 | 1.14 / 0.91 | 27.3 | 21.7 |
| prim/update10 | 0.335 | 1.01 / 1.00 | 1.50 | 4.48 / 4.63 | 1.46 / 1.08 | 2.69 | 3.72 |
| prim/updateAll | 1.69 | 1.01 / 1.01 | 3.11 | 1.84 / 1.84 | 1.03 / 1.14 | 13.8 | 14.8 |
| prim/clear | 1.94 | 1.03 / 0.98 | 2.48 | 1.28 / 1.25 | 1.01 / 1.01 | 1.99 | 2.55 |
| comp/fill | 7.04 | 1.07 / 0.96 | 13.3 | 1.88 / 1.75 | 1.10 / 1.02 | 22.9 | 28.2 |
| comp/update10 | 0.355 | 1.01 / 1.03 | 1.57 | 4.41 / 4.37 | 1.14 / 1.13 | 2.32 | 3.53 |
| comp/updateAll | 2.31 | 1.00 / 0.99 | 7.05 | 3.04 / 3.01 | 1.00 / 1.03 | 13.9 | 18.5 |
| comp/clear | 2.25 | 1.00 / 1.09 | 3.21 | 1.43 / 1.49 | 1.00 / 1.05 | 2.31 | 3.28 |
| text/fill | 1.74 | 0.95 / 0.98 | 1.45 | 0.83 / 0.81 | 1.11 / 1.08 | 10.9 | 10.5 |
| text/update10 | 0.180 | 0.97 / 1.16 | 0.675 | 3.75 / 4.44 | 1.81 / 1.94 | 1.67 | 2.09 |
| text/updateAll | 0.815 | 1.06 / 1.01 | 1.43 | 1.75 / 1.53 | 1.21 / 1.24 | 8.35 | 9.45 |
| text/clear | 0.800 | 1.02 / 0.99 | 1.18 | 1.48 / 1.50 | 1.05 / 1.02 | 0.845 | 1.24 |
| slot/fill | 6.28 | 1.00 / 0.98 | 9.16 | 1.46 / 1.59 | 0.98 / 1.00 | 19.2 | 19.2 |
| slot/update10 | 0.305 | 1.00 / 1.04 | 1.40 | 4.61 / 4.36 | 1.49 / 1.23 | 2.69 | 3.16 |
| slot/updateAll | 1.57 | 1.06 / 1.00 | 6.39 | 4.05 / 4.09 | 1.17 / 1.03 | 13.8 | 16.1 |
| slot/clear | 1.56 | 0.98 / 0.96 | 2.45 | 1.58 / 1.53 | 1.03 / 1.00 | 1.61 | 2.53 |
| model/fill | 4.00 | 0.98 / 1.01 | 3.94 | 0.98 / 1.03 | 1.00 / 1.03 | 16.5 | 15.6 |
| model/update10 | 0.430 | 0.91 / 1.04 | 1.68 | 3.90 / 4.12 | 0.93 / 1.12 | 2.39 | 3.45 |
| model/updateAll | 2.73 | 0.98 / 1.08 | 3.89 | 1.42 / 1.48 | 1.03 / 1.05 | 12.5 | 12.8 |
| model/clear | 1.49 | 1.26 / 0.95 | 2.27 | 1.52 / 1.40 | 1.02 / 0.95 | 1.56 | 2.33 |
| if/fill | 2.26 | 1.07 / 0.95 | 2.06 | 0.91 / 0.87 | 1.03 / 0.95 | 9.54 | 9.25 |
| if/update10 | 0.470 | 1.12 / 0.93 | 1.05 | 2.24 / 2.03 | 1.13 / 0.99 | 1.98 | 2.56 |
| if/updateAll | 3.41 | 0.99 / 1.03 | 2.56 | 0.75 / 0.89 | 0.92 / 1.00 | 10.5 | 9.35 |
| if/clear | 0.900 | 1.01 / 0.99 | 1.56 | 1.73 / 1.68 | 1.02 / 0.97 | 0.935 | 1.61 |
| attr/fill | 4.74 | 1.05 / 1.00 | 4.63 | 0.98 / 0.99 | 1.11 / 1.06 | 9.52 | 9.21 |
| attr/update10 | 0.380 | 0.97 / 0.96 | 1.09 | 2.86 / 2.75 | 1.04 / 1.08 | 1.39 | 2.08 |
| attr/updateAll | 2.17 | 1.05 / 0.98 | 1.86 | 0.85 / 0.81 | 1.05 / 1.04 | 5.47 | 5.49 |
| attr/clear | 0.895 | 0.99 / 1.03 | 1.78 | 1.99 / 2.00 | 0.99 / 1.02 | 0.925 | 1.85 |
- Against Vue 3.5: geometric mean 1.81× in both sessions, 0.75–4.63 per cell; outside the band on 22 and 23 of 28 cells. Replacing some rows is 2.0–4.6× faster in every pattern; creation is ahead for components (1.75–1.88×) and slots (1.46–1.59×) and inside the band for primitives (1.13×),
v-model(0.98, 1.03) and attributes (0.98, 0.99). - Behind Vue in three patterns, each outside the band in one of the two sessions:
text/fill0.83 and 0.81,if/updateAll0.75 and 0.89,attr/updateAll0.85 and 0.81;if/fillreads 0.91 and 0.87, inside both bands. These are the same three as at 0.3.0 (0.82, 0.85, 0.92): nothing in rounds nine and ten was aimed at them. - Against 0.3.0: geometric mean 1.09 and 1.07, three cells outside the band in each session and no cell resolved slower. What repeats is the partial update:
text/update101.81 and 1.94,slot/update101.49 and 1.23,prim/update101.46 and 1.08, andtext/updateAll1.21 and 1.24. The gate against 0.3.2 resolved no cell (geometric mean 1.03 at 21 runs), so these gains belong to 0.3.1 and 0.3.2; which change it was has not been measured. Fills read −2 to +14% in the first session and −9 to +8% in the second, inside the bands: round ten's creation gains are those of the benchmark's row (see "What made the difference"). - Totals with style and layout (the last two columns; the tool computes no band for totals): Roots' median is lower on 21 of 28 cells, geometric mean 1.22× in both sessions. Vue's is lower on
prim/fillby 20% in both sessions (21.7 against 27.3 ms, then 20.5 against 25.8; the A/A copy and 0.3.0 read 26.4 and 27.8, then 24.6 and 22.1), which is more than at 0.3.0 (0.93×) and is not explained: the script times of that cell are 4.9 against 5.5 ms the other way. Vue is also lower by 1–6% onmodel/fill,text/fill,if/fill,attr/fillandif/updateAll(0.89, 0.96), none of them resolved. Every variant produced the same DOM fingerprint for every pattern and operation. - Primitives against plain tags (a third session with Roots' rows written as the lowercase HTML the DOM backend creates, band 0.89–1.13): geometric mean 1.00, 25 of 28 cells inside the band. Outside it:
comp/fill0.88 andslot/fill0.87 (the lowercase rows are created 12–13% faster) andif/fill1.22 the other way, as at 0.3.0. Against Vue that session read 1.82× (text/fill0.86,attr/updateAll0.81 andif/fill0.88 outside the band,if/updateAll0.93 inside). - History, 0.3.0 against the tree before round seven (10 November 2026, band 0.76–1.32): geometric mean 1.25×; creation of primitives 1.59×, components 1.57×, attributes 1.55×,
v-if1.40×, slots 1.39×, text 2.33×; text update-all 1.56×, text clear 2.35×. That was the round that moved primitives into static templates and made a text run one binding.
Kit widgets and component instances #
run-widgets.mjs mounts 100 instances of one kind and reports the script time and, with --heap, the live JS objects and bytes per instance from two heap snapshots. No Vue column: the kit has no Vue build. "0.3.0" is the frozen tree of 0.3.0; 15 runs after 3 warm-up rounds on 11 November 2026; A/A band of the session 0.92–1.09; load average 7–8.
| kind | mount script ms (100 instances) | per instance µs | A/A | 0.3.0 ms | 0.3.0/tree | objects per instance | at 0.3.0 | bytes per instance | its copy | at 0.3.0 |
|---|---|---|---|---|---|---|---|---|---|---|
| box | 0.360 | 3.6 | 1.06× | 0.470 | 1.31× | 13.2 | 14.2 | 484 | 484 | 496 |
| div | 0.355 | 3.6 | 1.08× | 0.465 | 1.31× | 12.3 | 13.2 | 471 | 471 | 475 |
| plain | 0.700 | 7.0 | 1.09× | 0.870 | 1.24× | 39.4 | 40.5 | 1432 | 1432 | 1453 |
| todo | 1.86 | 18.6 | 0.99× | 2.10 | 1.13× | 61.9 | 62.8 | 2184 | 2184 | 2193 |
| button | 2.27 | 22.7 | 1.08× | 2.50 | 1.10× | 97.6 | 98.7 | 3379 | 3379 | 3431 |
| input | 2.13 | 21.3 | 1.03× | 2.52 | 1.19× | 112 | 113 | 3968 | 3968 | 4042 |
| field | 6.69 | 66.9 | 1.04× | 7.71 | 1.15× | 302 | 303 | 10529 | 10530 | 10490 |
| switch | 3.95 | 39.5 | 1.03× | 4.32 | 1.09× | 167 | 168 | 5891 | 5831 | 5839 |
| select | 6.19 | 61.9 | 1.00× | 6.60 | 1.07× | 215 | 216 | 7364 | 7318 | 7494 |
| badge | 0.995 | 10.0 | 1.01× | 1.10 | 1.11× | 55.8 | 56.4 | 1972 | 1876 | 1933 |
| card | 3.20 | 32.0 | 1.01× | 3.31 | 1.03× | 106 | 107 | 3808 | 3711 | 3770 |
Against 0.3.0 the median over the kinds is 1.13× (1.03–1.31). Clearly outside the band: box and div 1.31×, plain 1.24×, input 1.19×, field 1.15×, todo 1.13×; at its edge: badge 1.11×, button 1.10×, switch 1.09×; inside it: select, card. No kind is slower. Against 0.3.2 the gate's session resolved no kind in either direction (median 1.008, band 0.90–1.12), so this gain is not round ten's either; it arrived with 0.3.1 and 0.3.2.
Objects per instance are exact and are 0.6–1.1 fewer than at 0.3.0 for every kind. Bytes are not exact in this tool: two copies of the same build differ by up to 100 B per instance (the "its copy" column; badge 1,972 against 1,876). A review session with an exact control found badge and card 17–18 B per instance larger than in 0.3.2 (under 1%) with one object fewer, and box, div, plain and button 12–83 B smaller: a list's record is 84 B where it was 72 B, and a list keeps two more arrays.
History, 0.3.0 against the tree before round seven (10 November 2026, band 0.93–1.08): every kind 1.27–1.89× faster to mount, median 1.44×, with 21–52% fewer live objects per component instance (a one-prop component 84 → 41 objects, 2.8 → 1.5 KB; a kit Button 154 → 99; Field with its Input 445 → 303).
Startup #
run-startup.mjs loads each build in a warm renderer under a fresh URL (nothing cached, Cache-Control: no-store) and reports, from the request of the entry script, the first render: the moment the mount point gets its first child. "fetch" ends with the last module's response; "script after fetch" is the rest (compile finalization, evaluation, mount). 25 loads on localhost, 20 over an emulated network (25 ms latency, 10 Mbit/s, gzip); medians ± half the IQR; 11 November 2026, load average 6–9. Ratios are to Roots' bundle, above 1 when Roots renders first; bold outside the session's A/A band.
The field, bench app (each framework's js-framework-benchmark entry, built by its own compiler and esbuild; they mount into an empty element of the page, and their first render is that element's first child element):
| bench app | localhost: first render ms | vs Roots | script after fetch ms | 25 ms, 10 Mbit/s, gzip: first render ms | vs Roots | JS raw | gzip |
|---|---|---|---|---|---|---|---|
| A/A band | 0.92–1.08 | 0.92–1.09 | |||||
| Roots 0.3.3 | 8.85 ±0.51 | 7.08 | 56.75 ±2.86 | 57.3 KB | 19.6 KB | ||
| Vue 3.5.44 (3 files) | 16.40 ±1.00 | 1.85× | 11.26 | 111.09 ±3.66 | 1.96× | 117.9 KB | 43.7 KB |
| Vue Vapor 3.6 rc | 10.18 ±0.63 | 1.15× | 8.42 | 62.12 ±1.54 | 1.09× | 76.1 KB | 28.7 KB |
| Svelte 5.57 | 9.55 ±0.28 | 1.08× | 7.90 | 54.94 ±2.07 | 0.97× | 53.3 KB | 20.1 KB |
| Solid 1.9 | 5.32 ±0.22 | 0.60× | 3.52 | 41.75 ±1.55 | 0.74× | 16.5 KB | 6.8 KB |
| Preact 11.1 | 5.36 ±0.21 | 0.61× | 3.88 | 41.17 ±1.32 | 0.73× | 15.0 KB | 6.4 KB |
| lit-html 3.3 (patched) | 5.17 ±0.28 | 0.58× | 3.41 | 40.48 ±1.59 | 0.71× | 11.9 KB | 5.1 KB |
| Inferno 10 | 5.07 ±0.21 | 0.57× | 3.32 | 45.40 ±1.31 | 0.80× | 28.0 KB | 10.2 KB |
| ivi 5.1 | 4.78 ±0.24 | 0.54× | 2.85 | 38.37 ±1.82 | 0.68× | 9.4 KB | 4.4 KB |
Roots renders first among the three frameworks of its own size class (Vue 3.5 1.85–1.96×, Vapor 1.09–1.15×; Svelte is level: 1.08 and 0.97, inside the bands) and later than the five small ones: Solid, Preact, lit-html, Inferno and ivi render in 4.8–5.4 ms where Roots needs 8.9 ms, and in 38–45 ms over the network where Roots needs 57 ms. The difference is the bundle: they ship 4.4–10.2 KB gzip, Roots 19.6 KB (see "What is still slower"). Vapor's ratio over the network (1.09) sits on the upper edge of that session's band.
Output layouts of Roots itself: the bundle (roots build), --no-bundle as shipped (one modulepreload link per module and the compile hint //# allFunctionsCalledOnLoad in each), the same files with links and hints removed, and the bundle of 0.3.0:
| bench app | first render ms | vs bundle | fetch ms | script after fetch ms | JS files | gzip |
|---|---|---|---|---|---|---|
| localhost (A/A band 0.93–1.07) | ||||||
| bundle | 9.10 ±0.38 | 1.82 | 7.38 | 1 | 19.6 KB | |
| bundle of 0.3.0 | 8.80 ±0.32 | 0.97× | 1.69 | 7.13 | 1 | 19.3 KB |
| unbundled, preload + hints | 11.47 ±0.28 | 1.26× | 5.47 | 6.14 | 5 | 61.2 KB |
| unbundled, neither | 13.45 ±0.57 | 1.48× | 7.22 | 6.31 | 5 | 61.2 KB |
| Vue 3.5.44 | 16.85 ±0.45 | 1.85× | 5.31 | 11.63 | 3 | 43.7 KB |
| 25 ms, 10 Mbit/s, gzip (A/A band 0.90–1.11) | ||||||
| bundle | 52.51 ±1.27 | 46.63 | 5.59 | 1 | 19.6 KB | |
| bundle of 0.3.0 | 54.93 ±2.05 | 1.05× | 49.34 | 5.57 | 1 | 19.3 KB |
| unbundled, preload + hints | 91.68 ±1.03 | 1.75× | 85.84 | 5.81 | 5 | 61.2 KB |
| unbundled, neither | 124.69 ±2.85 | 2.37× | 119.13 | 5.70 | 5 | 61.2 KB |
| Vue 3.5.44 | 107.18 ±2.84 | 2.04× | 98.95 | 7.92 | 3 | 43.7 KB |
| todo example | first render ms | vs bundle | fetch ms | script after fetch ms | JS files | gzip |
|---|---|---|---|---|---|---|
| localhost (A/A band 0.99–1.01) | ||||||
| bundle | 12.30 ±0.49 | 1.57 | 10.73 | 1 | 23.7 KB | |
| bundle of 0.3.0 | 11.86 ±0.53 | 0.96× | 1.67 | 10.26 | 1 | 23.1 KB |
| unbundled, preload + hints | 14.84 ±1.48 | 1.21× | 12.07 | 2.79 | 16 | 70.8 KB |
| unbundled, neither | 22.19 ±1.88 | 1.80× | 13.13 | 8.78 | 16 | 70.5 KB |
| 25 ms, 10 Mbit/s, gzip (A/A band 0.95–1.06) | ||||||
| bundle | 61.83 ±1.93 | 53.22 | 8.32 | 1 | 23.7 KB | |
| bundle of 0.3.0 | 60.20 ±2.55 | 0.97× | 52.66 | 7.97 | 1 | 23.1 KB |
| unbundled, preload + hints | 124.92 ±2.77 | 2.02× | 122.12 | 2.65 | 16 | 70.8 KB |
| unbundled, neither | 162.34 ±4.22 | 2.63× | 154.29 | 8.23 | 16 | 70.5 KB |
The bundle renders first in every condition: one request instead of 5 or 16, and a third of the bytes. Preload links turn the unbundled graph's fetch rounds into one; the compile hint moves compilation off the main thread and under the fetch (todo, localhost: 8.8 → 2.8 ms of script after the last response). The bundle has no compile hint, and round ten measured that it should not get one: see "What made the difference" (it helps the todo example and slows the bench app).
Against earlier bundles the first render has not moved. In the tables above the bundle of 0.3.0 reads 0.97 and 1.05 for the bench app and 0.96 and 0.97 for the todo example; the 0.96 (localhost, outside a band of 0.99–1.01) did not repeat in a session of 40 loads (11.01 against 11.18 ms: 0.99, band 0.98–1.02). Against 0.3.2 at 40 loads: todo 0.99 (band 0.98–1.02), bench app 1.01 (band 0.99–1.01). No difference shown in either direction.
The times of 0.3.0's own startup tables (bundle 13.3 ms on localhost, Vue 24.4 ms, todo 23.1 ms) were measured on 10 November under more load and are not comparable in milliseconds; the ratio to Vue was 1.84× then and is 1.85× now. Cold renderers and repeat visits (--proc cold|repeat) were not measured.
What made the difference #
Round ten (0.3.3). Every item was kept or dropped by a gate on the combined tree against the frozen 0.3.2 checkout, in sessions with their own A/A control (10 November 2026, Chromium 143, Firefox 144, WebKit 26). Ratios are 0.3.2 / tree on script time, with the control of the same row in brackets.
- Template prototypes live in an inert document. The DOM backend moves a template's prototype into the content of one shared
<template>(the new optional backend methodhome), which is where Solid, Svelte, ivi and Vue Vapor clone their rows from. In Chromium a clone from there is cheaper, also for hand-written code ("vanilla with a template" in the tables). The tree against the same tree with this switched off: create 1,000 1.21 [1.04], create 10,000 1.17 [1.02], append 1.11 [1.00], replace 1.07 [0.99]. It is off in Firefox, where smoke runs showed no gain, and for templates with a custom element, a<video>or an<audio>(see "Semantics notes"). In WebKit it is on; one session measured a small gain on create 10,000 and a second could not resolve it (control 0.78–1.09). - The list update is a scan over two key arrays.
ListBlock.updatekeeps the keys of the previous render, finds the common prefix and suffix and a two-element exchange by comparing keys, and touches only the records that differ; it is split into the scan and one function per case. Keys are still computed eagerly, so key semantics are as before. Swap 1.48–1.65 in four sessions [0.97–1.00], remove 1.17–1.41; function calls for a swap 2,068 → 1,073 (exact). - Lean helpers for nodes the compiler knows. For a plain element of a static template on a DOM target the compiler emits
setElText,bindElText,bindClassElandonEl, which skip the tests the general helpers make for blocks, text nodes and checkboxes, and tellsforBlockwhen every item is one element. Against the new runtime with the general compiler output: append 1.16 and 1.14 [1.02, 1.03], create 10,000 1.07 and 1.12 [0.97, 1.03], create 1,000 1.10 and 1.04 [0.97, 0.95]: resolved on append and create 10,000 in both sessions, on create 1,000 in one. - Row handlers are shared. A handler in a
v-forrow is one descriptor per handler site and render of the list, registered with the row as its data (onRow), where each row had two closures. No time is resolved for it (create 0.98 and 0.94 [0.97, 0.95]); it is kept for the count: 2 objects and 56 B fewer per row. - A selector class needs no render effect.
:classasking acreateSelectorresult registers a 48-byte binding with the selector, which queues it into the render flush when its key gains or loses the selection. Kept for memory only: 4 objects and 148 B fewer per row; select is level with 0.3.2 in four sessions of 101 and 151 runs (medians equal, trimmed means 0–3 µs apart, as far as two copies of one build differ). - The record is the item's dependency: one object and 22 B fewer per row, no time.
- Together, per 1,000 rows of the benchmark app (exact): function calls for create 130,060 → 74,061; live JS objects per row 22.9 → 16.0; bytes per row 786 → 560.
- These are the gains of the benchmark's row. Measured on other row shapes (11 runs, controls 0.93–1.08): creation 1.12 with bound attributes, 1.14 with a static and a dynamic class, 1.21 non-keyed, 1.18 with two root elements per item, 1.10 with a list inside each row, 1.07 with a component inside each row. The swap gain carries wherever a key exists (1.58–2.18). Nothing measured was slower than 0.3.2 in Chromium.
- The minifier writes shorter syntax: no
;before}, merged declarations, unbraced single statements,!0/!1,void 0, and no description on symbols that are only keys. The bundle is within 1.3–1.7% ofesbuild --minifyon the same text (0.3.0: 10% above it raw).onis split fromonWith(the modifier half leaves apps without event modifiers) andcloneno longer tests the backend in DOM bundles. - Dropped: a compile hint on bundles.
//# allFunctionsCalledOnLoadas the bundle's first line makes the todo example render 8–9% earlier on localhost and the bench app 7–9% later (controls 0.97–1.04), with nothing on the gallery and nothing over the emulated network. The rule was "better on two apps, worse on none". - Not needed: lazy keys (not recomputing the key of an identical item object), which would have changed what happens to an item whose key field is mutated in place.
Rounds four to seven. The session of each round measured its steps against the tree before it, under load, with the method of the time; what is quoted here is what round eight's tables (0.3.0) show for the sum, and the exact counts.
Round seven (what real components are made of; the benchmark row compiles to the same code as before):
- Host primitives in static templates.
Box,Text,Button,ImageandSpacersubtrees are cloned like lowercase elements (Input,CheckboxandLinkstay$.elementcalls); the root of av-foritem has its static class and style baked into the prototype; the backend's new optionaladopt(node, tag)gives a copiedBoxorSpacerthe statecreateElementwould have given it. Before, no compiled Roots component took the clone path at all: all of them are written with primitives. Patterns:prim/fill1.59×,comp/fill1.57×,attr/fill1.55× against the tree after round five. - One binding per text run. Adjacent text and interpolations compile to one getter that concatenates the parts, written as its element's text content where the run is all the element holds: one text node and one render effect where each part had its own.
text/fill2.33×,text/updateAll1.56×,text/clear2.35×;v-ifrows, which hold text, 1.34–1.50×. - Development checks are off in production builds.
roots builddefinesDEVfalse (the bundle folds the branches away;--no-bundlereplaces__ROOTS_DEV__at link time), and prop validation moved under it: until then every component validated its props at mount in every build, reading each declared prop once. - Lighter component instances. Hook lists,
$refsand the default cache are made on first use; components that were passed no attrs share one empty attrs view; the props and attrs views share one Proxy handler and the_ctxproxies another, instead of closing over their instance. Per-node state lives in a symbol-keyed property instead of a WeakMap. Objects per one-prop component: 84 → 41. - A bound attribute's first value is not written when it only says "absent" (
null,undefined, a removingfalse): a kit Input no longer callsremoveAttributefor every attribute it never had. - Bindings that read nothing reactive are released after their first run (
:label="'Save'", a constant behind a getter): they cost no memory and nothing to dispose. - Kit: a part without pass-through evaluates its class to the skin's string directly (no array, no
normalizeClass, no per-key closures). - Together these are the widget table: 1.27–1.89× on mount, 21–52% fewer objects per component instance.
- Unbundled output:
modulepreloadlinks with SRI for the static import graph, and the compile hint, on DOM targets and inroots dev(from the startup tables: first render 1.21× earlier on localhost and 1.46× on the emulated network for the bench app, 1.35× and 1.41× for todo, each against the same files without them).
Round five (the row's object graph and the update path; measured then against the tree after round four, now part of the 0.2.1 comparison above):
- A list item is a scope, not an owner. A
forBlockhas one owner for the whole list; each item is a record that is the scope of its effects and holds the owners nested in it. A row costs no owner, no effect scope and no set entry. - Render effects are slimmer (
EffectBase, see Reactivity): the benchmark row keeps 2 of them, 60 bytes each. - Kept rows are not touched. An update leaves a kept item alone when its value is the same object and it received no index or key ref; the list's block keeps no node list of its own.
- The key's text is written once. An interpolation that shows the key of its keyed
v-for({{ row.id }}under:key="row.id") compiles to$.setText, no render effect: a kept row only ever receives an item with the same key. - Event handlers run through
batchCall(fn, arg), which allocates no closure per event. - Measured and not adopted: one grouped effect per row (faster creation, slower partial updates), rows without an item ref, a straight-line clone walk, hoisted row handlers, an inline render mode without
_ctx, a patch ofEvent.prototypeforcurrentTarget. Each was inside the noise or cost something Roots leads in. (Round ten shipped two of them in another form: the record is now the item's dependency, so the row has no item ref, and row handlers are shared; both are kept for their exact counts, not for time.)
Round four (per-row work in the runtime and the DOM backend):
- An interpolation alone in its element is the element's text content: no empty text node in the prototype,
clonecollects the element, the first run writestextContent(the DOM makes the text node itself, as hand-written code does). - Registering a listener allocates nothing. The backend's optional
listenkeeps the compiled handler and its component in symbol-keyed properties of the node; one invoker serves every listener. No closure and no remover per row. - The template walk is one backend call (
collect: direct property reads into a preallocated array, an ancestor stack instead ofparentNode), and the prototype is cached in the template for the first backend. A created row costs 5 DOM reads (firstChild3,nextSibling2: 5001 for 1,000 rows). - A bound class allocates its node's state only once it has a class to write:
:class="{ danger: selected }"on a thousand unselected rows keeps none. - A swap needs no key map: two items that exchanged places move directly (2
insertBefore, 1 for neighbours). DEVexists since this round, with the R215 duplicate-key scan of pure insertions under it; the render queue keeps its storage across flushes. ADocumentFragmentfor insertions was measured and not adopted (slower in script).
Round six changed no runtime code. It measured the todo example, the gallery and the kit instead of the benchmark and found that rounds one to five had optimised a path only the benchmark app reached (no compiled component contained a static template), that every built app ran its development checks, and that the unbundled layout was the one place Roots lost to Vue (first render over a network). Round seven is its plan; the tools it wrote are run-startup.mjs, run-patterns.mjs, run-widgets.mjs and ab-reactivity.mjs.
The earlier rounds, measured on the earlier machine (the numbers in them are that machine's, against Vue 3.5.45 and the unbundled build):
Round two (0.1.7):
- Event delegation (DOM backend). Listeners for the common bubbling events (
click,input,keydown, pointer and mouse events,focusin/focusout…) are no longer added to each element: every document gets one listener per event type, and an element keeps its handlers in a property (a symbol per event type). The document's listener walkscomposedPath()from the target up and calls the handlers like native listeners (registration order,currentTargetset, untilstopPropagation()), Solid's approach. Creating 1,000 rows no longer callsaddEventListener2,000 times nor allocates 2,000 removers: create 1k script 8.9 → 7.7 ms in an A/B run. The exact semantics, and what differs from per-element listeners, are indocs/runtime.md§6. createSelector(new, opt-in,docs/runtime.md§2):isSelected(key)makes a binding depend on its own key only, so selecting a row re-runs two class bindings instead of 1,000: select script 0.39 → 0.13 ms (Vue withv-memo: 0.42).- Template walk programs. A static template now precomputes the moves (first child, next sibling, parent, take) that reach its collected nodes in a copy, and never visits nodes after the last one needed in an element (the row's remove icon and last cell).
- The reactivity work above (render effects are reactive effects).
Round one (0.1.4):
- Static templates (template compiler + runtime). A subtree of two or more plain HTML elements becomes a spec hoisted to module level (
$.template([...])); the render code clones it ($.clone, onecloneNode(true)on the DOM) and binds only the nodes that need it ($.bindText, events,:class…, holes for blocks and components via$.insert). The prototype is built once per backend with the backend's owncreateElement/setProp, so every security check still applies (tests: R304 refused, R307 dropped). Only state-free things go into a spec (rules indocs/templates.md§3); backends withoutcloneNode(the terminal) get the same nodes built by the helpers, and a test checks that the clone path and the helper path give the same tree at every step. Create 1,000 rows: script 23.5 → 8.6 ms. - Class strings without objects.
:class="{ danger: row.id === selected }"on an element compiles to() => ((…) ? "danger" : ""), whatnormalizeClassmakes of the object, so re-running a row's class allocates nothing (select: Roots went from slower than Vue to 1.1× faster in script time). - One object per binding. A
RenderEffect(a subclass of the reactive effect) holds the binding's state (node,getter,last,key,part) and runs a shared update function per binding kind with a shared scheduler. Before: a job object, an effect, a wrapper closure, a scheduler closure and the binding closure per binding. - Render jobs re-run only when a source changed (an unchanged computed or prop stops there), are queued by append (subscribers are notified in creation order; the old code did a binary search and a
spliceper job) and allocate nothing per run. - Class merging without maps. A node with one class source keeps it in two fields; a bound class compares the normalized string before touching the merge, so selecting a row re-runs 1,000 tiny bindings and writes 2 attributes.
- Keyed lists. Common prefix and suffix stay in place; an insertion or a removal in one place needs no key map; otherwise a map for the changed middle only, and LIS moves. The
index(and objectkey) refs are only made when the item function declares them; a plain array is used as it is (reactive arrays are still read item by item). A list that is all its parent holds and loses every item is removed with one optional backendclear(textContent = ""), hooks in the usual order. Remove a row: script 0.25 ms vs Vue 2.4 ms. - Fewer allocations elsewhere:
on()without modifiers takes a direct path, the DOM listener remover is a bound function, rendering runs throughrunInScope(no closures per render), no emptyclasswrites on fresh nodes. Memory after 1,000 rows went from 4.5 MB to below Vue's.
What is still slower, and why #
Where Roots is not the fastest of what was measured, and why, as far as that is known.
- Select against Vue Vapor: 0.89–0.94 in three sessions. Selecting a row is 0.085–0.095 ms of script in Roots: about 70 function calls on the way from the delegated listener through the handler and the selector to the render flush, most of them run once and cold, at roughly 1 µs each. The selector binding of round ten removed 19 of those calls and no measurable time. A prototype that wrote the class synchronously inside the handler measured 1.19–1.29 on select; it was rejected, because it breaks "all DOM writes of a tick happen in the flush". A shorter path from event to write is work for a later round.
- Level with Vapor, not ahead, on create, replace, partial update, create 10,000 and clear; level with Solid on create and create 10,000. On creation the remaining cost per row is known (exact): 74 function calls and 17 objects, of which the item's record with its tracked
.value, one render effect for the label and the_ctxproxy between the template and the component's bindings are the larger parts. An inline render mode without that proxy, and a row whose item is the raw object, were measured as prototypes (1.03–1.05 each); the second was rejected on semantics (a watcher of the item alias would miss an identity change under a kept key), the first is deferred. - Totals: Vapor 0.96, Solid level. With style and layout, Vapor's medians are 4% lower in both sessions and Solid's are 5–6% lower on replace, although Roots runs less script. Not explained; the rows have the same DOM (the fingerprints agree).
- Against hand-written code: with its row in a
<template>the hand-written app creates 1,000 rows in 0.63–0.65× of Roots' script time, 10,000 in 0.76–0.79×, and does select, swap, remove and partial update in 0.42–0.68×, at 0.04–0.15 ms. The floor of this design for a row that binds nothing was measured at 1.13–1.15× of hand-written code; the rest is the bindings above. In totals the two are within 3–6%. - Firefox (two sessions, see the table): replace is 0.69–0.84 of Solid's time, and the totals of swap and remove are 12–15% above every other app's, as they were in 0.3.2. Neither has been looked into. Homing template prototypes is off there; in WebKit it is on and its gain is not resolved (a small gain in one session, nothing resolved in another). Select in Firefox against 0.3.2 is not resolved either: level in five gate sessions, two timer steps slower in two.
- Three template patterns against Vue 3.5 (
run-patterns.mjs, three sessions, each outside the band in one or two of them):text/fill0.81–0.86, a row whose element holds text with interpolations, which Vue creates with fewer steps;attr/updateAll0.81–0.85, replacing every item of rows with two bound attributes;if/updateAll0.75–0.93 (andif/fill0.87–0.91), rows that hold av-if/v-else, where each Roots branch is a block with its own owner and effect. Nothing since round seven was aimed at them. In totals Vue is also 20% lower onprim/fillin two sessions, which is not explained. - First render and size against the small frameworks. Solid, Preact, lit-html, Inferno and ivi render the bench app in 0.54–0.61× of Roots' time on localhost and 0.68–0.80× over the emulated network, from bundles of 4.4–10.2 KB gzip against 19.6 KB. About 82% of the bench bundle's gzip is a base every Roots app ships (a one-component app is 15.2 KB): the component instance, owners and scopes, the scheduler, event delegation, deep-ref proxies, the attribute rules of the security hardening. The measured cheap items (
--error-codes: −1.1 to −1.6 KB gzip; feature flags for parts an app does not use, simulated at −2 to −19%) reach about 16 KB for the bench app; below about 9 KB for a hello world the component model itself would have to change. What holds: Roots is the smallest and the first to render of the frameworks with a full component model and deep reactivity measured here (Svelte 5, within 3% in size and level on first render; Vue Vapor; Vue 3.5). - Memory against ivi and lit-html: 760 B of JS heap per row against 485 and 530, with the same number of objects as ivi (17.2 against 17.4) and more than lit-html (12.6). The loaded app's heap (1.32 MB) is the largest of the field after Vue 3.5's.
- The bundle has no compile hint, on purpose: measured on three apps, it helps one and slows one (round ten). The todo bundle spends 10.7 ms between its response and the first render where the hinted unbundled modules spend 2.8 ms; the bundle still renders first (12.3 against 14.8 ms) because it is one request.
- Totals are bounded by the browser. For create, replace, append, partial update, swap and remove, style recalculation and layout of 1,000–10,000 table rows are 80–97% of Roots' total, so the field's geometric means are within 0.96–1.29 of Roots' in totals (Preact 1.83). 2× over Vue on totals is not reachable for these operations by any framework.
- Not measured: cold renderer processes and repeat visits (HTTP cache, V8's code cache); phones; Safari itself (WebKit 26 was run through Playwright in the gate only). The field tables are Chromium; Firefox has two sessions with Solid and one with Vue. The frameworks other than Vue 3.5 were measured on the benchmark's row only, not on the template patterns or the kit. Vue Vapor is a release candidate.
Semantics notes #
What the speed work changed for app code and for backends, newest first, each checked against the code; the reference text is in docs/runtime.md, docs/templates.md, docs/build.md, docs/bundling.md and docs/CONTRACT.md.
Round ten (0.3.3) #
The owner's rule for this round: behaviour may change where a proof of concept is faster and the result is consistent and correct, and every change is listed. This is the list.
Templates and rendering:
- A fresh copy of a static template belongs to an inert document in Chromium and WebKit until it is inserted (
ownerDocument.defaultViewis null); in Firefox it belongs to the page's document as before. Code may rely on neither. Until insertion, URL-reflecting properties answer the unresolved attribute (a.hrefis"rel/x",a.protocolis":"; alsoimg.src,form.action): a function ref or a directive'screatedhook that reads them sees the raw value (readgetAttribute, or the property inmounted). An<img>with a staticsrcstarts loading at insertion, not at copy. Unchanged from 0.3.2, because their templates stay in the page's document: templates with a custom element (a dash in the tag, oris), with a<video>or with an<audio>(WebKit selects no<source>for a media element copied in an inert document). - The
v-foritem is the list's record, not ashallowRef..valueis tracked and read-only;isRef(item)is false. Index and key arguments are still refs. A template that assigns av-foralias or a slot prop (@click="n++",row = other,--i) is compile error C254; in 0.3.2 it compiled, and for the item alias wrote the row's own ref (the row showed the value until the list next refreshed it). A property of the item (row.done = true) is writable as before. A hand-written render function that assignsitem.valuegets a TypeError. - The list scan compares kept items with
===when the item function takes one parameter: an item that changes between 0 and −0 under the same key no longer re-runs its readers. The list keeps the last keys array and a copy of the items (about two pointers per row); a record is 84 B (was 72 B). - Handlers of static-template nodes in
v-forrows are shared: one function per handler site and render of the list, called with (row, event). A function-expression handler is evaluated per event instead of once per row (visible only to code comparing the function's identity). Handlers that read the index or key alias or a destructured alias, empty handlers and handlers in slot bodies stay a closure per row. - A selector class is written without a render effect (
:classof the formf(selector(key))in a keyedv-for), only when the callee is declared in the component's<script setup>asconst name = createSelector(…)withcreateSelectoran import. Any other callee (an import, a composable's result, a prop, alet, a plain function) is bound as a class effect as in 0.3.2, because it may be a ref whose selector is replaced later. The class is written at bind time and then in the render flush in creation order, like every binding. The key is taken at bind time. - Compiled modules for DOM targets call new helpers (
setElText,bindElText,bindClassEl,onEl,onWith,rowHandler,onRow,bindSelected), so a module compiled for web, pwa, ext, ios or android needs a runtime that exports them. The terminal target and a compilation without a target emit what 0.3.2 did.onwith modifiers isonWith(same behaviour;onwith a fourth argument still works).forBlock(source, render, key, single):singleis the compiler's promise that every item is one host element. - R307 and R308 have the same text and console output and are now raised through the DOM backend's own
warn; they still do not reach thewarnHandler.
Bundler and build:
- Minified bundles look different and behave the same, with one exception visible by reflection: a module-level
const x = Symbol("roots.…")becomesSymbol()when the chunk does not export it and uses it only as a key or in an equality test. A symbol used as a value anywhere (inject(key)) keeps its description, so R206 names its key as before (docs/bundling.md). CLONE_BACKENDis true in bundles for DOM targets:cloneno longer asks the backend forcloneNode/firstChild/isElement. A custom backend without them needs--no-bundle(CONTRACT §5).roots build --error-codes(opt-in, bundles only): a message becomes its code ([R208]);error.code, the handler's code argument and the component trace are unchanged (docs/build.md).
Backends (CONTRACT §5, both optional): home(proto) and listenElement(node, event, handler, data, invoke?).
Round nine (0.3.1) #
- A
ReactiveEffectis 96 B (was 88 B); one whose function declaresonCleanupis 144 B (was 226 B). Itsscheduleris fixed at construction (nullcounts as none).onCleanupand R106 behave as documented. - New exports of
runtime/reactivity.lispfor list bindings:trackDep,triggerDep,selectorBind,selectorUnbind;roots/runtimedoes not re-export them. - Dialect:
define-classtakes(:constructor-fields t);(some? x)and(none? x)compile tox !== nullandx === null(README, "Semantics to know").
Rounds four to seven (0.3.0) #
Production builds:
DEVis false inroots build, bundled or--no-bundle;roots devandroots runare development builds (DEVtrue,process.env.NODE_ENV"development"). UnderDEV: prop validation (below), the R215 duplicate-key scan of a pure insertion and the check of a swap's two keys, andinsert's R224 anchor check. The key map of a reordered middle still reports duplicate keys in production.- Props are validated in development builds only.
R202(missing required prop) andR203(wrong type, failed validator) are not reported by aroots buildoutput and avalidatoris never called there, as in Vue's production build. Values, defaults, Boolean casting andR204do not depend on it. Adefaultfactory runs at the first read that needs it (at mount in development, where validation reads every prop; possibly later in production), always with its component current. - Unbundled DOM output carries a
modulepreloadlink withintegrityper statically imported module and starts each runtime,.vue,.tsand.lispmodule under 128 KB with//# allFunctionsCalledOnLoad: an emitted module edited by hand no longer loads, as was already true ofapp.js.
Templates and rendering:
- Text runs. Adjacent text and interpolations are one text node kept by one binding (
childNodes.lengthchanges;textContentand the serialized DOM do not). An interpolation that throws takes its whole run with it, where it used to blank its own part. An element whose only content is a run has no text node while the text is empty. - The key's text is written once.
{{ row.id }}under:key="row.id"is not bound; an object used as key is ($.setTextdeclines it and$.bindTextbinds it), and inside a slot that binds slot props nothing is taken for the key. - Host primitives are cloned. A subtree of two or more
Box,Text,Button,ImageorSpacerelements is a static template; the staticclassandstyleof av-foritem's root are applied once to the prototype. A bakedImage srctherefore exists on one detached prototype per template, which the browser may fetch once (lowercaseimgalready behaved this way). - A bound attribute's first value is not written when it only says "absent":
null,undefined, andfalseexcept foraria-*,data-*,contenteditable,draggableandspellcheck. Always written:class,style,value,to/replaceon aLink,type,aria-hidden,textContent, fallthrough attrs, bindings on a component, and any binding the compiler marks as following an earlier write of the same name (bindAttr(node, name, getter, true): after a static attribute, an objectv-bindor an earlier binding). Hand-written render code that callssetAttrand thenbindAttrfor one key has to pass that fourth argument itself. On the terminal backend a prop whose first bound value isfalseis absent from the node until its first update. - Bindings that read nothing reactive are released after their first run and stopped; the effects of blocks,
modelandLinknever are. A scope that released an effect stops its remaining effects in no particular order. - One owner per list. Inside a
v-foritem's render,getCurrentScope()returns the item's record: aScope(onScopeDisposeworks, the item's effects and nested content end with it), not anEffectScopewithrunandstop. A kept item whose value is the same object and that received no index or key ref is not touched by an update. - Component instances. Components that were passed no attrs share one empty
attrs/$attrs/useAttrs()object;$refsis created on first access;propsandattrsviews also throwR204onObject.defineProperty,Object.setPrototypeOfandObject.freeze/seal/preventExtensions(deletealready did). - Class order corner case: a bound class whose first value is empty no longer claims the first position among a node's class sources; a source that appears before the bound class becomes non-empty is listed first (
"x danger", not"danger x"). R225(new, warning):onwas given a handler that is not a function, and ignores it.R224(new, development):insertwith the parent as its own anchor.- Kit:
ptandunstyledstay reactive for a part's class; which other attributes and handlers a part takes fromptis decided at setup (unchanged, now documented).
Reactive core:
- The shared
onCleanupthrowsR106wherever its effect is not the node collecting dependencies: after the run (as before), and now also during it insideuntrack(...), inside a computed's getter and inside awatchgetter. It used to register on the running effect there. This concerns only functions that reachonCleanupwithout declaring it (throughargumentsor a rest parameter) and effects made withcleanup: "run"; a function that declares the parameter keeps its own registrar, which works anywhere and at any time. effect(fn, { cleanup: "run" })is new: the caller promises thatfncallsonCleanuponly during its run.- Scopes do not own computeds. A computed created inside a scope is not stopped with it; it leaves its sources when its last subscriber does, and one that something outside the scope still reads stays alive.
- A parent scope stops its remaining children in no particular order once one of them has stopped on its own (the last sibling takes its slot), as in Vue; component owners are not affected.
runtime/reactivity.lispexportsscopeRemoveEffect,batchCall,EffectBaseand the typeScopefor the component runtime;roots/runtimedoes not re-export them.
Backends (CONTRACT §5; all optional, the terminal and test backends work without them): isElement, collect, setTextContent, listen, adopt. Two requirements are new: a node fresh from createElement must have every unset prop absent (type and aria-hidden excepted), and host nodes carry the runtime's per-node state in one symbol-keyed property, Symbol("roots.nodeState"), visible to Object.getOwnPropertySymbols (a non-extensible node gets a WeakMap entry instead). The DOM backend keeps a primitive's base class, a Checkbox's inner nodes and the delegated handlers in symbol-keyed properties too.
Compiler and bundler:
loopnames. The variables aloopgenerates (result,end, the counter ofrepeat) carry the loop's number from the second loop of a compilation unit on (result2,end2), so two loops in one function no longer redeclare them;repeat's counter is_i, a name the body cannot mean./* @__PURE__ */is emitted on$.defineComponent({…}), on hoisted static templates and ondefcomponent;(declaim (pure f))and(pure (f …))are new in the Lisp dialect.- Minified names are letters only (52 letters, no digits), so that they cannot collide with the names Bun's bundler generates when it compiles a terminal app to one executable.
B134is an error for a named import of a name its module does not export, used or not (ESM refuses to link such a program; the unbundled modules would fail in the browser).ns.missingon a namespace import stays a warning and readsundefined.
The earlier rounds:
Round three (0.1.8): no behaviour change. An effect that re-runs itself more than 100 times in one flush is still stopped with R102 (now counted per flush with a mark and a map instead of two fields), and the benchmark's method changed as described under "Method".
Round two (0.1.7):
- Event delegation (DOM backend): see
docs/runtime.md§6. Roots handlers on the path of an event run in native order among themselves, withcurrentTargetset; what differs from per-element listeners: a native listener added by your own code to an ancestor runs before the Roots handlers below it (and itsstopPropagation()stops them),stopImmediatePropagation()does not stop the other Roots handlers of the same element,eventPhaseis the bubbling phase, and an event dispatched on an element that is not in a document reaches no Roots handler. A handler that throws is reported like a throwing native listener, and the handlers above it still run. createSelector(source)is new (roots/runtime,docs/runtime.md§2).- A computed that reads itself while it computes always reports
R101. Before, a computed with subscribers took the read fast path and returned its stale value (and linked to itself). - Compiler: module-level
defvar/defparameteremitvarinstead oflet(no temporal dead zone: a read before the definition givesundefinedinstead of throwing). In<script setup lang="lisp">the bindings arevarinsetup()as well.
Round one (0.1.4); everything public behaved as before, internally observable details that changed:
- Schedulers (sync watchers, render effects) of the subscribers of a write are called right after the write's propagation, in subscriber order, as before; one that throws no longer stops the others (the first error is rethrown afterwards).
- An effect function that declares no parameter (it could only reach
onCleanupthroughargumentsor a rest parameter) gets a sharedonCleanupthat works while the effect runs; calling it later throwsR106, and so does calling it during the run where the effect is not the node collecting dependencies: insideuntrack, inside a computed's getter and inside awatchgetter (round seven: no variable is kept for "the effect running now", so the registrar goes by the active node, and refuses any node that is not an effect running its own function). Functions that declare the parameter get their own, usable anywhere and at any time. - R215 (duplicate keys) is reported when the list is reconciled with the duplicates (new items, or a reordered middle), not on every update.
- A flush that is aborted by an error resets the queued flag of the effects it drops (they could previously never be queued again).