feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93

Open
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router
Open

feat(bench): 674 — benchmark jarl against react-router, and publish the results#93
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Backs the README's "incredibly efficient" claim with an actual measurement against react-router, and publishes the methodology and results as a docs guide.

Based on task-530-retire-jarl-react-redux-and-native-packages (#89), not master.

Methodology

  • jarl jarl-atoms/jarl-react 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) vs react-router 8.3.0 data router, on identical react/react-dom 19.2.8.
  • Node 24.15.0, Intel i7-1165G7, Linux (WSL2).
  • Both routers drive the same app (13 active-styled nav links, 10 non-routing components, four routed pages), sharing every router-agnostic component verbatim. The render test asserts both apps produce byte-identical HTML after mount and after every single navigation — that assertion is what makes the comparison like-for-like, and it fails loudly if either app drifts.
  • Timed numbers: median of 30 retained samples × 1000 operations, after 10 discarded warm-up samples, GC forced between samples, NODE_ENV=production, one forked process. Reported with p25/p75.
  • Reproduce with npm run bench from the repo root.

Headline numbers, wins and losses alike

Re-renders per navigation — a tie. 13/13 nav links, 1/1 changed page, 0/0 layout, 0/0 non-routing components. react-router's context model is just as precise here, and the write-up says so in bold. jarl's one measured deficit: every route-atom subscriber renders twice at mount (react-router renders once).

Throughput — jarl wins one, loses one:

workloadjarlreact-router
resolve URL → matched leaf (warm)103 µs375 µs
resolve, cold store per URL117 µs375 µs
client navigation via each API100 µs56 µs

Bundle (min+gzip): jarl 5.7 kB full cost / 2.0 kB with jotai external, react-router 28.3 kB — flagged in the guide as not a like-for-like feature set, since react-router carries its data APIs regardless.

It also corrected a false README claim found along the way: route atoms return 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 skip unaffected routes. The README now says only what the harness measures, and links to the numbers.

What the numbers do not show

No real-browser timings (no layout/paint/input latency), no data loading on either side, one app shape and one route-table shape. Both READMEs state this.

Review changes made before opening

  • Cut the duplicated methodology essays from the harness source (a 17-line header on matching.benchmark.ts among others); that prose now lives once, in bench/README.md.
  • Deleted dead navEntries/NavEntry exports from bench/src/shape.ts.
  • Documented two fairness asymmetries that were previously unstated: jarl's leaf reads short-circuit at the first match (hence the deliberate early/middle/late/miss URL cycle), and router.navigate() does strictly more work than the atom write that loses to it — so that gap is understated in jarl's disfavour, not its favour.
  • Formatted Benchmarks.md with oxfmt (it was the only unformatted file in the tree).

Style exceptions

None. No exception: markers in the diff.

@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

I'm confused about the results. Client navigation is slated as slower than RR, but resolving a route takes significantly longer. Surely when navigating on the client a route must be resolved, so how can that be faster when the different is so great on resolution?

Let's almost come up with a rather more complicated scenario - multi-level deep nest routing, and also pit jotai's pure-start "switch" version vs react-router's route components (or maybe their routeconfig, which might be a bit more comparison)

