| name | webfe-benchmark |
| description | WebFePerfromance project workflow. Use when: adding a new frontend framework to the benchmark suite; registering a new app; writing SSR server and entry-server files; adding virtual-list market rows; adding WS odds updates; running benchmarks (Playwright browser automation + web-vitals); recording results; regenerating summary tables; updating docs after a test run.
|
| argument-hint | Name and details of the new framework (e.g. "Angular 17, port 3005")
|
WebFePerfromance Benchmark Skill
Project Overview
Benchmarks four SSR frontend frameworks under a live sportsbook workload using
Playwright browser automation and the Web Vitals API.
apps/
React/ React 19 + React Router v7 port 3000
ReactTSR/ React 19 + TanStack Router port 3014
Solid/ Solid.js + @solidjs/router port 3002
Vue/ Vue 3 + vue-router port 3003
Svelte/ Svelte 5 + svelte-routing port 3004
Angular/ Angular 19 + @angular/router port 3005
Preact/ Preact 10 + wouter port 3006
Qwik/ Qwik 1 + qwik-city port 3007
Remix/ Remix 2 (file routing) port 3008
NextJs/ Next.js 15 (App Router) port 3009
Nuxt/ Nuxt 3 (vue-router) port 3010
SvelteKit/ SvelteKit 2 (file routing) port 3011
Lit/ Lit 3 + @vaadin/router port 3012
Ember/ Ember 5 + ember-router port 3013
Shared/
market-data/ 500 seeded markets, pickUpdatedMarkets()
ws-simulator/ Bun WS server — 100 ms interval, 20 % updates
metrics/ client-side setupMetrics() → window.__perf_metrics
Dashboard/ Vanilla HTML/JS results viewer
benchmarks/ Python CLI (python -m benchmarks …)
results/ Per-framework markdown + summary.json
Metrics collected
| Category | Metrics |
|---|
| Build | Bundle size (gzip), initial chunk, chunk count, build time |
| Web Vitals | LCP, FCP, TTI, TBT, CLS, INP |
| Sportsbook | Hydration time, first-odds-update, avg-odds-update, memory, frame drops, bet-slip latency |
| SSR | Server render time (ms), TTFB |
Adding a New Framework
Step 1 — Create the app directory
apps/<PascalName>/
src/
main.<tsx|ts> client hydration entry
entry-server.<tsx|ts> SSR entry — exports render(markets): string (or Promise<string>)
App.<tsx|ts|vue|svelte>
components/
MarketRow.<ext>
OddsButton.<ext>
BetSlip.<ext>
server.ts Bun SSR server
vite.config.ts
tsconfig.json extends ../../tsconfig.base.json
package.json
Step 2 — Implement required endpoints in server.ts
| Route | Behaviour |
|---|
GET /health | Returns { ok: true } — used by the Python runner |
GET / | SSR-rendered HTML with window.__INITIAL_STATE__ |
GET /assets/* | Serves dist/client/assets/ |
GET /api/ssr-metrics | Returns { lastSsrMs, ttfbMs } |
Use process.env.PORT ?? <default-port> so the Python runner can override the port.
Step 3 — Implement window.__perf_metrics collection
In src/main.<ext> import and call setupMetrics() from @webfe/metrics.
Realistic library stack
Every app should include the following ecosystem libraries to reflect real-world usage and create a
comparable benchmark baseline:
| Category | React (RR) | React (TSR) | Solid | Vue | Svelte | Angular | Preact | Qwik | Lit | Meta-fw |
|---|
| Router | react-router v7 | @tanstack/react-router | @solidjs/router | vue-router v4 | svelte-routing | @angular/router | wouter | qwik-city | @vaadin/router | built-in |
| Server-state / cache | @tanstack/react-query | @tanstack/react-query | @tanstack/solid-query | @tanstack/vue-query | @tanstack/svelte-query | @ngrx/signals | — | — | — | @tanstack/{react,vue,svelte}-query |
| Animation | motion | motion | motion | motion | motion | motion | motion | motion | motion | motion |
| Virtual list | virtua | virtua | virtua/solid | virtua/vue | virtua/svelte | CDK virtual scroll | virtua | virtua | virtua | virtua |
Dispatch these custom events at the right times:
| Event | detail shape | When |
|---|
webfe:hydrated | { time: number } | After hydration completes |
webfe:odds-updated | { count: number, renderTime: number } | After each WS odds flush |
webfe:bet-slip | { latency: number } | After a bet selection commit |
Step 4 — Add routing
Every app exposes two routes: / (pre-match markets) and /live (live markets).
Use the router's link component for the nav tabs instead of <button onClick>.
| Framework | Router | Client wrapper | SSR wrapper |
|---|
| React (RR) | react-router v7 | <BrowserRouter> | <StaticRouter location={url}> |
| React (TSR) | @tanstack/react-router | <RouterProvider router={router}> | createMemoryHistory + router.load() |
| Solid | @solidjs/router | <Router> | <StaticRouter url={url}> |
| Vue | vue-router | createWebHistory() | createMemoryHistory() + router.push(url) |
| Svelte (bare) | svelte-routing | <Router> / <Route> | render at / in entry-server |
| Angular | @angular/router | provideRouter(routes) in bootstrapApplication | provideServerRouting |
| Preact | wouter | <Router hook={useHashLocation}> | <StaticRouter path={url}> |
| Qwik | @builder.io/qwik-city | built-in | built-in |
| Lit | @vaadin/router | Router.go(path) | render root component directly |
| Meta-frameworks | built-in file routing | — | — |
React Router v7 (library mode) pattern
import { Link, useLocation } from "react-router";
const view = useLocation().pathname === "/live" ? "live" : "prematch";
<Link to="/" className={view === "prematch" ? styles.activeTab : styles.tab}>Pre-match</Link>
<Link to="/live" className={view === "live" ? styles.activeTab : styles.tab}>Live</Link>
import { BrowserRouter } from "react-router";
hydrateRoot(el, <BrowserRouter><App /></BrowserRouter>);
import { StaticRouter } from "react-router";
export function render(markets, url = "/") {
return renderToString(<StaticRouter location={url}><App markets={markets} /></StaticRouter>);
}
TanStack Router (React, manual route tree) pattern
import {
createRootRoute,
createRoute,
createRouter,
} from "@tanstack/react-router";
const rootRoute = createRootRoute<{ markets: Market[] }>({
component: () => <Outlet />,
});
const prematchRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
});
const liveRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/live",
});
export const createAppRouter = (markets: Market[]) =>
createRouter({
routeTree: rootRoute.addChildren([prematchRoute, liveRoute]),
context: { markets },
});
import { RouterProvider } from "@tanstack/react-router";
hydrateRoot(el, <RouterProvider router={createAppRouter(markets)} />);
import { createMemoryHistory, RouterProvider } from "@tanstack/react-router";
export async function render(markets, url = "/") {
const router = createAppRouter(markets);
router.update({ history: createMemoryHistory({ initialEntries: [url] }) });
await router.load();
return renderToString(<RouterProvider router={router} />);
}
Step 5 — Add virtual list
Use virtua for the 500-row market list. Import the framework-specific entry:
| Framework | Import |
|---|
| React | import { VList } from "virtua" |
| Solid | import { VList } from "virtua/solid" |
| Vue | import { VList } from "virtua/vue" |
| Svelte | import { VList } from "virtua/svelte" |
Step 5 — vite.config.ts
import { defineConfig } from "vite";
export default defineConfig(({ isSsrBuild }) => ({
plugins: [
],
build: {
outDir: isSsrBuild ? "dist/server" : "dist/client",
rollupOptions: isSsrBuild
? {
input: "src/entry-server.<ext>",
external: [
],
}
: {},
},
}));
package.json build script must be:
"build": "vite build && vite build --ssr src/entry-server.<ext>"
Step 6 — Register in benchmarks/config.py
Add a new Framework(...) entry to the FRAMEWORKS dict:
Framework("<name>", "<Display Name>", "<PascalDir>", <port>, "<hex-color>"),
<name> is the CLI identifier (lowercase, used with --framework).
<PascalDir> must match the directory under apps/.
Ports already in use: 3000, 3001 (WS sim), 3002, 3003, 3004. Use 3005+.
Step 7 — Add to pnpm workspace
In pnpm-workspace.yaml add:
- "apps/<PascalDir>"
Running Benchmarks
python -m benchmarks run
python -m benchmarks run --framework react
python -m benchmarks run --framework solid --skip-build
python -m benchmarks build --framework vue
python -m benchmarks list
python -m benchmarks stop
Viewing Results
python -m benchmarks summary
python -m benchmarks dashboard
python -m benchmarks dashboard --port 8080
Result files
After a run, per-framework markdown files are written under results/<PascalName>/:
results/
React/
Build.md
WebVitals.md
Sportsbook.md
SSR.md
Solid/ …
summary.json ← consumed by the dashboard
Summary.md ← human-readable comparison tables
The best value per metric is bolded in Summary.md.
Initial setup
pnpm install
pip install -e ".[dev]"
playwright install chromium
Start the WS simulator standalone for development:
bun apps/Shared/ws-simulator/src/index.ts
pnpm ws:sim