Performance benchmarks for major frontend frameworks, based on js-framework-benchmark and
@remix-run/component benchmarking code. The project is modernized to use Vite as the central
bundler and Tailwind CSS for styling instead of Bootstrap 3.
Compared to js-framework-benchmark, this repo adds:
- Sorting rows ascending (
#sortasc) - Sorting rows descending (
#sortdesc) - Pokeboxes: a completely new set of benchmarks that are much more DOM-heavy than the default ones
- Getting Started
- Commands
- Tech Stack
- Project Architecture
- Results Viewer
- Manual UI Tests
- Adding a Framework
- Framework File Suffixes
- Implementation Guidelines
- Publishing Bench Snapshots
- FAQ
Prerequisites:
- Node.js
- pnpm
Install dependencies:
pnpm installRun the benchmarks:
pnpm run benchThis builds all frameworks, then uses Playwright to run the benchmarks and print results in the terminal.
pnpm run dev- Start the Vite dev server with HMR on port 4555.pnpm run build- Build all frameworks and the main benchmark pages todist/. directories.pnpm run bench- Build all frameworks and run the Playwright benchmark suite.
- Bundler: Vite (all frameworks use Vite with framework-specific plugins for consistent builds)
- Styling: Tailwind CSS 4
- Test Runner: Playwright
The benchmark runner uses a single Vite server and a pair of custom plugins to host everything in one build and dev pipeline:
-
Multi-HTML routing (
plugins/vite-plugin-multi-html.ts)- Maps multiple page roots into one Vite app using
pageDirsinvite.config.ts - Current routing:
/->src/pages/**/index.html/frameworks->frameworks/*/index.html
- Dev server rewrites incoming URLs to the correct
index.htmlfile - Build mode flattens HTML output so
src/pages/**/index.htmllands indist/without thesrc/pagesprefix - Rollup entry points are generated from the directory scan so each page gets its own JS entry
- Maps multiple page roots into one Vite app using
-
Multi-framework transforms (
plugins/vite-plugin-multi-framework.ts)- Scopes framework Vite plugins by file suffix (e.g.
.react.tsx,.solid.tsx) - Ensures per-framework JSX transforms do not bleed into other implementations
- Includes a guard to reset any global JSX config that plugins might apply
- Scopes framework Vite plugins by file suffix (e.g.
-
TypeScript project layout
- Root
tsconfig.jsonis a solution config with project references only - Shared compiler options live in
tsconfigs/tsconfig.base.json - Each framework has its own tsconfig extending the base with JSX settings and scoped includes
- This preserves per-framework typing while keeping the build orchestration centralized
- Root
The results page loads past runs from results/bench-results-index.json and lets you select a run
(with an optional comparison run) from a dropdown.
- Each run writes a timestamped snapshot file in
results/. - The latest run is still written to
results/bench-results.json. - History retention is controlled by
benchmarks.resultsRetentioninpackage.json(default: 30). - You can label a run with
--machine "Your machine label"and it will display above the config.
The results viewer fetches snapshots from /data/ in dev and /benchmarks/data/ in production. In
dev, plugins/vite-plugin-bench-results.ts serves files from results/. For static hosting, copy
results/ into the build output under data/ (or benchmarks/data/ when using build:prod).
You can view the benchmark implementations at http://localhost:4555/, for example
http://localhost:4555/frameworks/vani/.
Manual tests live under src/pages/manual-tests/ and are served by the same Vite app (they are not
part of the benchmark runner).
Each test should have its own directory:
src/pages/manual-tests/
[test-name]/
index.html
index.ts[x]?
Also add it to the homepage list in src/pages/index.ts so it appears under "Other Tests".
Frameworks are configured in one shared Vite app, so adding a new one means wiring together config, TypeScript projects, and the framework directory.
-
Create the framework directory at
frameworks/<id>/with:index.html(use../../src/styles.cssand the.bench-shellbody class)index.tsthat bootstraps the framework implementationapp.<suffix>(see file suffix rules below)
-
Register the framework in
package.json:- Add the framework runtime to
dependencies(plus@types/*if needed). - Add the Vite plugin to
devDependencieswhen the framework needs one. - Add a new entry to
benchmarks.frameworkswith:id(slug used in URLs and runner output)name(display name)path(must beframeworks/<id>)package(dependency name used for version lookup)version(optional override)implementationNotes(optional results notes)
- Add the framework runtime to
-
Wire up Vite plugins and file suffixes:
- Add the framework to
plugins/vite-plugin-multi-framework.tsand map its plugin. - Update the
frameworkIdslist and choose the file suffix (.react.tsx,.solid.tsx, etc). - Add the framework id to
frameworksinvite.config.tsso Vite loads the plugin.
- Add a TypeScript project:
- Create
tsconfigs/tsconfig.<id>.jsonextendingtsconfig.base.json. - Include
../src/**/*.tsand../frameworks/<id>/**/*ininclude. - Add the new project to the references in
tsconfig.json.
The multi-framework plugin scopes transforms by file suffix, so JSX-based frameworks need a framework-specific extension for their app entry:
- React:
app.react.tsx - Preact:
app.preact.tsx - Remix:
app.remix.tsx - Solid:
app.solid.tsx - Svelte:
app.svelte - Vue:
app.vue - Vani/Vanilla:
app.ts
The following rules are adapted from js-framework-benchmark and apply to framework benchmark
implementations in this repo:
- Reuse the shared helpers in
src/coreso implementations share baseline behavior and avoid accidental slow paths that skew results. - Keep the initial HTML rendered by the framework identical to the reference (in
src/core/blueprint-*.html), includingaria-hiddenattributes. The runner will run a preflight check to ensure the initial HTML rendered by the framework is identical to the reference. - Do not change button IDs (
run,runlots,add,update,clear,swaprows,sortasc,sortdesc). Playwright will use these IDs to interact with the UI. - Use the
src/design-system.cssclasses to style the UI. You may use up to 3 classes per element, including Tailwind classes and utilities. - Do not use more than 3 CSS classes per element. If you need more than 3, consider rethinking it or
defining a new component class in the
src/design-system.cssfile. - Use the shared
styles.cssfrom thesrcdirectory (do not use other Bootstrap copies). - Avoid Shadow DOM; it breaks global CSS and automated tests.
- Use keyed list rendering when the framework supports it: each row uses a stable id as its key so
DOM nodes stay aligned to data items during updates, reorders, and swaps. For example, in React
use the
keyprop to achieve this. - Prefer idiomatic framework code; avoid artificial micro-optimizations that skew results.
- Do not use requestAnimationFrame to alter benchmark operations.
- Do not add custom repaint timing code; the benchmark runner already measures timings.
- Track selection as a single id or reference, not per-row flags.
- Manual DOM manipulation or explicit event delegation may be flagged in reviews, with the exception of the vanilla JS implementation.
Benchmarks are currently run on a MacBook Pro Apple M1 Max 32GB, macOS 26 machine to keep the
environment controlled and results consistent, so the results are not representative of all
machines. To publish a new snapshot:
- Use a machine with the same specs as the one used to run the benchmarks.
- Run the benchmarks with all other apps closed.
- Avoid heavy background processes (Docker, Ollama, etc.).
- On macOS, run
killall bun || true && killall node || true && sudo purgeto stop any running Node/Bun processes, clean the file cache, and free up memory. Keep at least 22GB free of the 32GB of RAM. - Run
pnpm run benchand wait for it to finish. Do not move or keep your mouse over the browser window while it runs. - Verify the results with
pnpm run dev, then push them to the repository.
GitHub Actions are not reliable enough to run the benchmarks because the performance of the machines varies too much between runs, leading to drastically different results every time.