Another comparison worth doing : client navigation with jarl pre-resolving nested async data via atoms, vs react router triggering a multi-level Suspense cascade (pretty sure I know which one is better, but it's good to benchmark as well, and have a bigger more interesting set of numbers to show)

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6CompareAugust 19, 2026 00:41
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89CompareAugust 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

Done in 771ef89 — all three asks are in, and the suite is CI-green. Posting this on the agent's behalf; it hit a model usage limit after pushing but before replying.

Your first question: navigation is slower than resolution, so how can navigating be faster?

It isn't a contradiction — the two rows price different work, and the original table hid that. Navigating does resolve.

matchRoutes() flattens and ranks the whole route config on every call. A data router does that once at creation and keeps the ranked branches (precomputedBranches in its router.ts), so each router.navigate() matches against a table whose preparation is already amortised to zero. The resolve row was pricing an ad-hoc matchRoutes caller — table prep included, every call. The navigate row was pricing a mounted app that never pays it again.

There's a new commit (085990f) that demonstrates this rather than asserting it: hold the matched URL at the first-ranked branch so per-call match work is constant, then grow the table.

routes in tablematchRoutesrouter.navigatejarl (first leaf read)
217 µs61 µs12 µs
20112 µs79 µs18 µs
100421 µs73 µs18 µs

matchRoutes scales linearly with table size; router.navigate is near-flat. jarl has no preparation step to amortise — route atoms are their own index — which is why it wins resolve outright and loses navigate to a router that already paid resolution's expensive half up front.

Deep nesting (five levels, <Routes> vs route config vs jarl)

Render counts are a three-way tie — every level re-renders in every router, so the only difference is per-render cost:

per navigation, React render + commitmedian
react-router <Routes>137 µs
react-router data router397 µs
jarl493 µs

A clear jarl loss. The declarative form — no state machine, tiny table re-matched per render — is the fastest way to do a deep navigation. jarl pays re-deriving five levels of route atoms plus six useAtom subscribers.

Nested async: atoms vs Suspense cascade

Three levels, one 25 ms lookup each, never cached:

strategymedian
jarl asyncRouteAtom + followAsyncRoutes26.8 ms
react-router loaders26.9 ms
react-router per-component Suspense cascade78.1 ms

You guessed the shape right, but the honest framing is narrower than "jarl wins": jarl lands in ~one lookup's time, and react-router's loaders match it exactly. The 3× is the cost of not using a router-level data story on either side. The real claim is that jarl gives you the parallel behaviour as the idiomatic default where react-router requires opting into loaders.

Worth knowing before this merges

The doc deliberately records where jarl ties or loses, not just where it wins:

  • Re-renders on the flat app are a tie. Components reading no route state are never re-rendered by navigation in either router — react-router's context model is more precise than it's usually given credit for.
  • A route atom's value is a fresh object per 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. This is what makes the deep-nesting result a tie on counts.
  • jarl renders route-atom subscribers twice at initial mount (once for the tree, once when atomWithLocation first syncs).
  • Bundle size is the standout win: 5.7 kB min+gzip full cost, 2.0 kB if the app already uses jotai, against 28.3 kB — though not like-for-like, since react-router ships its data APIs regardless.

Two caveats on the numbers themselves: they're jsdom, so no layout, paint or input latency; and the timed run was taken on a machine that had been running other work, so treat absolute medians as ±20% — orderings and ratios were stable across five repeat runs, and those are the result.

The first two bullets above look like the most interesting follow-up: if fresh-object identity per location change is what forces every subscriber to re-render, making route atoms stable-when-unchanged would turn several of these ties into wins. Happy to file that as its own ticket if you want it chased.

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1CompareAugust 19, 2026 13:38
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74CompareAugust 19, 2026 13:38
@randomdevpeterandomdevpete changed the title Benchmark jarl against react-router, and publish the results674 — Benchmark jarl against react-router, and publish the resultsAug 19, 2026
@randomdevpeterandomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the resultsfeat(bench): 674 — benchmark jarl against react-router, and publish the resultsAug 19, 2026
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
A rank-0-hit workload over a growing table shows the public matchRoutes
re-flattening and ranking the config on every call, while a data router
ranks once at creation and navigates against the cached branches - which
is why react-router navigates faster than it resolves.
Ticket: 674
jarl's nested Switch/Route atoms against react-router's data router and
declarative <Routes> forms: render counts per level for leaf, mid and
root param changes (byte-identical HTML asserted across all three), and
a timed leaf toggle with React render and commit included.
Ticket: 674
Three-level chain, one 25ms lookup per level, navigation to deepest
data on screen: jarl's followAsyncRoutes parallel pre-resolution vs
react-router loaders vs a per-component Suspense cascade.
Ticket: 674
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
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32CompareAugust 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to masterAugust 21, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93

Open
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router
Open

feat(bench): 674 — benchmark jarl against react-router, and publish the results#93
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Backs the README's "incredibly efficient" claim with an actual measurement against react-router, and publishes the methodology and results as a docs guide.

Based on task-530-retire-jarl-react-redux-and-native-packages (#89), not master.

Methodology

  • jarl jarl-atoms/jarl-react 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) vs react-router 8.3.0 data router, on identical react/react-dom 19.2.8.
  • Node 24.15.0, Intel i7-1165G7, Linux (WSL2).
  • Both routers drive the same app (13 active-styled nav links, 10 non-routing components, four routed pages), sharing every router-agnostic component verbatim. The render test asserts both apps produce byte-identical HTML after mount and after every single navigation — that assertion is what makes the comparison like-for-like, and it fails loudly if either app drifts.
  • Timed numbers: median of 30 retained samples × 1000 operations, after 10 discarded warm-up samples, GC forced between samples, NODE_ENV=production, one forked process. Reported with p25/p75.
  • Reproduce with npm run bench from the repo root.

Headline numbers, wins and losses alike

Re-renders per navigation — a tie. 13/13 nav links, 1/1 changed page, 0/0 layout, 0/0 non-routing components. react-router's context model is just as precise here, and the write-up says so in bold. jarl's one measured deficit: every route-atom subscriber renders twice at mount (react-router renders once).

Throughput — jarl wins one, loses one:

workloadjarlreact-router
resolve URL → matched leaf (warm)103 µs375 µs
resolve, cold store per URL117 µs375 µs
client navigation via each API100 µs56 µs

Bundle (min+gzip): jarl 5.7 kB full cost / 2.0 kB with jotai external, react-router 28.3 kB — flagged in the guide as not a like-for-like feature set, since react-router carries its data APIs regardless.

It also corrected a false README claim found along the way: route atoms return 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 skip unaffected routes. The README now says only what the harness measures, and links to the numbers.

What the numbers do not show

No real-browser timings (no layout/paint/input latency), no data loading on either side, one app shape and one route-table shape. Both READMEs state this.

Review changes made before opening

  • Cut the duplicated methodology essays from the harness source (a 17-line header on matching.benchmark.ts among others); that prose now lives once, in bench/README.md.
  • Deleted dead navEntries/NavEntry exports from bench/src/shape.ts.
  • Documented two fairness asymmetries that were previously unstated: jarl's leaf reads short-circuit at the first match (hence the deliberate early/middle/late/miss URL cycle), and router.navigate() does strictly more work than the atom write that loses to it — so that gap is understated in jarl's disfavour, not its favour.
  • Formatted Benchmarks.md with oxfmt (it was the only unformatted file in the tree).

Style exceptions

None. No exception: markers in the diff.

@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

I'm confused about the results. Client navigation is slated as slower than RR, but resolving a route takes significantly longer. Surely when navigating on the client a route must be resolved, so how can that be faster when the different is so great on resolution?

Let's almost come up with a rather more complicated scenario - multi-level deep nest routing, and also pit jotai's pure-start "switch" version vs react-router's route components (or maybe their routeconfig, which might be a bit more comparison)

Another comparison worth doing : client navigation with jarl pre-resolving nested async data via atoms, vs react router triggering a multi-level Suspense cascade (pretty sure I know which one is better, but it's good to benchmark as well, and have a bigger more interesting set of numbers to show)

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6CompareAugust 19, 2026 00:41
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89CompareAugust 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

Done in 771ef89 — all three asks are in, and the suite is CI-green. Posting this on the agent's behalf; it hit a model usage limit after pushing but before replying.

Your first question: navigation is slower than resolution, so how can navigating be faster?

It isn't a contradiction — the two rows price different work, and the original table hid that. Navigating does resolve.

matchRoutes() flattens and ranks the whole route config on every call. A data router does that once at creation and keeps the ranked branches (precomputedBranches in its router.ts), so each router.navigate() matches against a table whose preparation is already amortised to zero. The resolve row was pricing an ad-hoc matchRoutes caller — table prep included, every call. The navigate row was pricing a mounted app that never pays it again.

There's a new commit (085990f) that demonstrates this rather than asserting it: hold the matched URL at the first-ranked branch so per-call match work is constant, then grow the table.

routes in tablematchRoutesrouter.navigatejarl (first leaf read)
217 µs61 µs12 µs
20112 µs79 µs18 µs
100421 µs73 µs18 µs

matchRoutes scales linearly with table size; router.navigate is near-flat. jarl has no preparation step to amortise — route atoms are their own index — which is why it wins resolve outright and loses navigate to a router that already paid resolution's expensive half up front.

Deep nesting (five levels, <Routes> vs route config vs jarl)

Render counts are a three-way tie — every level re-renders in every router, so the only difference is per-render cost:

per navigation, React render + commitmedian
react-router <Routes>137 µs
react-router data router397 µs
jarl493 µs

A clear jarl loss. The declarative form — no state machine, tiny table re-matched per render — is the fastest way to do a deep navigation. jarl pays re-deriving five levels of route atoms plus six useAtom subscribers.

Nested async: atoms vs Suspense cascade

Three levels, one 25 ms lookup each, never cached:

strategymedian
jarl asyncRouteAtom + followAsyncRoutes26.8 ms
react-router loaders26.9 ms
react-router per-component Suspense cascade78.1 ms

You guessed the shape right, but the honest framing is narrower than "jarl wins": jarl lands in ~one lookup's time, and react-router's loaders match it exactly. The 3× is the cost of not using a router-level data story on either side. The real claim is that jarl gives you the parallel behaviour as the idiomatic default where react-router requires opting into loaders.

Worth knowing before this merges

The doc deliberately records where jarl ties or loses, not just where it wins:

  • Re-renders on the flat app are a tie. Components reading no route state are never re-rendered by navigation in either router — react-router's context model is more precise than it's usually given credit for.
  • A route atom's value is a fresh object per 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. This is what makes the deep-nesting result a tie on counts.
  • jarl renders route-atom subscribers twice at initial mount (once for the tree, once when atomWithLocation first syncs).
  • Bundle size is the standout win: 5.7 kB min+gzip full cost, 2.0 kB if the app already uses jotai, against 28.3 kB — though not like-for-like, since react-router ships its data APIs regardless.

Two caveats on the numbers themselves: they're jsdom, so no layout, paint or input latency; and the timed run was taken on a machine that had been running other work, so treat absolute medians as ±20% — orderings and ratios were stable across five repeat runs, and those are the result.

The first two bullets above look like the most interesting follow-up: if fresh-object identity per location change is what forces every subscriber to re-render, making route atoms stable-when-unchanged would turn several of these ties into wins. Happy to file that as its own ticket if you want it chased.

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1CompareAugust 19, 2026 13:38
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74CompareAugust 19, 2026 13:38
@randomdevpeterandomdevpete changed the title Benchmark jarl against react-router, and publish the results674 — Benchmark jarl against react-router, and publish the resultsAug 19, 2026
@randomdevpeterandomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the resultsfeat(bench): 674 — benchmark jarl against react-router, and publish the resultsAug 19, 2026
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
A rank-0-hit workload over a growing table shows the public matchRoutes
re-flattening and ranking the config on every call, while a data router
ranks once at creation and navigates against the cached branches - which
is why react-router navigates faster than it resolves.
Ticket: 674
jarl's nested Switch/Route atoms against react-router's data router and
declarative <Routes> forms: render counts per level for leaf, mid and
root param changes (byte-identical HTML asserted across all three), and
a timed leaf toggle with React render and commit included.
Ticket: 674
Three-level chain, one 25ms lookup per level, navigation to deepest
data on screen: jarl's followAsyncRoutes parallel pre-resolution vs
react-router loaders vs a per-component Suspense cascade.
Ticket: 674
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
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32CompareAugust 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to masterAugust 21, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93

Open
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router
Open

feat(bench): 674 — benchmark jarl against react-router, and publish the results#93
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Backs the README's "incredibly efficient" claim with an actual measurement against react-router, and publishes the methodology and results as a docs guide.

Based on task-530-retire-jarl-react-redux-and-native-packages (#89), not master.

Methodology

  • jarl jarl-atoms/jarl-react 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) vs react-router 8.3.0 data router, on identical react/react-dom 19.2.8.
  • Node 24.15.0, Intel i7-1165G7, Linux (WSL2).
  • Both routers drive the same app (13 active-styled nav links, 10 non-routing components, four routed pages), sharing every router-agnostic component verbatim. The render test asserts both apps produce byte-identical HTML after mount and after every single navigation — that assertion is what makes the comparison like-for-like, and it fails loudly if either app drifts.
  • Timed numbers: median of 30 retained samples × 1000 operations, after 10 discarded warm-up samples, GC forced between samples, NODE_ENV=production, one forked process. Reported with p25/p75.
  • Reproduce with npm run bench from the repo root.

Headline numbers, wins and losses alike

Re-renders per navigation — a tie. 13/13 nav links, 1/1 changed page, 0/0 layout, 0/0 non-routing components. react-router's context model is just as precise here, and the write-up says so in bold. jarl's one measured deficit: every route-atom subscriber renders twice at mount (react-router renders once).

Throughput — jarl wins one, loses one:

workloadjarlreact-router
resolve URL → matched leaf (warm)103 µs375 µs
resolve, cold store per URL117 µs375 µs
client navigation via each API100 µs56 µs

Bundle (min+gzip): jarl 5.7 kB full cost / 2.0 kB with jotai external, react-router 28.3 kB — flagged in the guide as not a like-for-like feature set, since react-router carries its data APIs regardless.

It also corrected a false README claim found along the way: route atoms return 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 skip unaffected routes. The README now says only what the harness measures, and links to the numbers.

What the numbers do not show

No real-browser timings (no layout/paint/input latency), no data loading on either side, one app shape and one route-table shape. Both READMEs state this.

Review changes made before opening

  • Cut the duplicated methodology essays from the harness source (a 17-line header on matching.benchmark.ts among others); that prose now lives once, in bench/README.md.
  • Deleted dead navEntries/NavEntry exports from bench/src/shape.ts.
  • Documented two fairness asymmetries that were previously unstated: jarl's leaf reads short-circuit at the first match (hence the deliberate early/middle/late/miss URL cycle), and router.navigate() does strictly more work than the atom write that loses to it — so that gap is understated in jarl's disfavour, not its favour.
  • Formatted Benchmarks.md with oxfmt (it was the only unformatted file in the tree).

Style exceptions

None. No exception: markers in the diff.

@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

I'm confused about the results. Client navigation is slated as slower than RR, but resolving a route takes significantly longer. Surely when navigating on the client a route must be resolved, so how can that be faster when the different is so great on resolution?

Let's almost come up with a rather more complicated scenario - multi-level deep nest routing, and also pit jotai's pure-start "switch" version vs react-router's route components (or maybe their routeconfig, which might be a bit more comparison)

Another comparison worth doing : client navigation with jarl pre-resolving nested async data via atoms, vs react router triggering a multi-level Suspense cascade (pretty sure I know which one is better, but it's good to benchmark as well, and have a bigger more interesting set of numbers to show)

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6CompareAugust 19, 2026 00:41
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89CompareAugust 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

Done in 771ef89 — all three asks are in, and the suite is CI-green. Posting this on the agent's behalf; it hit a model usage limit after pushing but before replying.

Your first question: navigation is slower than resolution, so how can navigating be faster?

It isn't a contradiction — the two rows price different work, and the original table hid that. Navigating does resolve.

matchRoutes() flattens and ranks the whole route config on every call. A data router does that once at creation and keeps the ranked branches (precomputedBranches in its router.ts), so each router.navigate() matches against a table whose preparation is already amortised to zero. The resolve row was pricing an ad-hoc matchRoutes caller — table prep included, every call. The navigate row was pricing a mounted app that never pays it again.

There's a new commit (085990f) that demonstrates this rather than asserting it: hold the matched URL at the first-ranked branch so per-call match work is constant, then grow the table.

routes in tablematchRoutesrouter.navigatejarl (first leaf read)
217 µs61 µs12 µs
20112 µs79 µs18 µs
100421 µs73 µs18 µs

matchRoutes scales linearly with table size; router.navigate is near-flat. jarl has no preparation step to amortise — route atoms are their own index — which is why it wins resolve outright and loses navigate to a router that already paid resolution's expensive half up front.

Deep nesting (five levels, <Routes> vs route config vs jarl)

Render counts are a three-way tie — every level re-renders in every router, so the only difference is per-render cost:

per navigation, React render + commitmedian
react-router <Routes>137 µs
react-router data router397 µs
jarl493 µs

A clear jarl loss. The declarative form — no state machine, tiny table re-matched per render — is the fastest way to do a deep navigation. jarl pays re-deriving five levels of route atoms plus six useAtom subscribers.

Nested async: atoms vs Suspense cascade

Three levels, one 25 ms lookup each, never cached:

strategymedian
jarl asyncRouteAtom + followAsyncRoutes26.8 ms
react-router loaders26.9 ms
react-router per-component Suspense cascade78.1 ms

You guessed the shape right, but the honest framing is narrower than "jarl wins": jarl lands in ~one lookup's time, and react-router's loaders match it exactly. The 3× is the cost of not using a router-level data story on either side. The real claim is that jarl gives you the parallel behaviour as the idiomatic default where react-router requires opting into loaders.

Worth knowing before this merges

The doc deliberately records where jarl ties or loses, not just where it wins:

  • Re-renders on the flat app are a tie. Components reading no route state are never re-rendered by navigation in either router — react-router's context model is more precise than it's usually given credit for.
  • A route atom's value is a fresh object per 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. This is what makes the deep-nesting result a tie on counts.
  • jarl renders route-atom subscribers twice at initial mount (once for the tree, once when atomWithLocation first syncs).
  • Bundle size is the standout win: 5.7 kB min+gzip full cost, 2.0 kB if the app already uses jotai, against 28.3 kB — though not like-for-like, since react-router ships its data APIs regardless.

Two caveats on the numbers themselves: they're jsdom, so no layout, paint or input latency; and the timed run was taken on a machine that had been running other work, so treat absolute medians as ±20% — orderings and ratios were stable across five repeat runs, and those are the result.

The first two bullets above look like the most interesting follow-up: if fresh-object identity per location change is what forces every subscriber to re-render, making route atoms stable-when-unchanged would turn several of these ties into wins. Happy to file that as its own ticket if you want it chased.

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1CompareAugust 19, 2026 13:38
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74CompareAugust 19, 2026 13:38
@randomdevpeterandomdevpete changed the title Benchmark jarl against react-router, and publish the results674 — Benchmark jarl against react-router, and publish the resultsAug 19, 2026
@randomdevpeterandomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the resultsfeat(bench): 674 — benchmark jarl against react-router, and publish the resultsAug 19, 2026
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
A rank-0-hit workload over a growing table shows the public matchRoutes
re-flattening and ranking the config on every call, while a data router
ranks once at creation and navigates against the cached branches - which
is why react-router navigates faster than it resolves.
Ticket: 674
jarl's nested Switch/Route atoms against react-router's data router and
declarative <Routes> forms: render counts per level for leaf, mid and
root param changes (byte-identical HTML asserted across all three), and
a timed leaf toggle with React render and commit included.
Ticket: 674
Three-level chain, one 25ms lookup per level, navigation to deepest
data on screen: jarl's followAsyncRoutes parallel pre-resolution vs
react-router loaders vs a per-component Suspense cascade.
Ticket: 674
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
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32CompareAugust 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to masterAugust 21, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93

Open
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router
Open

feat(bench): 674 — benchmark jarl against react-router, and publish the results#93
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Backs the README's "incredibly efficient" claim with an actual measurement against react-router, and publishes the methodology and results as a docs guide.

Based on task-530-retire-jarl-react-redux-and-native-packages (#89), not master.

Methodology

  • jarl jarl-atoms/jarl-react 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) vs react-router 8.3.0 data router, on identical react/react-dom 19.2.8.
  • Node 24.15.0, Intel i7-1165G7, Linux (WSL2).
  • Both routers drive the same app (13 active-styled nav links, 10 non-routing components, four routed pages), sharing every router-agnostic component verbatim. The render test asserts both apps produce byte-identical HTML after mount and after every single navigation — that assertion is what makes the comparison like-for-like, and it fails loudly if either app drifts.
  • Timed numbers: median of 30 retained samples × 1000 operations, after 10 discarded warm-up samples, GC forced between samples, NODE_ENV=production, one forked process. Reported with p25/p75.
  • Reproduce with npm run bench from the repo root.

Headline numbers, wins and losses alike

Re-renders per navigation — a tie. 13/13 nav links, 1/1 changed page, 0/0 layout, 0/0 non-routing components. react-router's context model is just as precise here, and the write-up says so in bold. jarl's one measured deficit: every route-atom subscriber renders twice at mount (react-router renders once).

Throughput — jarl wins one, loses one:

workloadjarlreact-router
resolve URL → matched leaf (warm)103 µs375 µs
resolve, cold store per URL117 µs375 µs
client navigation via each API100 µs56 µs

Bundle (min+gzip): jarl 5.7 kB full cost / 2.0 kB with jotai external, react-router 28.3 kB — flagged in the guide as not a like-for-like feature set, since react-router carries its data APIs regardless.

It also corrected a false README claim found along the way: route atoms return 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 skip unaffected routes. The README now says only what the harness measures, and links to the numbers.

What the numbers do not show

No real-browser timings (no layout/paint/input latency), no data loading on either side, one app shape and one route-table shape. Both READMEs state this.

Review changes made before opening

  • Cut the duplicated methodology essays from the harness source (a 17-line header on matching.benchmark.ts among others); that prose now lives once, in bench/README.md.
  • Deleted dead navEntries/NavEntry exports from bench/src/shape.ts.
  • Documented two fairness asymmetries that were previously unstated: jarl's leaf reads short-circuit at the first match (hence the deliberate early/middle/late/miss URL cycle), and router.navigate() does strictly more work than the atom write that loses to it — so that gap is understated in jarl's disfavour, not its favour.
  • Formatted Benchmarks.md with oxfmt (it was the only unformatted file in the tree).

Style exceptions

None. No exception: markers in the diff.

@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

I'm confused about the results. Client navigation is slated as slower than RR, but resolving a route takes significantly longer. Surely when navigating on the client a route must be resolved, so how can that be faster when the different is so great on resolution?

Let's almost come up with a rather more complicated scenario - multi-level deep nest routing, and also pit jotai's pure-start "switch" version vs react-router's route components (or maybe their routeconfig, which might be a bit more comparison)

Another comparison worth doing : client navigation with jarl pre-resolving nested async data via atoms, vs react router triggering a multi-level Suspense cascade (pretty sure I know which one is better, but it's good to benchmark as well, and have a bigger more interesting set of numbers to show)

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6CompareAugust 19, 2026 00:41
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89CompareAugust 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

Done in 771ef89 — all three asks are in, and the suite is CI-green. Posting this on the agent's behalf; it hit a model usage limit after pushing but before replying.

Your first question: navigation is slower than resolution, so how can navigating be faster?

It isn't a contradiction — the two rows price different work, and the original table hid that. Navigating does resolve.

matchRoutes() flattens and ranks the whole route config on every call. A data router does that once at creation and keeps the ranked branches (precomputedBranches in its router.ts), so each router.navigate() matches against a table whose preparation is already amortised to zero. The resolve row was pricing an ad-hoc matchRoutes caller — table prep included, every call. The navigate row was pricing a mounted app that never pays it again.

There's a new commit (085990f) that demonstrates this rather than asserting it: hold the matched URL at the first-ranked branch so per-call match work is constant, then grow the table.

routes in tablematchRoutesrouter.navigatejarl (first leaf read)
217 µs61 µs12 µs
20112 µs79 µs18 µs
100421 µs73 µs18 µs

matchRoutes scales linearly with table size; router.navigate is near-flat. jarl has no preparation step to amortise — route atoms are their own index — which is why it wins resolve outright and loses navigate to a router that already paid resolution's expensive half up front.

Deep nesting (five levels, <Routes> vs route config vs jarl)

Render counts are a three-way tie — every level re-renders in every router, so the only difference is per-render cost:

per navigation, React render + commitmedian
react-router <Routes>137 µs
react-router data router397 µs
jarl493 µs

A clear jarl loss. The declarative form — no state machine, tiny table re-matched per render — is the fastest way to do a deep navigation. jarl pays re-deriving five levels of route atoms plus six useAtom subscribers.

Nested async: atoms vs Suspense cascade

Three levels, one 25 ms lookup each, never cached:

strategymedian
jarl asyncRouteAtom + followAsyncRoutes26.8 ms
react-router loaders26.9 ms
react-router per-component Suspense cascade78.1 ms

You guessed the shape right, but the honest framing is narrower than "jarl wins": jarl lands in ~one lookup's time, and react-router's loaders match it exactly. The 3× is the cost of not using a router-level data story on either side. The real claim is that jarl gives you the parallel behaviour as the idiomatic default where react-router requires opting into loaders.

Worth knowing before this merges

The doc deliberately records where jarl ties or loses, not just where it wins:

  • Re-renders on the flat app are a tie. Components reading no route state are never re-rendered by navigation in either router — react-router's context model is more precise than it's usually given credit for.
  • A route atom's value is a fresh object per 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. This is what makes the deep-nesting result a tie on counts.
  • jarl renders route-atom subscribers twice at initial mount (once for the tree, once when atomWithLocation first syncs).
  • Bundle size is the standout win: 5.7 kB min+gzip full cost, 2.0 kB if the app already uses jotai, against 28.3 kB — though not like-for-like, since react-router ships its data APIs regardless.

Two caveats on the numbers themselves: they're jsdom, so no layout, paint or input latency; and the timed run was taken on a machine that had been running other work, so treat absolute medians as ±20% — orderings and ratios were stable across five repeat runs, and those are the result.

The first two bullets above look like the most interesting follow-up: if fresh-object identity per location change is what forces every subscriber to re-render, making route atoms stable-when-unchanged would turn several of these ties into wins. Happy to file that as its own ticket if you want it chased.

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1CompareAugust 19, 2026 13:38
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74CompareAugust 19, 2026 13:38
@randomdevpeterandomdevpete changed the title Benchmark jarl against react-router, and publish the results674 — Benchmark jarl against react-router, and publish the resultsAug 19, 2026
@randomdevpeterandomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the resultsfeat(bench): 674 — benchmark jarl against react-router, and publish the resultsAug 19, 2026
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
A rank-0-hit workload over a growing table shows the public matchRoutes
re-flattening and ranking the config on every call, while a data router
ranks once at creation and navigates against the cached branches - which
is why react-router navigates faster than it resolves.
Ticket: 674
jarl's nested Switch/Route atoms against react-router's data router and
declarative <Routes> forms: render counts per level for leaf, mid and
root param changes (byte-identical HTML asserted across all three), and
a timed leaf toggle with React render and commit included.
Ticket: 674
Three-level chain, one 25ms lookup per level, navigation to deepest
data on screen: jarl's followAsyncRoutes parallel pre-resolution vs
react-router loaders vs a per-component Suspense cascade.
Ticket: 674
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
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32CompareAugust 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to masterAugust 21, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93

Open
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router
Open

feat(bench): 674 — benchmark jarl against react-router, and publish the results#93
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Backs the README's "incredibly efficient" claim with an actual measurement against react-router, and publishes the methodology and results as a docs guide.

Based on task-530-retire-jarl-react-redux-and-native-packages (#89), not master.

Methodology

  • jarl jarl-atoms/jarl-react 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) vs react-router 8.3.0 data router, on identical react/react-dom 19.2.8.
  • Node 24.15.0, Intel i7-1165G7, Linux (WSL2).
  • Both routers drive the same app (13 active-styled nav links, 10 non-routing components, four routed pages), sharing every router-agnostic component verbatim. The render test asserts both apps produce byte-identical HTML after mount and after every single navigation — that assertion is what makes the comparison like-for-like, and it fails loudly if either app drifts.
  • Timed numbers: median of 30 retained samples × 1000 operations, after 10 discarded warm-up samples, GC forced between samples, NODE_ENV=production, one forked process. Reported with p25/p75.
  • Reproduce with npm run bench from the repo root.

Headline numbers, wins and losses alike

Re-renders per navigation — a tie. 13/13 nav links, 1/1 changed page, 0/0 layout, 0/0 non-routing components. react-router's context model is just as precise here, and the write-up says so in bold. jarl's one measured deficit: every route-atom subscriber renders twice at mount (react-router renders once).

Throughput — jarl wins one, loses one:

workloadjarlreact-router
resolve URL → matched leaf (warm)103 µs375 µs
resolve, cold store per URL117 µs375 µs
client navigation via each API100 µs56 µs

Bundle (min+gzip): jarl 5.7 kB full cost / 2.0 kB with jotai external, react-router 28.3 kB — flagged in the guide as not a like-for-like feature set, since react-router carries its data APIs regardless.

It also corrected a false README claim found along the way: route atoms return 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 skip unaffected routes. The README now says only what the harness measures, and links to the numbers.

What the numbers do not show

No real-browser timings (no layout/paint/input latency), no data loading on either side, one app shape and one route-table shape. Both READMEs state this.

Review changes made before opening

  • Cut the duplicated methodology essays from the harness source (a 17-line header on matching.benchmark.ts among others); that prose now lives once, in bench/README.md.
  • Deleted dead navEntries/NavEntry exports from bench/src/shape.ts.
  • Documented two fairness asymmetries that were previously unstated: jarl's leaf reads short-circuit at the first match (hence the deliberate early/middle/late/miss URL cycle), and router.navigate() does strictly more work than the atom write that loses to it — so that gap is understated in jarl's disfavour, not its favour.
  • Formatted Benchmarks.md with oxfmt (it was the only unformatted file in the tree).

Style exceptions

None. No exception: markers in the diff.

@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

I'm confused about the results. Client navigation is slated as slower than RR, but resolving a route takes significantly longer. Surely when navigating on the client a route must be resolved, so how can that be faster when the different is so great on resolution?

Let's almost come up with a rather more complicated scenario - multi-level deep nest routing, and also pit jotai's pure-start "switch" version vs react-router's route components (or maybe their routeconfig, which might be a bit more comparison)

Another comparison worth doing : client navigation with jarl pre-resolving nested async data via atoms, vs react router triggering a multi-level Suspense cascade (pretty sure I know which one is better, but it's good to benchmark as well, and have a bigger more interesting set of numbers to show)

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6CompareAugust 19, 2026 00:41
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89CompareAugust 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

Done in 771ef89 — all three asks are in, and the suite is CI-green. Posting this on the agent's behalf; it hit a model usage limit after pushing but before replying.

Your first question: navigation is slower than resolution, so how can navigating be faster?

It isn't a contradiction — the two rows price different work, and the original table hid that. Navigating does resolve.

matchRoutes() flattens and ranks the whole route config on every call. A data router does that once at creation and keeps the ranked branches (precomputedBranches in its router.ts), so each router.navigate() matches against a table whose preparation is already amortised to zero. The resolve row was pricing an ad-hoc matchRoutes caller — table prep included, every call. The navigate row was pricing a mounted app that never pays it again.

There's a new commit (085990f) that demonstrates this rather than asserting it: hold the matched URL at the first-ranked branch so per-call match work is constant, then grow the table.

routes in tablematchRoutesrouter.navigatejarl (first leaf read)
217 µs61 µs12 µs
20112 µs79 µs18 µs
100421 µs73 µs18 µs

matchRoutes scales linearly with table size; router.navigate is near-flat. jarl has no preparation step to amortise — route atoms are their own index — which is why it wins resolve outright and loses navigate to a router that already paid resolution's expensive half up front.

Deep nesting (five levels, <Routes> vs route config vs jarl)

Render counts are a three-way tie — every level re-renders in every router, so the only difference is per-render cost:

per navigation, React render + commitmedian
react-router <Routes>137 µs
react-router data router397 µs
jarl493 µs

A clear jarl loss. The declarative form — no state machine, tiny table re-matched per render — is the fastest way to do a deep navigation. jarl pays re-deriving five levels of route atoms plus six useAtom subscribers.

Nested async: atoms vs Suspense cascade

Three levels, one 25 ms lookup each, never cached:

strategymedian
jarl asyncRouteAtom + followAsyncRoutes26.8 ms
react-router loaders26.9 ms
react-router per-component Suspense cascade78.1 ms

You guessed the shape right, but the honest framing is narrower than "jarl wins": jarl lands in ~one lookup's time, and react-router's loaders match it exactly. The 3× is the cost of not using a router-level data story on either side. The real claim is that jarl gives you the parallel behaviour as the idiomatic default where react-router requires opting into loaders.

Worth knowing before this merges

The doc deliberately records where jarl ties or loses, not just where it wins:

  • Re-renders on the flat app are a tie. Components reading no route state are never re-rendered by navigation in either router — react-router's context model is more precise than it's usually given credit for.
  • A route atom's value is a fresh object per 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. This is what makes the deep-nesting result a tie on counts.
  • jarl renders route-atom subscribers twice at initial mount (once for the tree, once when atomWithLocation first syncs).
  • Bundle size is the standout win: 5.7 kB min+gzip full cost, 2.0 kB if the app already uses jotai, against 28.3 kB — though not like-for-like, since react-router ships its data APIs regardless.

Two caveats on the numbers themselves: they're jsdom, so no layout, paint or input latency; and the timed run was taken on a machine that had been running other work, so treat absolute medians as ±20% — orderings and ratios were stable across five repeat runs, and those are the result.

The first two bullets above look like the most interesting follow-up: if fresh-object identity per location change is what forces every subscriber to re-render, making route atoms stable-when-unchanged would turn several of these ties into wins. Happy to file that as its own ticket if you want it chased.

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1CompareAugust 19, 2026 13:38
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74CompareAugust 19, 2026 13:38
@randomdevpeterandomdevpete changed the title Benchmark jarl against react-router, and publish the results674 — Benchmark jarl against react-router, and publish the resultsAug 19, 2026
@randomdevpeterandomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the resultsfeat(bench): 674 — benchmark jarl against react-router, and publish the resultsAug 19, 2026
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
A rank-0-hit workload over a growing table shows the public matchRoutes
re-flattening and ranking the config on every call, while a data router
ranks once at creation and navigates against the cached branches - which
is why react-router navigates faster than it resolves.
Ticket: 674
jarl's nested Switch/Route atoms against react-router's data router and
declarative <Routes> forms: render counts per level for leaf, mid and
root param changes (byte-identical HTML asserted across all three), and
a timed leaf toggle with React render and commit included.
Ticket: 674
Three-level chain, one 25ms lookup per level, navigation to deepest
data on screen: jarl's followAsyncRoutes parallel pre-resolution vs
react-router loaders vs a per-component Suspense cascade.
Ticket: 674
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
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32CompareAugust 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to masterAugust 21, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93

Open
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router
Open

feat(bench): 674 — benchmark jarl against react-router, and publish the results#93
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Backs the README's "incredibly efficient" claim with an actual measurement against react-router, and publishes the methodology and results as a docs guide.

Based on task-530-retire-jarl-react-redux-and-native-packages (#89), not master.

Methodology

  • jarl jarl-atoms/jarl-react 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) vs react-router 8.3.0 data router, on identical react/react-dom 19.2.8.
  • Node 24.15.0, Intel i7-1165G7, Linux (WSL2).
  • Both routers drive the same app (13 active-styled nav links, 10 non-routing components, four routed pages), sharing every router-agnostic component verbatim. The render test asserts both apps produce byte-identical HTML after mount and after every single navigation — that assertion is what makes the comparison like-for-like, and it fails loudly if either app drifts.
  • Timed numbers: median of 30 retained samples × 1000 operations, after 10 discarded warm-up samples, GC forced between samples, NODE_ENV=production, one forked process. Reported with p25/p75.
  • Reproduce with npm run bench from the repo root.

Headline numbers, wins and losses alike

Re-renders per navigation — a tie. 13/13 nav links, 1/1 changed page, 0/0 layout, 0/0 non-routing components. react-router's context model is just as precise here, and the write-up says so in bold. jarl's one measured deficit: every route-atom subscriber renders twice at mount (react-router renders once).

Throughput — jarl wins one, loses one:

workloadjarlreact-router
resolve URL → matched leaf (warm)103 µs375 µs
resolve, cold store per URL117 µs375 µs
client navigation via each API100 µs56 µs

Bundle (min+gzip): jarl 5.7 kB full cost / 2.0 kB with jotai external, react-router 28.3 kB — flagged in the guide as not a like-for-like feature set, since react-router carries its data APIs regardless.

It also corrected a false README claim found along the way: route atoms return 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 skip unaffected routes. The README now says only what the harness measures, and links to the numbers.

What the numbers do not show

No real-browser timings (no layout/paint/input latency), no data loading on either side, one app shape and one route-table shape. Both READMEs state this.

Review changes made before opening

  • Cut the duplicated methodology essays from the harness source (a 17-line header on matching.benchmark.ts among others); that prose now lives once, in bench/README.md.
  • Deleted dead navEntries/NavEntry exports from bench/src/shape.ts.
  • Documented two fairness asymmetries that were previously unstated: jarl's leaf reads short-circuit at the first match (hence the deliberate early/middle/late/miss URL cycle), and router.navigate() does strictly more work than the atom write that loses to it — so that gap is understated in jarl's disfavour, not its favour.
  • Formatted Benchmarks.md with oxfmt (it was the only unformatted file in the tree).

Style exceptions

None. No exception: markers in the diff.

@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

I'm confused about the results. Client navigation is slated as slower than RR, but resolving a route takes significantly longer. Surely when navigating on the client a route must be resolved, so how can that be faster when the different is so great on resolution?

Let's almost come up with a rather more complicated scenario - multi-level deep nest routing, and also pit jotai's pure-start "switch" version vs react-router's route components (or maybe their routeconfig, which might be a bit more comparison)

Another comparison worth doing : client navigation with jarl pre-resolving nested async data via atoms, vs react router triggering a multi-level Suspense cascade (pretty sure I know which one is better, but it's good to benchmark as well, and have a bigger more interesting set of numbers to show)

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6CompareAugust 19, 2026 00:41
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89CompareAugust 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

Done in 771ef89 — all three asks are in, and the suite is CI-green. Posting this on the agent's behalf; it hit a model usage limit after pushing but before replying.

Your first question: navigation is slower than resolution, so how can navigating be faster?

It isn't a contradiction — the two rows price different work, and the original table hid that. Navigating does resolve.

matchRoutes() flattens and ranks the whole route config on every call. A data router does that once at creation and keeps the ranked branches (precomputedBranches in its router.ts), so each router.navigate() matches against a table whose preparation is already amortised to zero. The resolve row was pricing an ad-hoc matchRoutes caller — table prep included, every call. The navigate row was pricing a mounted app that never pays it again.

There's a new commit (085990f) that demonstrates this rather than asserting it: hold the matched URL at the first-ranked branch so per-call match work is constant, then grow the table.

routes in tablematchRoutesrouter.navigatejarl (first leaf read)
217 µs61 µs12 µs
20112 µs79 µs18 µs
100421 µs73 µs18 µs

matchRoutes scales linearly with table size; router.navigate is near-flat. jarl has no preparation step to amortise — route atoms are their own index — which is why it wins resolve outright and loses navigate to a router that already paid resolution's expensive half up front.

Deep nesting (five levels, <Routes> vs route config vs jarl)

Render counts are a three-way tie — every level re-renders in every router, so the only difference is per-render cost:

per navigation, React render + commitmedian
react-router <Routes>137 µs
react-router data router397 µs
jarl493 µs

A clear jarl loss. The declarative form — no state machine, tiny table re-matched per render — is the fastest way to do a deep navigation. jarl pays re-deriving five levels of route atoms plus six useAtom subscribers.

Nested async: atoms vs Suspense cascade

Three levels, one 25 ms lookup each, never cached:

strategymedian
jarl asyncRouteAtom + followAsyncRoutes26.8 ms
react-router loaders26.9 ms
react-router per-component Suspense cascade78.1 ms

You guessed the shape right, but the honest framing is narrower than "jarl wins": jarl lands in ~one lookup's time, and react-router's loaders match it exactly. The 3× is the cost of not using a router-level data story on either side. The real claim is that jarl gives you the parallel behaviour as the idiomatic default where react-router requires opting into loaders.

Worth knowing before this merges

The doc deliberately records where jarl ties or loses, not just where it wins:

  • Re-renders on the flat app are a tie. Components reading no route state are never re-rendered by navigation in either router — react-router's context model is more precise than it's usually given credit for.
  • A route atom's value is a fresh object per 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. This is what makes the deep-nesting result a tie on counts.
  • jarl renders route-atom subscribers twice at initial mount (once for the tree, once when atomWithLocation first syncs).
  • Bundle size is the standout win: 5.7 kB min+gzip full cost, 2.0 kB if the app already uses jotai, against 28.3 kB — though not like-for-like, since react-router ships its data APIs regardless.

Two caveats on the numbers themselves: they're jsdom, so no layout, paint or input latency; and the timed run was taken on a machine that had been running other work, so treat absolute medians as ±20% — orderings and ratios were stable across five repeat runs, and those are the result.

The first two bullets above look like the most interesting follow-up: if fresh-object identity per location change is what forces every subscriber to re-render, making route atoms stable-when-unchanged would turn several of these ties into wins. Happy to file that as its own ticket if you want it chased.

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1CompareAugust 19, 2026 13:38
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74CompareAugust 19, 2026 13:38
@randomdevpeterandomdevpete changed the title Benchmark jarl against react-router, and publish the results674 — Benchmark jarl against react-router, and publish the resultsAug 19, 2026
@randomdevpeterandomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the resultsfeat(bench): 674 — benchmark jarl against react-router, and publish the resultsAug 19, 2026
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
A rank-0-hit workload over a growing table shows the public matchRoutes
re-flattening and ranking the config on every call, while a data router
ranks once at creation and navigates against the cached branches - which
is why react-router navigates faster than it resolves.
Ticket: 674
jarl's nested Switch/Route atoms against react-router's data router and
declarative <Routes> forms: render counts per level for leaf, mid and
root param changes (byte-identical HTML asserted across all three), and
a timed leaf toggle with React render and commit included.
Ticket: 674
Three-level chain, one 25ms lookup per level, navigation to deepest
data on screen: jarl's followAsyncRoutes parallel pre-resolution vs
react-router loaders vs a per-component Suspense cascade.
Ticket: 674
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
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32CompareAugust 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to masterAugust 21, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93

Open
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router
Open

feat(bench): 674 — benchmark jarl against react-router, and publish the results#93
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Backs the README's "incredibly efficient" claim with an actual measurement against react-router, and publishes the methodology and results as a docs guide.

Based on task-530-retire-jarl-react-redux-and-native-packages (#89), not master.

Methodology

  • jarl jarl-atoms/jarl-react 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) vs react-router 8.3.0 data router, on identical react/react-dom 19.2.8.
  • Node 24.15.0, Intel i7-1165G7, Linux (WSL2).
  • Both routers drive the same app (13 active-styled nav links, 10 non-routing components, four routed pages), sharing every router-agnostic component verbatim. The render test asserts both apps produce byte-identical HTML after mount and after every single navigation — that assertion is what makes the comparison like-for-like, and it fails loudly if either app drifts.
  • Timed numbers: median of 30 retained samples × 1000 operations, after 10 discarded warm-up samples, GC forced between samples, NODE_ENV=production, one forked process. Reported with p25/p75.
  • Reproduce with npm run bench from the repo root.

Headline numbers, wins and losses alike

Re-renders per navigation — a tie. 13/13 nav links, 1/1 changed page, 0/0 layout, 0/0 non-routing components. react-router's context model is just as precise here, and the write-up says so in bold. jarl's one measured deficit: every route-atom subscriber renders twice at mount (react-router renders once).

Throughput — jarl wins one, loses one:

workloadjarlreact-router
resolve URL → matched leaf (warm)103 µs375 µs
resolve, cold store per URL117 µs375 µs
client navigation via each API100 µs56 µs

Bundle (min+gzip): jarl 5.7 kB full cost / 2.0 kB with jotai external, react-router 28.3 kB — flagged in the guide as not a like-for-like feature set, since react-router carries its data APIs regardless.

It also corrected a false README claim found along the way: route atoms return 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 skip unaffected routes. The README now says only what the harness measures, and links to the numbers.

What the numbers do not show

No real-browser timings (no layout/paint/input latency), no data loading on either side, one app shape and one route-table shape. Both READMEs state this.

Review changes made before opening

  • Cut the duplicated methodology essays from the harness source (a 17-line header on matching.benchmark.ts among others); that prose now lives once, in bench/README.md.
  • Deleted dead navEntries/NavEntry exports from bench/src/shape.ts.
  • Documented two fairness asymmetries that were previously unstated: jarl's leaf reads short-circuit at the first match (hence the deliberate early/middle/late/miss URL cycle), and router.navigate() does strictly more work than the atom write that loses to it — so that gap is understated in jarl's disfavour, not its favour.
  • Formatted Benchmarks.md with oxfmt (it was the only unformatted file in the tree).

Style exceptions

None. No exception: markers in the diff.

@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

I'm confused about the results. Client navigation is slated as slower than RR, but resolving a route takes significantly longer. Surely when navigating on the client a route must be resolved, so how can that be faster when the different is so great on resolution?

Let's almost come up with a rather more complicated scenario - multi-level deep nest routing, and also pit jotai's pure-start "switch" version vs react-router's route components (or maybe their routeconfig, which might be a bit more comparison)

Another comparison worth doing : client navigation with jarl pre-resolving nested async data via atoms, vs react router triggering a multi-level Suspense cascade (pretty sure I know which one is better, but it's good to benchmark as well, and have a bigger more interesting set of numbers to show)

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6CompareAugust 19, 2026 00:41
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89CompareAugust 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

Done in 771ef89 — all three asks are in, and the suite is CI-green. Posting this on the agent's behalf; it hit a model usage limit after pushing but before replying.

Your first question: navigation is slower than resolution, so how can navigating be faster?

It isn't a contradiction — the two rows price different work, and the original table hid that. Navigating does resolve.

matchRoutes() flattens and ranks the whole route config on every call. A data router does that once at creation and keeps the ranked branches (precomputedBranches in its router.ts), so each router.navigate() matches against a table whose preparation is already amortised to zero. The resolve row was pricing an ad-hoc matchRoutes caller — table prep included, every call. The navigate row was pricing a mounted app that never pays it again.

There's a new commit (085990f) that demonstrates this rather than asserting it: hold the matched URL at the first-ranked branch so per-call match work is constant, then grow the table.

routes in tablematchRoutesrouter.navigatejarl (first leaf read)
217 µs61 µs12 µs
20112 µs79 µs18 µs
100421 µs73 µs18 µs

matchRoutes scales linearly with table size; router.navigate is near-flat. jarl has no preparation step to amortise — route atoms are their own index — which is why it wins resolve outright and loses navigate to a router that already paid resolution's expensive half up front.

Deep nesting (five levels, <Routes> vs route config vs jarl)

Render counts are a three-way tie — every level re-renders in every router, so the only difference is per-render cost:

per navigation, React render + commitmedian
react-router <Routes>137 µs
react-router data router397 µs
jarl493 µs

A clear jarl loss. The declarative form — no state machine, tiny table re-matched per render — is the fastest way to do a deep navigation. jarl pays re-deriving five levels of route atoms plus six useAtom subscribers.

Nested async: atoms vs Suspense cascade

Three levels, one 25 ms lookup each, never cached:

strategymedian
jarl asyncRouteAtom + followAsyncRoutes26.8 ms
react-router loaders26.9 ms
react-router per-component Suspense cascade78.1 ms

You guessed the shape right, but the honest framing is narrower than "jarl wins": jarl lands in ~one lookup's time, and react-router's loaders match it exactly. The 3× is the cost of not using a router-level data story on either side. The real claim is that jarl gives you the parallel behaviour as the idiomatic default where react-router requires opting into loaders.

Worth knowing before this merges

The doc deliberately records where jarl ties or loses, not just where it wins:

  • Re-renders on the flat app are a tie. Components reading no route state are never re-rendered by navigation in either router — react-router's context model is more precise than it's usually given credit for.
  • A route atom's value is a fresh object per 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. This is what makes the deep-nesting result a tie on counts.
  • jarl renders route-atom subscribers twice at initial mount (once for the tree, once when atomWithLocation first syncs).
  • Bundle size is the standout win: 5.7 kB min+gzip full cost, 2.0 kB if the app already uses jotai, against 28.3 kB — though not like-for-like, since react-router ships its data APIs regardless.

Two caveats on the numbers themselves: they're jsdom, so no layout, paint or input latency; and the timed run was taken on a machine that had been running other work, so treat absolute medians as ±20% — orderings and ratios were stable across five repeat runs, and those are the result.

The first two bullets above look like the most interesting follow-up: if fresh-object identity per location change is what forces every subscriber to re-render, making route atoms stable-when-unchanged would turn several of these ties into wins. Happy to file that as its own ticket if you want it chased.

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1CompareAugust 19, 2026 13:38
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74CompareAugust 19, 2026 13:38
@randomdevpeterandomdevpete changed the title Benchmark jarl against react-router, and publish the results674 — Benchmark jarl against react-router, and publish the resultsAug 19, 2026
@randomdevpeterandomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the resultsfeat(bench): 674 — benchmark jarl against react-router, and publish the resultsAug 19, 2026
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
A rank-0-hit workload over a growing table shows the public matchRoutes
re-flattening and ranking the config on every call, while a data router
ranks once at creation and navigates against the cached branches - which
is why react-router navigates faster than it resolves.
Ticket: 674
jarl's nested Switch/Route atoms against react-router's data router and
declarative <Routes> forms: render counts per level for leaf, mid and
root param changes (byte-identical HTML asserted across all three), and
a timed leaf toggle with React render and commit included.
Ticket: 674
Three-level chain, one 25ms lookup per level, navigation to deepest
data on screen: jarl's followAsyncRoutes parallel pre-resolution vs
react-router loaders vs a per-component Suspense cascade.
Ticket: 674
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
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32CompareAugust 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to masterAugust 21, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@randomdevpete
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93

Open
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router
Open

feat(bench): 674 — benchmark jarl against react-router, and publish the results#93
randomdevpete wants to merge 5 commits into
masterfrom
task-674-performance-comparison-vs-react-router

Conversation

@randomdevpete

Copy link
Copy Markdown
Owner

Backs the README's "incredibly efficient" claim with an actual measurement against react-router, and publishes the methodology and results as a docs guide.

Based on task-530-retire-jarl-react-redux-and-native-packages (#89), not master.

Methodology

  • jarl jarl-atoms/jarl-react 2.6.0 (jotai 2.20.2, jotai-location 0.6.2) vs react-router 8.3.0 data router, on identical react/react-dom 19.2.8.
  • Node 24.15.0, Intel i7-1165G7, Linux (WSL2).
  • Both routers drive the same app (13 active-styled nav links, 10 non-routing components, four routed pages), sharing every router-agnostic component verbatim. The render test asserts both apps produce byte-identical HTML after mount and after every single navigation — that assertion is what makes the comparison like-for-like, and it fails loudly if either app drifts.
  • Timed numbers: median of 30 retained samples × 1000 operations, after 10 discarded warm-up samples, GC forced between samples, NODE_ENV=production, one forked process. Reported with p25/p75.
  • Reproduce with npm run bench from the repo root.

Headline numbers, wins and losses alike

Re-renders per navigation — a tie. 13/13 nav links, 1/1 changed page, 0/0 layout, 0/0 non-routing components. react-router's context model is just as precise here, and the write-up says so in bold. jarl's one measured deficit: every route-atom subscriber renders twice at mount (react-router renders once).

Throughput — jarl wins one, loses one:

workloadjarlreact-router
resolve URL → matched leaf (warm)103 µs375 µs
resolve, cold store per URL117 µs375 µs
client navigation via each API100 µs56 µs

Bundle (min+gzip): jarl 5.7 kB full cost / 2.0 kB with jotai external, react-router 28.3 kB — flagged in the guide as not a like-for-like feature set, since react-router carries its data APIs regardless.

It also corrected a false README claim found along the way: route atoms return 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 skip unaffected routes. The README now says only what the harness measures, and links to the numbers.

What the numbers do not show

No real-browser timings (no layout/paint/input latency), no data loading on either side, one app shape and one route-table shape. Both READMEs state this.

Review changes made before opening

  • Cut the duplicated methodology essays from the harness source (a 17-line header on matching.benchmark.ts among others); that prose now lives once, in bench/README.md.
  • Deleted dead navEntries/NavEntry exports from bench/src/shape.ts.
  • Documented two fairness asymmetries that were previously unstated: jarl's leaf reads short-circuit at the first match (hence the deliberate early/middle/late/miss URL cycle), and router.navigate() does strictly more work than the atom write that loses to it — so that gap is understated in jarl's disfavour, not its favour.
  • Formatted Benchmarks.md with oxfmt (it was the only unformatted file in the tree).

Style exceptions

None. No exception: markers in the diff.

@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

I'm confused about the results. Client navigation is slated as slower than RR, but resolving a route takes significantly longer. Surely when navigating on the client a route must be resolved, so how can that be faster when the different is so great on resolution?

Let's almost come up with a rather more complicated scenario - multi-level deep nest routing, and also pit jotai's pure-start "switch" version vs react-router's route components (or maybe their routeconfig, which might be a bit more comparison)

Another comparison worth doing : client navigation with jarl pre-resolving nested async data via atoms, vs react router triggering a multi-level Suspense cascade (pretty sure I know which one is better, but it's good to benchmark as well, and have a bigger more interesting set of numbers to show)

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6CompareAugust 19, 2026 00:41
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89CompareAugust 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
OwnerAuthor

Done in 771ef89 — all three asks are in, and the suite is CI-green. Posting this on the agent's behalf; it hit a model usage limit after pushing but before replying.

Your first question: navigation is slower than resolution, so how can navigating be faster?

It isn't a contradiction — the two rows price different work, and the original table hid that. Navigating does resolve.

matchRoutes() flattens and ranks the whole route config on every call. A data router does that once at creation and keeps the ranked branches (precomputedBranches in its router.ts), so each router.navigate() matches against a table whose preparation is already amortised to zero. The resolve row was pricing an ad-hoc matchRoutes caller — table prep included, every call. The navigate row was pricing a mounted app that never pays it again.

There's a new commit (085990f) that demonstrates this rather than asserting it: hold the matched URL at the first-ranked branch so per-call match work is constant, then grow the table.

routes in tablematchRoutesrouter.navigatejarl (first leaf read)
217 µs61 µs12 µs
20112 µs79 µs18 µs
100421 µs73 µs18 µs

matchRoutes scales linearly with table size; router.navigate is near-flat. jarl has no preparation step to amortise — route atoms are their own index — which is why it wins resolve outright and loses navigate to a router that already paid resolution's expensive half up front.

Deep nesting (five levels, <Routes> vs route config vs jarl)

Render counts are a three-way tie — every level re-renders in every router, so the only difference is per-render cost:

per navigation, React render + commitmedian
react-router <Routes>137 µs
react-router data router397 µs
jarl493 µs

A clear jarl loss. The declarative form — no state machine, tiny table re-matched per render — is the fastest way to do a deep navigation. jarl pays re-deriving five levels of route atoms plus six useAtom subscribers.

Nested async: atoms vs Suspense cascade

Three levels, one 25 ms lookup each, never cached:

strategymedian
jarl asyncRouteAtom + followAsyncRoutes26.8 ms
react-router loaders26.9 ms
react-router per-component Suspense cascade78.1 ms

You guessed the shape right, but the honest framing is narrower than "jarl wins": jarl lands in ~one lookup's time, and react-router's loaders match it exactly. The 3× is the cost of not using a router-level data story on either side. The real claim is that jarl gives you the parallel behaviour as the idiomatic default where react-router requires opting into loaders.

Worth knowing before this merges

The doc deliberately records where jarl ties or loses, not just where it wins:

  • Re-renders on the flat app are a tie. Components reading no route state are never re-rendered by navigation in either router — react-router's context model is more precise than it's usually given credit for.
  • A route atom's value is a fresh object per 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. This is what makes the deep-nesting result a tie on counts.
  • jarl renders route-atom subscribers twice at initial mount (once for the tree, once when atomWithLocation first syncs).
  • Bundle size is the standout win: 5.7 kB min+gzip full cost, 2.0 kB if the app already uses jotai, against 28.3 kB — though not like-for-like, since react-router ships its data APIs regardless.

Two caveats on the numbers themselves: they're jsdom, so no layout, paint or input latency; and the timed run was taken on a machine that had been running other work, so treat absolute medians as ±20% — orderings and ratios were stable across five repeat runs, and those are the result.

The first two bullets above look like the most interesting follow-up: if fresh-object identity per location change is what forces every subscriber to re-render, making route atoms stable-when-unchanged would turn several of these ties into wins. Happy to file that as its own ticket if you want it chased.

@randomdevpete
randomdevpeteforce-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1CompareAugust 19, 2026 13:38
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74CompareAugust 19, 2026 13:38
@randomdevpeterandomdevpete changed the title Benchmark jarl against react-router, and publish the results674 — Benchmark jarl against react-router, and publish the resultsAug 19, 2026
@randomdevpeterandomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the resultsfeat(bench): 674 — benchmark jarl against react-router, and publish the resultsAug 19, 2026
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
A rank-0-hit workload over a growing table shows the public matchRoutes
re-flattening and ranking the config on every call, while a data router
ranks once at creation and navigates against the cached branches - which
is why react-router navigates faster than it resolves.
Ticket: 674
jarl's nested Switch/Route atoms against react-router's data router and
declarative <Routes> forms: render counts per level for leaf, mid and
root param changes (byte-identical HTML asserted across all three), and
a timed leaf toggle with React render and commit included.
Ticket: 674
Three-level chain, one 25ms lookup per level, navigation to deepest
data on screen: jarl's followAsyncRoutes parallel pre-resolution vs
react-router loaders vs a per-component Suspense cascade.
Ticket: 674
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
@randomdevpete
randomdevpeteforce-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32CompareAugust 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to masterAugust 21, 2026 03:35
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@randomdevpete