Performance
What is measured
Section titled “What is measured”bench/ in the repository runs the standard js-framework-benchmark operations
against hand-written, keyed, delegated DOM — the floor no library can beat —
and against production React 19, Vue 3, Solid, Svelte 5, Lit 3,
and Stencil 4.
Solid, Svelte and Stencil are compiled. Alacris and Lit are not: templates are parsed once per call site at runtime. That is the comparison that matters for this library. Stencil compiles down to a virtual DOM, so a compiler here does not mean Solid’s create path.
Two things make the numbers trustworthy:
- Every implementation is checked to render byte-identical output before any of it is timed.
- Layout is excluded, so what remains is the framework’s own JS and DOM-mutation cost — the part a library actually controls. A toggle measures the layout-inclusive cost instead.
Milliseconds, median of five. Lower is better.
Try it here
Section titled “Try it here”The table below is from the in-repo harness. This is the same each +
selector path, timed in this browser — layout included, so the milliseconds
will not match the table.
import { define, html, each, signal, batch } from '@alacris/core';import { selector } from '@alacris/core/store';
const A = ['pretty', 'large', 'big', 'small', 'tall', 'short', 'long', 'plain'];const C = ['red', 'yellow', 'blue', 'green', 'pink', 'brown'];const N = ['table', 'chair', 'house', 'desk', 'car', 'cookie'];let nextId = 1;const rnd = (max) => (Math.random() * max) | 0;const make = (n) => Array.from({ length: n }, () => ({ id: nextId++, label: `${A[rnd(A.length)]} ${C[rnd(C.length)]} ${N[rnd(N.length)]}`,}));
define('demo-bench', { styles: ` :host { display: grid; gap: .75rem; font: inherit } .ctl { display: flex; gap: .45rem; flex-wrap: wrap; align-items: center } button { font: inherit; padding: .35rem .7rem; border-radius: 6px; cursor: pointer; border: 1px solid currentColor; background: transparent; color: inherit } .ms { opacity: .7; font-variant-numeric: tabular-nums; font-size: .85rem } table { width: 100%; border-collapse: collapse; font-size: .8rem; font-variant-numeric: tabular-nums } th, td { text-align: left; padding: .25rem .4rem; border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent) } th:not(:first-child), td:not(:first-child) { text-align: right } tr.danger td { color: var(--sl-color-accent-high, #f5b719) } tbody { display: block; max-height: 12rem; overflow: auto } thead, tbody tr { display: table; width: 100%; table-layout: fixed } `, setup() { const rows = signal(make(200)); const selected = signal(-1); const isSelected = selector(selected); const last = signal('idle — run an operation');
const time = (name, fn) => { const t0 = performance.now(); fn(); last(`${name}: ${(performance.now() - t0).toFixed(2)} ms`); };
return html` <div class="ctl"> <button @click=${() => time('create 1,000', () => { selected(-1); rows(make(1000)); })}>create 1k</button> <button @click=${() => time('update every 10th', () => { const list = rows.peek().slice(); for (let i = 0; i < list.length; i += 10) list[i] = { ...list[i], label: list[i].label + ' !!!' }; rows(list); })}>update every 10th</button> <button @click=${() => time('select', () => { const r = rows.peek()[100]; if (r) selected(r.id); })}>select</button> <button @click=${() => time('swap', () => { const list = rows.peek().slice(); if (list.length < 999) return; [list[1], list[998]] = [list[998], list[1]]; rows(list); })}>swap</button> <button @click=${() => time('clear', () => batch(() => { rows([]); selected(-1); }))}>clear</button> <span class="ms">${last}</span> </div> <table> <thead><tr><th>id</th><th>label</th></tr></thead> <tbody> ${each( rows, (row) => { const id = row().id; return html`<tr class=${() => (isSelected(id) ? 'danger' : '')}> <td>${id}</td> <td class="lbl">${() => row().label}</td> </tr>`; }, (r) => r.id )} </tbody> </table>`; },});| operation | vanilla | Alacris each | each + store | Solid | Svelte | Lit | Stencil | Vue | React |
|---|---|---|---|---|---|---|---|---|---|
| create 1,000 | 1.90 | 4.30 | 4.25 | 2.10 | 4.50 | 4.20 | 11.4 | 3.35 | 5.65 |
| create 10,000 | 17.4 | 41.8 | 54.3 | 20.8 | 161 | 49.7 | 130 | 33.3 | 228 |
| append 1,000 to 10,000 | 1.60 | 4.30 | 10.9 | 2.50 | 36.7 | 4.90 | 95.2 | 12.0 | 6.85 |
| update every 10th row | 0.030 | 0.083 | 0.077 | 0.033 | 0.090 | 0.118 | 6.78 | 0.910 | 0.318 |
| select a row | <0.01 | <0.01 | <0.01 | <0.01 | 0.240 | 0.098 | 6.80 | 0.815 | 0.152 |
| swap 2 rows | <0.01 | 0.102 | 0.283 | 0.080 | 0.468 | 0.407 | 6.78 | 0.882 | 1.72 |
| remove a row | <0.01 | 0.063 | 0.440 | 0.057 | 0.738 | 0.227 | 8.65 | 0.795 | 0.162 |
| clear 1,000 | 0.200 | 0.600 | 0.600 | 0.350 | 0.400 | 152 | 3.55 | 0.400 | 1.50 |
.map is omitted from the table: it is the naive pattern and loses on every
structural operation. Use each.
What this says
Section titled “What this says”Alacris each is faster than React and Stencil on every operation, and
faster than Lit on everything except create 1,000, where the two tie within
noise. Lit is the fair comparison — another runtime-only custom-element
library, keyed with repeat. Create and append sit next to Alacris; tearing
the list down is the outlier (152 ms to clear 1,000 rows). Stencil is
compiled, but it emits a virtual DOM, so a keyed update still walks the tree
(~7 ms to select or swap a row).
The same is true against Svelte 5 on all but a tie on update-every-10th and
on clear. Svelte’s bulk create looks like React in this harness because the
impl holds the row array in $state; the update/select/swap numbers are the
fair comparison.
The update path sits next to Solid. Select, with selector, is at the
vanilla floor. Remove is 0.063 ms against Solid’s 0.057. Swap is 0.102 ms
against Solid’s 0.080 — two insertBefore calls, not a cascade through the
middle. That used to cost 1.18 ms.
Creation still costs ~2.3× vanilla and ~2× compiled Solid on 1,000 rows
(~2× Solid on 10,000). That is the price of wiring bindings at runtime rather
than compiling them into firstChild / nextSibling instructions. A
runtime-only library cannot close the last stretch of that gap without growing
a compiler, and a compiler is the one thing Alacris refuses. If you need
vanilla-speed construction of ten thousand rows, no runtime library will give
it to you.
Pick the tool per shape. each is best for structural churn. The store is
best for deep, targeted updates and costs more on bulk array rewrites, because
its proxy is paid per access. They compose — each for the list, a store for
the row data. Stable fields (row.id) should be written once, not wrapped in a
thunk; live fields (row.label) stay in a function.
Reproducing it
Section titled “Reproducing it”git clone https://github.com/bmartel/alacris.gitcd alacrisnpm installnpm run bench:bundle # production React / Vue / Solid / Svelte / Lit / Stencilnpm run demo # then open /bench/The absolute values move a lot with hardware and browser. The ratios are the durable part. Competitor bundles stay in the repo harness; the docs site runs Alacris only.
Where the speed comes from
Section titled “Where the speed comes from”- No virtual DOM. Templates are cloned from a native
<template>— the fastest DOM construction path a browser offers — and structure is never diffed. - Fine-grained bindings. Each binding is its own subscription, so a change writes to one attribute or one text node rather than walking a tree.
- Lazy, glitch-free propagation. Writes push invalidation; reads pull values. A diamond runs its effect once, and a computed that lands on the same value stops propagation there.
- Per-row scopes.
eachbuilds a row once; reordering isinsertBeforeon the rows that moved. A longest-increasing-subsequence pass keeps untouched rows still, so a swap of two rows is two moves, not a shuffle of the middle. - Direct paths, not a TreeWalker. Each clone follows a compile-time
firstChild/nextSiblingrecipe to its bindings. - Event delegation. One listener per render root instead of one per binding — 2 instead of 2,000 on a thousand-row table.
- Shared setters and interned stylesheets. Attribute setters are built once per template rather than per instance, and identical CSS is parsed once per page.
| file | raw | gzip | brotli |
|---|---|---|---|
alacris.js — everything | 16.79 kB | 6.56 kB | 5.96 kB |
store.js | 2.14 kB | 1.03 kB | 0.95 kB |
context.js | 0.91 kB | 0.54 kB | 0.46 kB |
signal.js — no DOM | 2.34 kB | 1.03 kB | 0.96 kB |
Add-ons import the core rather than bundling it, so there is exactly one reactive graph at runtime. With a bundler, anything you do not import is tree-shaken away.
Size is treated as part of the public contract: CI fails if a change moves it without the regenerated figures being committed.