Jotai makes server data look effortless: write atom(async (get) => fetch(β¦)), read it with useAtomValue, let Suspense handle the rest. Much of that promise is real β two of the nastiest bugs we've measured in other libraries can't happen in jotai β but the failure modes it does have are uniquely quiet: an infinite fetch loop with zero warnings, an app that renders nothing with zero errors, and a deprecation that just landed on the util every tutorial teaches.
Everything here was reproduced and measured in a real Chrome on jotai 2.20.3 + React 19.3 (Vite, client-side) with an instrumented window.fetch, render counters, and an abort counter before publishing. Two full runs produced identical numbers (the infinite-loop scenario varies with machine speed, as you'd expect).
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, CI β anywhere. When you want your own schema it's one curl or one click β see Β§13.
const productsAtom = atom(async () => {
const r = await fetch(`${API}/products`)
return r.json()
})
function List() {
const items = useAtomValue(productsAtom) // suspends until resolved
return <ul>{items.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
// <Suspense fallback={<Spinner/>}><List/></Suspense>
Measured: 1 fetch, 20 rows (the demo API's default page size; X-Total-Count: 30 tells you the rest). Now the part that surprises people: wrap it in <StrictMode> β the setup where a useEffect fetch fires twice in every fresh Vite app β and it's still exactly 1 fetch. The atom's value lives in the store, not the component, so React's deliberate double-mount hits a cache instead of your API. That's a genuine win; jotai starts this guide ahead.
The most-warned-about jotai mistake, finally with numbers attached. Atoms are cheap to create, so nothing stops you from creating one where it feels natural:
function List() {
const dataAtom = atom(async () => { // β new atom identity EVERY render
const r = await fetch(`${API}/products?limit=3`)
return r.json()
})
const items = useAtomValue(dataAtom)
return <ul>{items.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
Every render creates a different atom, which suspends, resolves, re-renders β which creates a different atom. Measured: 45 fetches in the first 2 seconds, 114 by 5 seconds, renders tracking fetches 1:1, and the UI never leaves the Suspense fallback. The console: completely silent. No "maximum update depth", no warning, nothing β the slowest-burning and quietest storm we've measured in this series (zustand's at least prints one warning; Svelte 5 crashes with a named error). Against a metered API this runs until someone notices the bill. Fix: define atoms at module scope, or memoize with useMemo (or atomFamily) when they must be parameterized.
Forget the <Suspense> wrapper and nothing breaks. That's the trap. With the endpoint slowed to 800 ms (mock_delay=800), we sampled the DOM mid-flight: the root element has zero children. No fallback, no skeleton, no error, no warning β React simply withholds the entire tree until the promise resolves, then the app pops in. Users on a slow connection see a blank page and there is nowhere to put a spinner, because no boundary exists to show one. If a page "works on my machine" but field reports say it loads blank-then-pops, look for a missing Suspense boundary around an async atom:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_delay=800" # make dev feel like 3G
The scenario that breaks raw Svelte 5 $effect, naive zustand actions, and naive Pinia actions: click page 1 (answers in 800 ms), then page 2 (answers in 100 ms) 150 ms later. Last-to-resolve wins, so those three show page-1 rows under a page-2 header.
const pageAtom = atom(1)
const listAtom = atom(async (get) => {
const r = await fetch(`${API}/products?_page=${get(pageAtom)}&_limit=5`)
return r.json()
})
To reproduce deterministically, the mock decides who's slow β mock_delay is per-request:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=1&_limit=3&mock_delay=800"
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=5&mock_delay=100"
Measured in jotai: 2 fetches, 0 aborts, header "page 2", and the rows are page 2's (first id 6) β even after the stale page-1 response resolves late. When pageAtom changes, the derived atom's value becomes a new promise; the old one's resolution is simply ignored. The race is unloseable by default. But note the "0 aborts": the stale request still ran to completion on the network. Which brings us toβ¦
The async read function receives an options object almost every codebase ignores:
const listAtom = atom(async (get, { signal }) => {
const r = await fetch(`${API}/products?_page=${get(pageAtom)}&_limit=5`, { signal })
return r.json()
})
Same click sequence, measured: exactly 1 abort β jotai aborts the signal itself when a dependency changes mid-flight; your only job is forwarding it to fetch. Zero controllers, zero cleanup, zero refs. Compare: in zustand you build an AbortController into the store yourself; in Solid's createResource there is no signal to pass at all. This is the cheapest request-cancellation of any library in this series β one destructure and one property.
Change pageAtom and the derived atom re-suspends: measured 300 ms into a page change, the fallback has replaced your list (the old rows are still in the DOM but hidden). Every page click flashes "loadingβ¦" β functional, ugly. The keep-previous-data pattern is built in:
import { unwrap } from 'jotai/utils'
const smoothAtom = unwrap(listAtom, prev => prev ?? [])
Measured mid-flight: no fallback β page 1's rows stay visible until page 2's data lands, then swap. One honest caveat we measured: the header reads the sync pageAtom and already says "page 2" over page-1 rows during the flight. If mixed UI bothers you, derive everything the user sees from the unwrapped data (or add an isStale flag by comparing the two atoms).
Failure path one β the classic missing res.ok check:
const badAtom = atom(async () => {
const r = await fetch(`${API}/products?mock_status=500`)
return r.json() // 500 body parses fine β it's just not an array
})
The error JSON becomes items, the component throws Uncaught TypeError: items.map is not a function, and the page goes blank. Failure path two β you dutifully add if (!r.ok) throw new Error(β¦) and keep only Suspense: still a blank page, just with a cleaner uncaught "HTTP 500". Measured both. Suspense catches pending, not rejected β a thrown async atom needs an ErrorBoundary, which turned both blanks into a rendered error UI in our harness. Drill every branch against real statuses before shipping:
curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=500" # any code: 401, 403, 429β¦
The standard advice for "I don't want Suspense" has been loadable() β and it still works as documented (measured: states loading β hasData, or loading β hasError with a throwing fetcher, no boundary needed). But import it on jotai 2.20 and the console greets you with:
[DEPRECATED] loadable is deprecated and will be removed in v3.
Please use a userland util with the `unwrap` util
Every jotai data-fetching tutorial written since 2021 teaches this util; if your lint gate fails on console output, this warning is now in your build. The sanctioned replacement is composing unwrap (Β§7) with your own state flags β or reaching for a query library for anything stateful. Worth knowing before v3 lands, not after.
"Retry on 5xx" code paths usually ship untested because you can't make prod fail twice on demand. mock_seq serves a deterministic status script β 503, 503, then the real data:
const r = await fetch(`${API}/products?limit=3&mock_seq=503,503,200&mock_seq_key=run1`)
Measured with a for-loop retry in a write-only atom: exactly 3 fetches, then 3 rows render. Each response carries x-mockbird-seq: 1/3 β¦ 3/3 so every attempt is assertable; the sequence sticks on its last entry per key, so use a fresh mock_seq_key (or mock_seq_reset=1) per test run.
A write atom that commits its result in three separate set() calls after the await:
set(itemsAtom, items)
set(countAtom, items.length)
set(loadedAtAtom, Date.now())
Measured against a control doing a single set(): identical render counts β React 18+ batches the whole microtask continuation, so a component subscribed to all three atoms commits once either way. Write clear, granular atoms; don't glue state together for imagined performance.
Seeded dev data means your "No products yet" branch ships untested. Pin a request to an empty snapshot β live data untouched:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty" # β []
Measured in the harness: the empty-state div renders cleanly (with Suspense you get "loading" and "loaded zero" distinguished for free β the atom is either pending or resolved-empty, a nicety store libraries make you model by hand).
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "content-type: application/json" \
-d '{"preset": "ecommerce"}'
That returns a live base URL with seeded products, orders, customers, reviews β full CRUD, filters (?price_gte=100), pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag used above (mock_status, mock_delay, mock_seq, envelope reshaping, chaos). Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.
Written by the Mockbird maker β bias disclosed. Jotai comes out of these measurements better than most of this series: the pager race is unloseable by default, request cancellation is a destructure away (Β§6), StrictMode double-fetching simply doesn't happen, and keep-previous-data is one unwrap call. The costs are equally real: its worst failure modes are silent (a 114-fetch storm and a childless root, both with a clean console), Suspense-first design means error handling is someone else's boundary, and the loadable deprecation shows the v2βv3 API is still settling. For heavy server-state needs the jotai team themselves maintain jotai-tanstack-query β fair advice; see our TanStack Query guide. Measurements taken Sep 24, 2026 on jotai 2.20.3, React 19.3, Vite 5, client-side rendering; two runs, identical numbers (storm scenario Β±5%). Related: mocking for React, React 19 use(), measured, Zustand, measured, MobX, measured, Redux createAsyncThunk, measured, Solid createResource, measured, SWR.