The same fleet dashboard written with Velotype, Solid, Svelte, Vue, React, Preact, Lit, and in plain DOM code, and a page that times each one in the reader's own browser.
The dashboard has a grid of servers (10,000 rows by default), a summary above it, and a toolbar. The page loads each implementation into the same iframe, one at a time, then clicks its buttons and types into its filter box, timing each action until the browser has painted the result.
| Action | What happens |
|---|---|
| Create rows | Click Create with an empty grid |
| Clear rows | Click Clear with a full grid |
| Live update | New CPU, latency, and status for 1% of the rows, and the summary |
| Type a filter | Type "eu-w" one character at a time, the four times added up |
| Clear the filter | Empty the filter box, showing every row again |
| Sort | Sort every row by CPU, or back by ID |
| Move a row | Move the last row to the top |
| Select a row | Click a row's name to highlight it |
deno task build # builds ./site
deno task serve # serves ./site at http://localhost:8000deno task check type-checks everything.
shared/data.ts: the data and the operations on it, shared by every implementation so that each does the same work. Everything random comes from a seeded generator, so every implementation shows the same rows after the same actionsimpl/velotype/main.tsx: Velotype 0.3.1, with the grid as aRenderTemplateArrayimpl/solid/main.tsx: Solid, with the servers in a store and<For>impl/svelte/App.svelte: Svelte 5, with$state.rawfor the list and$statefields for each server's live readingsimpl/vue/App.vue: Vue 3.5, with ashallowReflist andv-memoon each rowimpl/react/main.tsx: React 19, with memoized rows keyed by id, and the filter and summary derived withuseMemoimpl/preact/main.tsx: Preact, the React version with Preact's hooks andmemoimpl/lit/main.ts: Lit, with a light DOM element for the dashboard and one for each row, andrepeat()keyed by idimpl/vanilla/main.ts: the baseline, hand-written DOM codestatic/impl/app.css: the dashboard's stylesheet, shared by all of themstatic/theme.cssandstatic/theme.js: the Velocity Project site's colors (velodesign's theme tokens, written out as plain CSS) and the script that picks light or dark the way the site does. The page is styled to match the site, but is plain HTML and CSS, not built with velodesignharness/runner.ts: drives and times the implementationsharness/main.tsx: the page around them, in Velotype 0.3.1scripts/build.ts: bundles everything the same way (esbuild, minified, production mode) and writessite/meta.jsonwith each bundle's sizescripts/framework-plugins.ts: esbuild plugins that compile Solid's JSX (with Babel andbabel-preset-solid), Svelte components, and Vue single-file components with their own compilers
The harness only uses the page's own controls, so a new implementation needs:
- buttons
#create,#clear,#tick,#sort-cpu,#sort-id,#move-top, and an input#filterthat handlesinputevents - the rows as the children of
#rows, with filtered-out rows either removed orhidden, and each row's name in a.selectelement that selects the row when clicked - the summary in
#sum-count,#sum-cpu,#sum-warn, and#sum-down - the row count from the page's
?rows=parameter (rowCount()inshared/data.ts)
After each scenario the harness compares what every page shows (the number of visible rows, the summary, and the first row) and warns when they differ.
- Velotype sorts, moves, and filters with
RenderTemplateArray's own methods (sort(),move(), andfilter(), new in 0.3.1), which move the rows already rendered.sort()moves only the rows that change places, so re-sorting after a live update moves only the few rows whose CPU changed. - The filter takes rows out of the page in every version. Velotype's
filter()and the vanilla version keep the rows they take out, so clearing the filter moves them back. The other frameworks filter the list they render, so they remove the rows and render them again when the filter is cleared. - Solid's live update sets each change by path (
setServers(index, {...})insidebatch()). Setting them withproduce()on the whole list was twice as slow, because the store then unwraps all 10,000 rows on every update. - Preact is slow to type a filter, about 290ms of script for the first keystroke against React's 20ms with the same code (and the same in Preact 10.29). Its keyed diff looks for each row's old position by searching outward from where it expects it, so after many scattered removals every search is longer. React looks keys up in a
Map. - Lit renders into light DOM, not a shadow root, so that the shared stylesheet applies inside them and the harness can read their rows. That is a supported Lit option (
createRenderRoot()returning the element), but not its default. - The grid uses CSS grid rows with
content-visibility: auto, as a large grid should. Laid out as a<table>, every change re-laid out all 10,000 rows and took seconds in every implementation, which hid the frameworks' own work.
Each action is timed from just before its event until a message posted from a requestAnimationFrame callback is handled, which happens just after that frame is painted, so style, layout, and paint count. Before each action the page is idle for two frames and a short pause. The first iteration of each scenario warms up the code and is not counted. The run stops if the tab is hidden, since browsers stop painting background tabs.
This is coarser than js-framework-benchmark, which reads Chrome's traces. The page shows every sample, and the median of each.
.github/workflows/pages.yml builds the site and deploys it to GitHub Pages on every push to main. In the repository's settings, set Pages to deploy from GitHub Actions.