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:

workload jarl react-router
resolve URL → matched leaf (warm) 103 µs 375 µs
resolve, cold store per URL 117 µs 375 µs
client navigation via each API 100 µs 56 µ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
Owner Author

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
randomdevpete force-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 5e0d9cf to 79927e6 Compare August 19, 2026 00:41
@randomdevpete
randomdevpete force-pushed the task-674-performance-comparison-vs-react-router branch 2 times, most recently from 434880e to 771ef89 Compare August 19, 2026 01:29
@randomdevpete

Copy link
Copy Markdown
Owner Author

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 table matchRoutes router.navigate jarl (first leaf read)
2 17 µs 61 µs 12 µs
20 112 µs 79 µs 18 µs
100 421 µs 73 µs 18 µs

matchRoutes scales linearly with table size; 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 + commit median
react-router <Routes> 137 µs
react-router data router 397 µs
jarl 493 µ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:

strategy median
jarl asyncRouteAtom + followAsyncRoutes 26.8 ms
react-router loaders 26.9 ms
react-router per-component Suspense cascade 78.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
randomdevpete force-pushed the task-530-retire-jarl-react-redux-and-native-packages branch from 79927e6 to 7e20fe1 Compare August 19, 2026 13:38
@randomdevpete
randomdevpete force-pushed the task-674-performance-comparison-vs-react-router branch from 771ef89 to 4d99e74 Compare August 19, 2026 13:38
@randomdevpete randomdevpete changed the title Benchmark jarl against react-router, and publish the results 674 — Benchmark jarl against react-router, and publish the results Aug 19, 2026
@randomdevpete randomdevpete changed the title 674 — Benchmark jarl against react-router, and publish the results feat(bench): 674 — benchmark jarl against react-router, and publish the results Aug 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
randomdevpete force-pushed the task-674-performance-comparison-vs-react-router branch from 4d99e74 to 72e7e32 Compare August 21, 2026 03:35
@randomdevpete
randomdevpete changed the base branch from task-530-retire-jarl-react-redux-and-native-packages to master August 21, 2026 03:35
@randomdevpete randomdevpete reopened this Aug 21, 2026
Sign up for free to 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