diff --git a/README.md b/README.md
index f9a27d7..790a7ab 100644
--- a/README.md
+++ b/README.md
@@ -27,9 +27,11 @@ the ubiquitous [``](/api/jarl-react#link) are of course provided in the Re
you want to build more compositionally; they all just accept atoms for parameters instead of
type-unsafe strings.)
-Because each route atom is an independent, subscribable unit of jotai state, a component that
-reads one only re-renders when *that atom's* derived value actually changes - it turns out this
-is incredibly efficient.
+Because each route atom is an independent, subscribable unit of jotai state, only components
+that actually read route state are re-rendered by navigation, resolving a URL against the whole
+route table is fast, and the bundle stays small. Those claims are
+[measured against react-router](/docs/benchmarks) rather than asserted - including the workloads
+where the two routers tie, and the ones where react-router is quicker.
## Features
diff --git a/bench/README.md b/bench/README.md
new file mode 100644
index 0000000..b96d599
--- /dev/null
+++ b/bench/README.md
@@ -0,0 +1,108 @@
+# jarl vs react-router benchmark
+
+A reproducible comparison of jarl (`jarl-atoms` + `jarl-react`) and react-router. Results and
+interpretation are published in the docs site's [Benchmarks guide](../packages/docs/src/content/guides/Benchmarks.md);
+this README defines exactly what is measured and how to re-run it.
+
+```bash
+# from the repo root: builds jarl-atoms + jarl-react, then runs everything
+npm run bench
+```
+
+`npm test` in this workspace runs only the deterministic render-count comparison (no timing), so it
+is safe in CI; the timed benchmarks run under `NODE_ENV=production` in a forked Node process with
+`--expose-gc`.
+
+## What is measured
+
+### Re-renders per navigation (`src/renders.test.tsx`)
+
+Both routers drive the same app — a layout with 13 active-styled nav links, 10 components that read
+no route state ("widgets"), and four routed pages — defined once in `src/shape.ts` and
+`src/apps/sharedComponents.tsx`, with only the router integration differing per app. Navigation is
+performed by clicking the rendered links under jsdom, and every component tallies its own renders.
+Counts are deterministic, so there is no sampling; the run also asserts that both apps produce
+**byte-identical HTML** after mount and after every single navigation, which is what makes the
+comparison like-for-like.
+
+Nav links are built from each router's public hook primitives (`useAtom` over the route atom for
+jarl; `useMatch`/`useHref`/`useLinkClickHandler` for react-router) so both render the same anchor
+markup. One deliberate deviation, discovered by the parity assertion: jarl's own `active` flag
+(`useLink`/`activeClassName`) is route-level — every link to a route atom reports active whatever
+its param values — so the jarl nav link narrows it to href-level by also comparing param values,
+matching react-router's semantics.
+
+React's development build is used here; without StrictMode it renders each component once per
+update, the same as production, and no timing is taken from this file.
+
+### Matching/resolve throughput (`src/matching.benchmark.ts`)
+
+Pure library cost with React excluded, over a 100-route table (50 static sections, each with a
+`:id` param child), run under plain Node. Neither side touches history or the DOM there: jarl's
+`locationAtom` falls back to its server path, and react-router is given a config or a memory
+router. The two libraries match with different machinery, so the workloads are defined by
+outcome rather than mechanics:
+
+- **resolve** — one URL string in, the matched leaf out. jarl writes `locationAtom` and reads leaf
+ route atoms in order until one matches, which is exactly what a mounted `Switch` does — its
+ `findIndex` short-circuits too, so a hit early in the table costs less than a late one or a
+ miss. The measured URLs cycle an early, middle and late hit plus a miss so neither library is
+ measured only at its best. react-router calls `matchRoutes` over the equivalent config, which
+ ranks the whole table on every call. The "cold" jarl variant pays a fresh jotai store per
+ resolve, as each SSR request would; `matchRoutes` is stateless, so its cold and warm costs are
+ the same call.
+- **navigate** — one client-side navigation through each library's own API: a param-value write to
+ a route atom plus re-reading the leaves, versus `router.navigate()` on a memory router
+ (awaited — its API is promise-based). Not equivalent work: `router.navigate()` runs
+ react-router's full data-router state machine, where the atom write only re-derives state. jarl
+ is slower here regardless, so the gap this understates is jarl's own.
+- **resolve cost decomposition** — why react-router can navigate faster than it resolves: the
+ public `matchRoutes()` flattens and ranks the whole config on *every call*, where a data router
+ does that once at creation and each navigation matches against the cached ranking
+ (`precomputedBranches` in react-router's `router.ts`). The workload holds the matched URL at the
+ first-ranked branch while the table grows, so per-call match work is constant: `matchRoutes`
+ scales linearly with table size (it is dominated by per-call table preparation), while
+ `router.navigate` and jarl stay near-flat. Navigation does still resolve the route — it just
+ never re-pays the preparation the stateless number includes.
+
+Each number is 30 retained samples of 1000 operations, after 10 discarded warm-up samples, with GC
+forced between samples; reported as median with p25/p75 and min/max.
+
+### Deep nesting (`src/deepRenders.test.tsx`, `src/deepNavigation.benchmark.tsx`)
+
+Five nested levels (`/d1/:p1/d2/:p2/…/d5/:p5`), each level a layout that renders its own param
+and a static child, against three implementations: jarl's nested `Switch`/`Route` atoms,
+react-router's data router (route config), and react-router's declarative `` component
+form. `deepRenders.test.tsx` counts renders per level for a leaf-only, mid-level and root-level
+param change, with the same byte-identical-HTML assertion across all three apps.
+`deepNavigation.benchmark.tsx` times the same leaf toggle with React included — click to
+committed DOM, via `flushSync` — since render counts alone can't rank routers that re-render the
+same components at different per-render cost.
+
+### Nested async data (`src/asyncData.benchmark.tsx`)
+
+A three-level route chain where every level needs one 25ms async lookup, measured from
+navigation to the deepest level's data being in the DOM, with fresh param values per run so no
+cache is ever warm. Three loading strategies: jarl's `asyncRouteAtom` + `followAsyncRoutes`
+(every lookup starts on the location change, in parallel — the param routes chain on each other,
+not on the async atoms, so no lookup waits for another's data), react-router loaders (its own
+parallel mechanism), and a react-router Suspense cascade (each level's component `use()`s its own
+fetch, so a level's lookup cannot start until its parent has rendered). The cascade is what
+fetch-on-render components give you, not a limitation of react-router — loaders exist precisely
+to avoid it and are included as the fair comparison.
+
+### Bundle size (`src/bundle-size.benchmark.ts`)
+
+The two entries in `src/size/` implement the same minimal app using each router's typical surface.
+Each is bundled from the packages' published dist builds with rolldown (minified,
+`NODE_ENV=production` defined, `react`/`react-dom` external) and reported minified and gzipped
+(zlib level 9). jarl is reported both with its full dependency cost (jotai + jotai-location
+bundled) and with jotai external, for apps already using jotai.
+
+## What the numbers do not show
+
+- No real-browser timings: no layout, paint or input latency. jsdom timings cover library and
+ React render/commit work only.
+- react-router's actions and lazy-route machinery are unexercised.
+- Two app shapes and two route-table shapes; others (splats, query-heavy routing) may rank
+ differently.
diff --git a/bench/package.json b/bench/package.json
new file mode 100644
index 0000000..d1ff0ca
--- /dev/null
+++ b/bench/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "jarl-bench",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Reproducible performance comparison of jarl vs react-router: re-render counts, matching throughput and bundle size. See README.md for methodology.",
+ "type": "module",
+ "scripts": {
+ "test": "vitest run",
+ "bench": "vitest run && NODE_ENV=production vitest run --config vitest.bench.config.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "jarl-atoms": "2.6.0",
+ "jarl-react": "2.6.0",
+ "jotai": "^2.20.2",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-router": "^8.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.0.5",
+ "jsdom": "^30.0.1",
+ "rolldown": "^1.2.3",
+ "typescript": "^7.0.2",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/bench/src/apps/JarlApp.tsx b/bench/src/apps/JarlApp.tsx
new file mode 100644
index 0000000..8923a0f
--- /dev/null
+++ b/bench/src/apps/JarlApp.tsx
@@ -0,0 +1,92 @@
+import { DefaultParams, RouteAtom, paramRouteAtom, rootAtom, staticRouteAtom } from "jarl-atoms";
+import { Route, Switch, useAtom } from "jarl-react";
+import { Provider, createStore } from "jotai";
+import { countRender } from "../renderCounter";
+import { itemIds } from "../shape";
+import { AboutPage, HomePage, ItemDetail, ItemsPage, NotFoundPage, Widgets } from "./sharedComponents";
+import type { BenchApp } from "./types";
+
+export const homeRoute = rootAtom;
+export const aboutRoute = staticRouteAtom("about");
+export const itemsRoute = staticRouteAtom("items");
+export const itemRoute = paramRouteAtom("itemId", { parent: itemsRoute });
+
+// Built from hook primitives rather than , so both apps render identical anchor markup.
+const NavItem = ({
+ route,
+ to,
+ label,
+ exact,
+}: {
+ route: RouteAtom;
+ to: T;
+ label: string;
+ exact?: boolean;
+}) => {
+ countRender("nav link");
+ const [state, setRoute] = useAtom(route);
+ // jarl's own `active` is route-level, so two links to different items both light up.
+ // Narrowed to href-level here to match react-router's semantics.
+ const matched = exact ? state.exact : state.match;
+ const active =
+ matched && Object.entries(to).every(([key, value]) => (state.values as Record)[key] === value);
+ return (
+ {
+ event.preventDefault();
+ setRoute(to);
+ }}
+ >
+ {label}
+
+ );
+};
+
+const Nav = () => (
+
+);
+
+const Layout = () => {
+ countRender("layout");
+ return (
+
;
diff --git a/bench/src/apps/sharedComponents.tsx b/bench/src/apps/sharedComponents.tsx
new file mode 100644
index 0000000..04eaa19
--- /dev/null
+++ b/bench/src/apps/sharedComponents.tsx
@@ -0,0 +1,48 @@
+// Shared verbatim by both apps, so any render-count difference comes from the router alone.
+import { countRender } from "../renderCounter";
+import { WIDGET_COUNT, itemIds } from "../shape";
+
+/** Reads nothing from any router: the control group that should never re-render on navigation. */
+export const Widget = ({ index }: { index: number }) => {
+ countRender("widget");
+ return
},
+ ],
+ },
+]);
+
+createRoot(document.getElementById("root")!).render();
diff --git a/bench/src/stats.ts b/bench/src/stats.ts
new file mode 100644
index 0000000..c42cbbc
--- /dev/null
+++ b/bench/src/stats.ts
@@ -0,0 +1,80 @@
+/** Summary of one timed measurement: quartiles over the retained (post-warm-up) samples. */
+export type Summary = {
+ samples: number;
+ median: number;
+ p25: number;
+ p75: number;
+ min: number;
+ max: number;
+};
+
+const quantile = (sorted: number[], q: number): number => {
+ const pos = (sorted.length - 1) * q;
+ const lower = Math.floor(pos);
+ const upper = Math.ceil(pos);
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (pos - lower);
+};
+
+export const summarise = (samples: number[]): Summary => {
+ const sorted = [...samples].sort((a, b) => a - b);
+ return {
+ samples: sorted.length,
+ median: quantile(sorted, 0.5),
+ p25: quantile(sorted, 0.25),
+ p75: quantile(sorted, 0.75),
+ min: sorted[0],
+ max: sorted[sorted.length - 1],
+ };
+};
+
+/**
+ * Times `warmup + samples` samples of `iterations` calls, discards the warm-up
+ * ones, and summarises the rest in microseconds per call. Forces GC before each
+ * sample where `--expose-gc` allows it.
+ */
+export const measure = (
+ work: () => void,
+ { samples = 30, warmup = 10, iterations = 1000 }: { samples?: number; warmup?: number; iterations?: number } = {},
+): Summary => {
+ const times: number[] = [];
+ for (let s = 0; s < warmup + samples; s++) {
+ globalThis.gc?.();
+ const start = performance.now();
+ for (let i = 0; i < iterations; i++) work();
+ const elapsed = performance.now() - start;
+ if (s >= warmup) times.push((elapsed * 1000) / iterations);
+ }
+ return summarise(times);
+};
+
+/** Async variant of `measure`, for workloads whose API is promise-based. */
+export const measureAsync = async (
+ work: () => Promise,
+ { samples = 30, warmup = 10, iterations = 1000 }: { samples?: number; warmup?: number; iterations?: number } = {},
+): Promise => {
+ const times: number[] = [];
+ for (let s = 0; s < warmup + samples; s++) {
+ globalThis.gc?.();
+ const start = performance.now();
+ for (let i = 0; i < iterations; i++) await work();
+ const elapsed = performance.now() - start;
+ if (s >= warmup) times.push((elapsed * 1000) / iterations);
+ }
+ return summarise(times);
+};
+
+const fmt = (value: number) => (value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2));
+
+export const formatSummary = ({ samples, median, p25, p75, min, max }: Summary, unit = "µs"): string =>
+ `median ${fmt(median)}${unit} p25 ${fmt(p25)}${unit} p75 ${fmt(p75)}${unit} min ${fmt(min)}${unit} max ${fmt(max)}${unit} (n=${samples})`;
+
+/** Prints rows as a column-aligned table, first column left-justified. */
+export const printTable = (title: string, header: string[], rows: (string | number)[][]) => {
+ const all = [header, ...rows.map((row) => row.map(String))];
+ const widths = header.map((_, col) => Math.max(...all.map((row) => row[col].length)));
+ const line = (row: string[]) =>
+ row.map((cell, col) => (col === 0 ? cell.padEnd(widths[col]) : cell.padStart(widths[col]))).join(" ");
+ console.log(`\n${title}`);
+ console.log(line(header));
+ for (const row of all.slice(1)) console.log(line(row));
+};
diff --git a/bench/tsconfig.json b/bench/tsconfig.json
new file mode 100644
index 0000000..e056dae
--- /dev/null
+++ b/bench/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["src", "vitest.config.ts", "vitest.bench.config.ts"]
+}
diff --git a/bench/vitest.bench.config.ts b/bench/vitest.bench.config.ts
new file mode 100644
index 0000000..ce3094f
--- /dev/null
+++ b/bench/vitest.bench.config.ts
@@ -0,0 +1,17 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+// Timed benchmarks. Needs NODE_ENV=production (the `bench` script sets it) for the
+// libraries' production builds, and a serial forked process with --expose-gc.
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ include: ["src/**/*.benchmark.{ts,tsx}"],
+ fileParallelism: false,
+ pool: "forks",
+ maxWorkers: 1,
+ execArgv: ["--expose-gc"],
+ testTimeout: 300_000,
+ },
+});
diff --git a/bench/vitest.config.ts b/bench/vitest.config.ts
new file mode 100644
index 0000000..59ffe89
--- /dev/null
+++ b/bench/vitest.config.ts
@@ -0,0 +1,13 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+// Render-count comparison only, so it is deterministic under CI; the timed
+// benchmarks are behind vitest.bench.config.ts and `npm run bench`.
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ include: ["src/**/*.test.{ts,tsx}"],
+ fileParallelism: false,
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index 91f673e..b4ea558 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,8 @@
"workspaces": [
"packages/jarl-atoms",
"packages/jarl-react",
- "packages/docs"
+ "packages/docs",
+ "bench"
],
"devDependencies": {
"@babel/plugin-syntax-jsx": "^8.0.1",
@@ -52,6 +53,27 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
+ "bench": {
+ "name": "jarl-bench",
+ "version": "0.0.0",
+ "dependencies": {
+ "jarl-atoms": "2.6.0",
+ "jarl-react": "2.6.0",
+ "jotai": "^2.20.2",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-router": "^8.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^6.0.5",
+ "jsdom": "^30.0.1",
+ "rolldown": "^1.2.3",
+ "typescript": "^7.0.2",
+ "vitest": "^4.1.10"
+ }
+ },
"node_modules/@actions/core": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz",
@@ -5700,6 +5722,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
+ },
"node_modules/core-js-compat": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz",
@@ -6993,6 +7021,10 @@
"resolved": "packages/jarl-atoms",
"link": true
},
+ "node_modules/jarl-bench": {
+ "resolved": "bench",
+ "link": true
+ },
"node_modules/jarl-react": {
"resolved": "packages/jarl-react",
"link": true
@@ -10397,6 +10429,27 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/react-router": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
+ "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie-es": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=22.22.0"
+ },
+ "peerDependencies": {
+ "react": ">=19.2.7",
+ "react-dom": ">=19.2.7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/read-package-up": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
diff --git a/package.json b/package.json
index d7d90ec..766e884 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,8 @@
"workspaces": [
"packages/jarl-atoms",
"packages/jarl-react",
- "packages/docs"
+ "packages/docs",
+ "bench"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
@@ -12,6 +13,7 @@
"start": "npm run dev --workspace packages/docs",
"dev": "npm run dev --workspace packages/docs",
"test": "npm run test --workspaces --if-present",
+ "bench": "npm run build --workspace packages/jarl-atoms --workspace packages/jarl-react && npm run bench --workspace bench",
"ci-test": "npm run ci-test --workspaces --if-present",
"test:e2e:install": "npm --prefix e2e install && npx --prefix e2e playwright install --with-deps chromium",
"test:e2e": "npm --prefix e2e run test",
diff --git a/packages/docs/src/content/guides/Benchmarks.md b/packages/docs/src/content/guides/Benchmarks.md
new file mode 100644
index 0000000..8fe591c
--- /dev/null
+++ b/packages/docs/src/content/guides/Benchmarks.md
@@ -0,0 +1,182 @@
+# Benchmarks
+
+The README claims routing with subscribable atoms is efficient; this page holds the actual
+measurements behind that claim, compared against react-router — including the places where JARL
+ties or loses. The full harness is checked into the repo under
+[`bench/`](https://github.com/randomdevpete/jarl/tree/master/bench) and reproducible with a
+single command from the repo root:
+
+```bash
+npm run bench
+```
+
+## Setup
+
+| | |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
+| jarl | `jarl-atoms` 2.6.0 + `jarl-react` 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) |
+| react-router | 8.3.0 — data router (`createBrowserRouter`); the deep-nesting scenario also covers the declarative `` form |
+| react / react-dom | 19.2.8 (identical for both) |
+| environment | Node 24.15.0, Intel i7-1165G7, Linux (WSL2) |
+
+Two app shapes are driven by every router under comparison: a flat app (a layout with 13
+active-styled nav links, 10 components that read no route state, and four routed pages) and a
+five-level nested app. The harness asserts all implementations of a shape produce
+**byte-identical HTML** after every navigation, so every number below compares the same rendered
+output. Timed results are medians with quartiles over repeated samples, GC forced between
+samples and warm-up samples discarded; render counts are deterministic and need no sampling.
+The timed numbers below are one full run of the suite; the orderings and ratios were stable
+across five repeat runs under varying background load, absolute medians within about ±20%.
+
+## Re-renders per navigation (flat app)
+
+Renders per component group, navigating by clicking links (jsdom, counts identical across runs):
+
+| component group | jarl | react-router |
+| ------------------------------------------- | --------------------- | ------------- |
+| nav links (13), per navigation | 13 | 13 |
+| changed page, per navigation | 1 | 1 |
+| layout, per navigation | 0 | 0 |
+| non-routing components (10), per navigation | 0 | 0 |
+| everything above, at initial mount | nav links ×2, rest ×1 | everything ×1 |
+
+**This is a tie, not a win.** Components that read no route state are never re-rendered by
+navigation in either router; react-router's context-based model is more precise here than it is
+usually given credit for. Every active-styled link re-renders on every navigation in both
+routers — each subscribes to location state to know whether it is active — including when
+re-clicking the already-active link. JARL's one measured deficit: each route-atom subscriber
+renders twice at initial mount, once for the tree and once when `atomWithLocation` first syncs.
+
+Two caveats the harness surfaced:
+
+- A route atom's value is a fresh object on every location change, so _every_ subscriber of _any_
+ route atom re-renders on _any_ navigation — atom-level subscription narrows _which components
+ subscribe_, it does not currently skip unaffected routes.
+- JARL's `active` flag is route-level: two links to the same route atom with different param
+ values both report active. The harness narrows it to href-level by comparing param values, to
+ match react-router's semantics.
+
+## Matching and navigation throughput
+
+Pure library cost, React excluded, over a 100-route table (50 static sections each with a
+`:id` param child), per operation:
+
+| workload | jarl | react-router |
+| ------------------------------------------ | ------------------------------ | --------------------------- |
+| resolve URL → matched leaf (warm) | **120 µs** (p25 110 / p75 131) | 407 µs (p25 380 / p75 471) |
+| resolve, cold store per URL (as SSR would) | **164 µs** (p25 143 / p75 180) | 407 µs (stateless) |
+| client navigation via each API | 138 µs (p25 123 / p75 160) | **79 µs** (p25 72 / p75 96) |
+
+### Why is react-router's navigation faster than its resolution?
+
+Navigating does resolve the route — these two rows are not contradictory, they price different
+work. The public `matchRoutes()` flattens and ranks the whole route config on **every call**. A
+data router does that **once at creation**, keeps the ranked branches, and each
+`router.navigate()` matches against them (`precomputedBranches` in react-router's `router.ts`).
+So the resolve row is what an ad-hoc `matchRoutes` caller pays per call, table preparation
+included; the navigate row is what a mounted client app pays per navigation, where that
+preparation is already amortised to zero.
+
+The benchmark demonstrates this directly by holding the matched URL at the **first-ranked
+branch** while the table grows, so per-call match work is constant and any growth is per-call
+preparation:
+
+| routes in table | `matchRoutes` | `router.navigate` | jarl (first leaf read) |
+| --------------- | ------------- | ----------------- | ---------------------- |
+| 2 | 17 µs | 61 µs | 12 µs |
+| 20 | 112 µs | 79 µs | 18 µs |
+| 100 | 421 µs | 73 µs | 18 µs |
+
+`matchRoutes` scales linearly with table size — its per-call cost is dominated by preparing the
+table, not matching against it. `router.navigate` is near-flat: warm matching is a few µs and
+the rest is its state-machine and subscriber work. jarl has no preparation step to amortise —
+route atoms are their own index, so a cold resolve pays only a fresh jotai store, never a table
+prep — which is why it wins the resolve row outright while losing the navigate row to a router
+that has already paid resolution's expensive half up front.
+
+jarl's leaf reads also stop at the first match where `matchRoutes` ranks then scans, so the
+resolve row's URLs deliberately cycle an early, middle and late hit plus a miss rather than
+sampling only the cheap case. And the navigate row understates jarl's deficit if anything:
+`router.navigate` runs react-router's whole data-router state machine, strictly more work than
+the atom write it beats.
+
+## Deep nesting
+
+Five nested levels (`/d1/:p1/d2/:p2/…/d5/:p5`), each level a layout rendering its own param and
+one static child, against three implementations: jarl's nested `Switch`/`Route` atoms,
+react-router's data router (route config), and react-router's declarative `` form.
+Render counts per navigation are **identical across all three** — a three-way tie, and not a
+flattering one:
+
+| component group, per navigation | jarl | rr data router | rr `` |
+| ------------------------------- | ---- | -------------- | ------------- |
+| every level layout (5) | 5 | 5 | 5 |
+| per-level static children (5) | 5 | 5 | 5 |
+| nav links (6) | 6 | 6 | 6 |
+| shell | 0 | 0 | 0 |
+
+Whether only the leaf param changes, a mid-level one or the root one, **every level re-renders
+in every router**: each level reads its own param, every router hands out fresh param/values
+objects per navigation, and each re-rendered layout recreates its children's elements. Atom-level
+subscription does not narrow this — jarl's known caveat that route atoms produce fresh objects
+per location change applies at every level at once. jarl also repeats its mount deficit here
+(nav links render twice at initial mount).
+
+Since the same components re-render everywhere, the routers can only differ in per-render cost,
+so the same leaf toggle is also timed with React included (click to committed DOM, production
+builds, `flushSync`):
+
+| per navigation, React render + commit | median |
+| ------------------------------------- | ------------------------------ |
+| react-router `` | **137 µs** (p25 122 / p75 150) |
+| react-router data router | 397 µs (p25 355 / p75 517) |
+| jarl | 493 µs (p25 434 / p75 585) |
+
+A clear jarl loss, and an instructive ordering: the declarative `` form — no state
+machine, a tiny route table re-matched per render — is the fastest way to do a deep navigation,
+the data router pays its state machine, and jarl pays re-deriving five levels of route atoms
+plus six `useAtom` subscribers. (Absolute numbers are jsdom without layout or paint; the ranking
+is the result.)
+
+## Nested async data
+
+A three-level route chain where every level needs one **25ms** async lookup, measured from
+navigation to the deepest level's data on screen, fresh param values per run so no cache is ever
+warm:
+
+| strategy | median |
+| ------------------------------------------------------- | --------------------------------- |
+| jarl: `asyncRouteAtom` + `followAsyncRoutes` (parallel) | **26.8 ms** (p25 26.4 / p75 27.1) |
+| react-router: loaders (parallel) | **26.9 ms** (p25 26.2 / p75 27.7) |
+| react-router: per-component Suspense cascade | 78.1 ms (p25 77.5 / p75 78.5) |
+
+jarl's atom pre-resolution starts every level's lookup the moment the location changes — the
+param routes chain on each other, not on the async atoms, so no lookup waits for another's
+data — and lands in ~one lookup's time. A fetch-on-render Suspense cascade cannot start a
+level's lookup until its parent has rendered, so it pays the full sum of the chain, 3× here and
+growing with depth. The honest comparison: react-router's loaders exist precisely to avoid that
+cascade and match jarl's parallel time exactly. The cascade row is the cost of _not_ using a
+router-level data story on either side — jarl's atoms give you the parallel behaviour as the
+idiomatic default, react-router's requires opting into loaders.
+
+## Bundle size
+
+The same minimal routed app bundled from each router's published dist build (rolldown, minified,
+production, `react`/`react-dom` external):
+
+| | minified | min+gzip |
+| ------------------------------------------------------------------ | -------- | ---------- |
+| jarl, full cost (jarl-atoms + jarl-react + jotai + jotai-location) | 14.1 kB | **5.7 kB** |
+| jarl, app already using jotai | 4.6 kB | **2.0 kB** |
+| react-router | 90.1 kB | 28.3 kB |
+
+Not a like-for-like feature set: react-router's bundle carries its data APIs (loaders, actions,
+lazy routes) whether or not the app uses them, where JARL's data loading is jotai's own async
+atoms. It is, however, the real wire cost of "a routed app" with each library.
+
+## What these numbers do not show
+
+- No real-browser timings — no layout, paint or input latency; jsdom timings cover library and
+ React render/commit work only.
+- react-router's actions and lazy-route machinery are unexercised.
+- Two app shapes and two route-table shapes; splats or query-heavy routing may rank differently.
diff --git a/packages/docs/src/pages/Docs.tsx b/packages/docs/src/pages/Docs.tsx
index daa4082..bb72c95 100644
--- a/packages/docs/src/pages/Docs.tsx
+++ b/packages/docs/src/pages/Docs.tsx
@@ -1,6 +1,7 @@
import gettingStarted from "../content/guides/GettingStarted.md?raw";
import dataLoading from "../content/guides/DataLoading.md?raw";
import pathVariables from "../content/guides/PathVariables.md?raw";
+import benchmarks from "../content/guides/Benchmarks.md?raw";
import LinkList from "../lib/LinkList";
import Markdown from "../lib/Markdown";
import { Link } from "jarl-react";
@@ -10,6 +11,7 @@ const guides: Record = {
"getting-started": gettingStarted,
"data-loading": dataLoading,
"path-variables": pathVariables,
+ benchmarks,
};
export const DocsIndex = () => (
diff --git a/packages/docs/src/router/routes.ts b/packages/docs/src/router/routes.ts
index f2def08..e2bf2bc 100644
--- a/packages/docs/src/router/routes.ts
+++ b/packages/docs/src/router/routes.ts
@@ -78,12 +78,13 @@ export const notFoundAtom = atom(
(get) => get(exactRouteMissedAtom) && !get(changelogRoute).match && !get(blogRoutingDemoRoute).match,
);
-export type DocName = "getting-started" | "data-loading" | "path-variables";
+export type DocName = "getting-started" | "data-loading" | "path-variables" | "benchmarks";
export const docPages: { docName: DocName; title: string }[] = [
{ docName: "getting-started", title: "Getting Started" },
{ docName: "data-loading", title: "Data Loading" },
{ docName: "path-variables", title: "Path Variables" },
+ { docName: "benchmarks", title: "Benchmarks" },
];
export type ApiName = "jarl-atoms" | "jarl-react";