From d293e8f3d0971c8a2a9bbbc28efdde15bce484cb Mon Sep 17 00:00:00 2001
From: Peter Hurst
Date: Tue, 18 Aug 2026 05:09:30 +0100
Subject: [PATCH 1/5] feat(bench): jarl vs react-router benchmark workspace
Reproducible comparison harness: per-navigation re-render counts with a
byte-identical-HTML parity assertion between the two apps, matching/resolve
and navigation throughput under plain Node, and min+gzip bundle size from the
published dist builds. Run from the repo root with `npm run bench`; the
deterministic parity/count test also runs under `npm test` in CI.
Ticket: 674
---
bench/README.md | 78 ++++++++++++++++++++
bench/package.json | 29 ++++++++
bench/src/apps/JarlApp.tsx | 92 +++++++++++++++++++++++
bench/src/apps/ReactRouterApp.tsx | 76 +++++++++++++++++++
bench/src/apps/sharedComponents.tsx | 48 ++++++++++++
bench/src/apps/types.ts | 7 ++
bench/src/bundle-size.benchmark.ts | 44 +++++++++++
bench/src/matching.benchmark.ts | 77 ++++++++++++++++++++
bench/src/renderCounter.ts | 18 +++++
bench/src/renders.test.tsx | 101 ++++++++++++++++++++++++++
bench/src/shape.ts | 6 ++
bench/src/size/jarl-entry.tsx | 40 ++++++++++
bench/src/size/react-router-entry.tsx | 31 ++++++++
bench/src/stats.ts | 80 ++++++++++++++++++++
bench/tsconfig.json | 7 ++
bench/vitest.bench.config.ts | 17 +++++
bench/vitest.config.ts | 13 ++++
package-lock.json | 55 +++++++++++++-
package.json | 4 +-
19 files changed, 821 insertions(+), 2 deletions(-)
create mode 100644 bench/README.md
create mode 100644 bench/package.json
create mode 100644 bench/src/apps/JarlApp.tsx
create mode 100644 bench/src/apps/ReactRouterApp.tsx
create mode 100644 bench/src/apps/sharedComponents.tsx
create mode 100644 bench/src/apps/types.ts
create mode 100644 bench/src/bundle-size.benchmark.ts
create mode 100644 bench/src/matching.benchmark.ts
create mode 100644 bench/src/renderCounter.ts
create mode 100644 bench/src/renders.test.tsx
create mode 100644 bench/src/shape.ts
create mode 100644 bench/src/size/jarl-entry.tsx
create mode 100644 bench/src/size/react-router-entry.tsx
create mode 100644 bench/src/stats.ts
create mode 100644 bench/tsconfig.json
create mode 100644 bench/vitest.bench.config.ts
create mode 100644 bench/vitest.config.ts
diff --git a/bench/README.md b/bench/README.md
new file mode 100644
index 0000000..e855efa
--- /dev/null
+++ b/bench/README.md
@@ -0,0 +1,78 @@
+# 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.
+
+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.
+
+### 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: navigation cost here is library work only, not layout/paint, and jsdom
+ is used solely for deterministic render counting.
+- No data APIs: react-router's loaders/actions/lazy-route machinery and jarl's async route atoms
+ are unexercised, though the react-router bundle necessarily carries that code.
+- One app shape and one route-table shape; different shapes (deep nesting, 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 (
+
,
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+ return {
+ element: ,
+ navigate: (path) => void router.navigate(path),
+ dispose: () => router.dispose(),
+ };
+};
+
+const createCascadeApp = (): Harness => {
+ // One promise per key, held across renders so use() can settle.
+ const cache = new Map>();
+ const cachedFetch = (key: string): Promise => {
+ let promise = cache.get(key);
+ if (!promise) {
+ promise = fetchData(key);
+ cache.set(key, promise);
+ }
+ return promise;
+ };
+
+ const CascadeLevel = ({ level, children }: { level: "a" | "b" | "c"; children?: React.ReactNode }) => {
+ const params = useParams();
+ const data = use(cachedFetch(`${level}:${params[`p${level}`]}`));
+ return children ? (
+
+
+ {level.toUpperCase()}:{data}
+
+ {children}
+
+ ) : (
+
+ {level.toUpperCase()}:{data}
+
+ );
+ };
+
+ const router = createBrowserRouter([
+ {
+ path: "a/:pa",
+ element: (
+
+
+
+ ),
+ children: [
+ {
+ path: "b/:pb",
+ element: (
+
+
+
+ ),
+ children: [{ path: "c/:pc", element: }],
+ },
+ ],
+ },
+ ]);
+ return {
+ element: (
+ Loading}>
+
+
+ ),
+ navigate: (path) => void router.navigate(path),
+ dispose: () => router.dispose(),
+ };
+};
+
+test("nested async data: parallel pre-resolution vs loaders vs a Suspense cascade", async () => {
+ const jarl = await timeApp(createJarlAsyncApp);
+ const loaders = await timeApp(createLoaderApp);
+ const cascade = await timeApp(createCascadeApp);
+
+ console.log(`\nNested async data, 3 levels × ${DELAY_MS}ms lookup, navigation → deepest data on screen (ms):`);
+ console.log(` jarl (followAsyncRoutes) ${formatSummary(jarl, "ms")}`);
+ console.log(` react-router (loaders) ${formatSummary(loaders, "ms")}`);
+ console.log(` react-router (Suspense cascade) ${formatSummary(cascade, "ms")}`);
+});
diff --git a/bench/src/stats.ts b/bench/src/stats.ts
index 618aa2f..c42cbbc 100644
--- a/bench/src/stats.ts
+++ b/bench/src/stats.ts
@@ -65,8 +65,8 @@ export const measureAsync = async (
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): string =>
- `median ${fmt(median)}µs p25 ${fmt(p25)}µs p75 ${fmt(p75)}µs min ${fmt(min)}µs max ${fmt(max)}µs (n=${samples})`;
+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)[][]) => {
From 72e7e323d9e6193ebfe172503fcd3537f93f24ac Mon Sep 17 00:00:00 2001
From: Peter Hurst
Date: Tue, 18 Aug 2026 05:09:30 +0100
Subject: [PATCH 5/5] docs: publish benchmark results and point the README
claim at them
Adds the Benchmarks guide (results, methodology, honest caveats: re-render
ties, the mount double-render, and react-router's faster stateful navigate)
and replaces the README's unmeasured "incredibly efficient" line with a link
to the measured comparison.
Ticket: 674
---
README.md | 8 +-
bench/README.md | 42 +++-
.../docs/src/content/guides/Benchmarks.md | 182 ++++++++++++++++++
packages/docs/src/pages/Docs.tsx | 2 +
packages/docs/src/router/routes.ts | 3 +-
5 files changed, 227 insertions(+), 10 deletions(-)
create mode 100644 packages/docs/src/content/guides/Benchmarks.md
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
index e855efa..b96d599 100644
--- a/bench/README.md
+++ b/bench/README.md
@@ -56,10 +56,41 @@ outcome rather than mechanics:
(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.
@@ -70,9 +101,8 @@ bundled) and with jotai external, for apps already using jotai.
## What the numbers do not show
-- No real-browser timings: navigation cost here is library work only, not layout/paint, and jsdom
- is used solely for deterministic render counting.
-- No data APIs: react-router's loaders/actions/lazy-route machinery and jarl's async route atoms
- are unexercised, though the react-router bundle necessarily carries that code.
-- One app shape and one route-table shape; different shapes (deep nesting, splats, query-heavy
- routing) may rank differently.
+- 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/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";