← All guides

Jotai async atoms, measured β€” the atom-in-render storm, the race you can't lose, and the abort signal you're ignoring

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).

1. Try it in 10 seconds (no signup)

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.

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.

2. The good path β€” and the StrictMode fetch that doesn't happen

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.

3. atom() in the component body: 114 fetches in 5 s, zero warnings, fallback forever

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.

4. No Suspense boundary: no crash β€” and no app either

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

5. The pager race jotai wins by default (0 aborts, correct rows)

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…

6. The abort signal jotai already handed you

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.

7. Paging flashes the fallback; unwrap() keeps the old rows

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).

8. Suspense is not an error boundary: two blank pages, measured

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…

9. loadable(): the tutorial favorite is now deprecated

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.

10. Retry logic you can assert on: exactly 3 fetches

"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.

11. Three atom writes cost the same as one

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.

12. The empty state you never see in dev

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).

13. Your own backend in one curl

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.

14. Honest notes

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.

← All guides