Plate 66
Jotai vs Zustand: 0.15 ms vs 0.17 ms per Update
Aditya Challa5 min read
I bumped one row in a 1,000-row React 19 list on this box: Jotai 2.20.3 median 0.145 ms per update, Zustand 5.0.15 median 0.167 ms, both with exactly one re-render per bump. The bundle gap is the bigger story — jotai core gzip 4,164 B against zustand create at 387 B.
Short answer: pick Zustand for a single store and the smallest client. Pick Jotai when you want per-field atoms and derived graphs without inventing selectors. No affiliate links in this post.
What I tested
- Machine: 8 vCPU Intel Xeon / 15 GB RAM shared Linux cloud box, tested 6 Oct 2026 about 00:47–00:50 IST.
- Versions: jotai 2.20.3, zustand 5.0.15, React 19.3.0, jsdom 27.4.0, Node 22.20.0, esbuild for the bundle check. NODE_ENV=production.
- Fixture: /workspace/bench-jotai-zustand/. Normalized 1,000-item list. Zustand holds an items map and a bump action that replaces one entry. Jotai uses createStore plus 1,000 primitive atoms and store.set. Each row is a memoized component subscribed to one item (useStore selector or useAtomValue with an explicit store).
- Timing: warm one update after mount, then 300 flushSync bumps × 7 trials. Also a burst of 300 sync bumps then one paint, and a store-only path of 20,000 bumps × 9 rounds with no React.
- Findings: per-update median 0.145 ms (Jotai) vs 0.167 ms (Zustand); renders per update 1 for both. Burst of 300 updates 31.37 ms vs 39.11 ms. Store-only 915,340 vs 870,952 updates/s. Gzip 4,164 B vs 387 B.
- One surprise: with flushSync before subscriptions had settled, Jotai looked like it re-rendered every row. After a short settle + one warm bump, isolation matched Zustand at 1 render per update. Measure after mount settles.
- Not tested: jotai atomFamily / loadable, Zustand middleware (persist, immer), Redux Toolkit (covered separately), React Native, real browsers, SSR, DevTools.
How wide was the update gap?
| Path | Median |
|---|---|
| Jotai, ms per flushSync update | 0.145 |
| Zustand, ms per flushSync update | 0.167 |
| Jotai, renders per update | 1 |
| Zustand, renders per update | 1 |
| Jotai, 300-update burst (ms) | 31.37 |
| Zustand, 300-update burst (ms) | 39.11 |
| Jotai, store-only updates/s | 915,340 |
| Zustand, store-only updates/s | 870,952 |
On this fixture the libraries are within noise on React update cost. Jotai was a bit ahead on the burst and store-only loops. Isolation worked the same: touching one atom or one selector re-rendered one row. The jsdom harness details sit next to my jsdom vs happy-dom notes.
Does the bundle size change the call?
| Browser build (esbuild minify, React external) | Minified | gzip -9 |
|---|---|---|
| zustand create | 624 B | 387 B |
| jotai (atom, createStore, Provider, useAtomValue, useSetAtom) | 10,137 B | 4,164 B |
Zustand was about 10× smaller gzip on the imports I actually used. Disk trees were about 288 KB for zustand against 1.4 MB for jotai. Track shipped weight with size-limit vs bundlesize. If you are choosing between Zustand and Redux Toolkit instead, that is a different intent (single store vs Redux store + Immer) and belongs in its own comparison once published.
Which one should you pick?
| Situation | My pick |
|---|---|
| One store, a few actions, smallest client | Zustand |
| Many independent fields / derived atoms | Jotai |
| You already think in selectors over one tree | Zustand |
| You want atom graphs without boilerplate | Jotai |
| Bundle budget under 1 KB for state | Zustand |
Both stayed at one re-render per bump once the tree had settled. Jotai's model shines when atoms depend on atoms; Zustand's when the app state is already a tree you are happy to slice. Lint and unused-export cleanup after a swap: Biome vs ESLint/Prettier and knip vs depcheck. Test runner for the suite: Jest vs Vitest.
Bottom line: on a 1,000-row React 19 list, Jotai 2.20.3 took 0.15 ms per update against 0.17 ms for Zustand 5.0.15, both with one re-render, while Zustand shipped 0.4 KB gzip against Jotai's 4.2 KB. Who should not switch: teams whose Zustand stores are already simple and under budget — the 0.02 ms gap is not a reason to rewrite.
How this was made: I installed Jotai and Zustand on the ShopperCove box, timed React update and store-only loops under jsdom, measured esbuild minify+gzip sizes and recorded the JSON. The write-up was drafted with AI help and checked against that output.
Sources
- https://jotai.org/
- https://www.npmjs.com/package/jotai
- https://zustand.docs.pmnd.rs/
- https://www.npmjs.com/package/zustand
Related
- https://www.shoppercove.com/blog/jsdom-vs-happy-dom-vitest
- https://www.shoppercove.com/blog/size-limit-vs-bundlesize
- https://www.shoppercove.com/blog/knip-vs-depcheck
- https://www.shoppercove.com/blog/jest-vs-vitest
- https://www.shoppercove.com/blog/biome-vs-eslint-prettier
- https://www.shoppercove.com/blog/react-19-3-view-transitions-fragment-refs-2026
- https://www.shoppercove.com/blog/msw-vs-nock
- https://www.shoppercove.com/blog/oxlint-vs-eslint
Lab evidence
What I found running this
Hands-on on ShopperCove box 6 Oct 2026 ~00:47-00:50 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 22.20.0). jotai 2.20.3 vs zustand 5.0.15, React 19.3.0, jsdom 27.4.0. Fixture /workspace/bench-jotai-zustand/: 1,000-item Zustand map with bump-one-entry action vs createStore + 1,000 primitive atoms; memoized row components (useStore selector / useAtomValue with store option). After mount settle + one warm bump: 300 flushSync updates x 7 trials. Medians: Jotai 0.145 ms/update, Zustand 0.167 ms/update; renders/update 1 both. Burst 300: 31.37 vs 39.11 ms, 300 renders each. Store-only 20k x 9: 915,340 vs 870,952 ups. esbuild minify/gzip9 React external: jotai 10,137/4,164 B; zustand 624/387 B. Disk ~1.4 MB vs ~288 KB. Raw: res-react-final.json, res-store.json, res-bundle.json. Not tested: atomFamily/loadable, Zustand middleware, RTK, RN, real browsers, SSR, DevTools. No affiliate. Distinct from zustand-vs-redux-toolkit (atoms vs single store, not Redux).
Related links
Plate 60
Zustand vs Redux Toolkit: 0.14 ms vs 0.68 ms per Update
zustand 5.0.15 vs @reduxjs/toolkit 2.13.0 + react-redux 9.3.0 on React 19.3.0 (jsdom, NODE_ENV=production). 1,000 selector-subscribed rows, 300 flushSync updates x 7 trials, 2 runs: per-update median 0.137/0.139 ms vs 0.676/0.694 ms (~5x); 1 row re-rendered per update in both. Store-only: 1,376,191 vs 6,502 updates/s; Immer autoFreeze off lifts RTK to 386,966. Gzip (React external): 387 B vs 9,708 B. Fixture /workspace/bench-zustand-rtk/.
5 Oct 2026
Plate 38
TanStack Query vs SWR: 10.4 KB vs 5.6 KB Gzip
5 Oct 2026
Plate 12
Yarn Berry vs pnpm: 1.3s vs 0.24s Install
Yarn 4.9.2 (Berry PnP + node-modules linker) vs pnpm 12.9.1 on one 22-dependency React+Vite app. Isolated caches (Yarn enableGlobalCache false; pnpm --store-dir). Warm medians (5): Yarn PnP 1.307 s, Yarn nm 2.471 s, pnpm 0.240 s. Lock+empty medians 1.656 / 2.789 / 0.725 s. Clean disk: Yarn PnP 246 MB; Yarn nm modules 189 MB; pnpm hardlink project 167 MB. Fixture /workspace/bench-yarn-pnpm/. Distinct from pnpm-vs-npm-vs-bun (no Yarn).
5 Oct 2026