Skip to content

Performance

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.

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.

Alacris each, liverunning the real bundleEdit in playground
bench.js
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>`;
},
});
operationvanillaAlacris eacheach + storeSolidSvelteLitStencilVueReact
create 1,0001.904.304.252.104.504.2011.43.355.65
create 10,00017.441.854.320.816149.713033.3228
append 1,000 to 10,0001.604.3010.92.5036.74.9095.212.06.85
update every 10th row0.0300.0830.0770.0330.0900.1186.780.9100.318
select a row<0.01<0.01<0.01<0.010.2400.0986.800.8150.152
swap 2 rows<0.010.1020.2830.0800.4680.4076.780.8821.72
remove a row<0.010.0630.4400.0570.7380.2278.650.7950.162
clear 1,0000.2000.6000.6000.3500.4001523.550.4001.50

.map is omitted from the table: it is the naive pattern and loses on every structural operation. Use each.

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.

Terminal window
git clone https://github.com/bmartel/alacris.git
cd alacris
npm install
npm run bench:bundle # production React / Vue / Solid / Svelte / Lit / Stencil
npm 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.

  • 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. each builds a row once; reordering is insertBefore on 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 / nextSibling recipe 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.
filerawgzipbrotli
alacris.js — everything16.79 kB6.56 kB5.96 kB
store.js2.14 kB1.03 kB0.95 kB
context.js0.91 kB0.54 kB0.46 kB
signal.js — no DOM2.34 kB1.03 kB0.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.