feat(bench): 674 — benchmark jarl against react-router, and publish the results - #93
feat(bench): 674 — benchmark jarl against react-router, and publish the results#93randomdevpete wants to merge 5 commits into
Conversation
|
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) |
5e0d9cf to
79927e6
Compare
434880e to
771ef89
Compare
|
Done in 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.
There's a new commit (
Deep nesting (five levels,
|
| 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
atomWithLocationfirst 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.
79927e6 to
7e20fe1
Compare
771ef89 to
4d99e74
Compare
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
4d99e74 to
72e7e32
Compare
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-atoms/jarl-react2.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_ENV=production, one forked process. Reported with p25/p75.npm run benchfrom 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:
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
matching.benchmark.tsamong others); that prose now lives once, inbench/README.md.navEntries/NavEntryexports frombench/src/shape.ts.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.Benchmarks.mdwithoxfmt(it was the only unformatted file in the tree).Style exceptions
None. No
exception:markers in the diff.