Solid's createResource is the primitive under every data fetch in a Solid app: give it an async function, read data(), and Suspense, loading flags, and error state come along for free. The model is small and honest β but its edges are sharp in exactly the places fine-grained reactivity trains you not to look. The fetcher is deliberately untracked, so the most natural refactor in the world silently stops refetching. A blind data().map blanks the entire app β even inside <Suspense>. And a thrown fetch error without an ErrorBoundary rendersβ¦ nothing, anywhere, ever.
Everything below was reproduced and measured in a real Chrome on solid-js 1.9.15 (Vite + vite-plugin-solid, client-side) with an instrumented window.fetch before publishing. The numbers β a pager frozen on page 1 while the header says page 2, 106 fetches in 5 seconds from one innocent effect, a fallback that re-appears over perfectly good data β are observed counts, not estimates.
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:3000, StackBlitz, the Solid playground, CI β anywhere. When you want your own schema it's one curl or one click β see Β§12.
Here is the paginated list almost everyone writes first β a page signal, read inside the fetcher:
const [page, setPage] = createSignal(1)
const [data] = createResource(async () => {
const r = await fetch(`${API}/products?page=${page()}&limit=5`)
return r.json()
})
It looks reactive β page() is a signal read, and everywhere else in Solid that creates a subscription. Measured, clicking a page 2 button that calls setPage(2):
page() in JSX) dutifully shows page 2β¦ and the network log still shows 1 fetch, total. The list still shows page 1's records. Click page 3: same. Zero console warnings, zero errors.The rule, stated in the docs and skated past by everyone: the fetcher runs untracked. Signals read inside it register no dependency β on purpose, so a fetcher touching five signals doesn't refire five ways. Reactivity lives entirely in the source argument you didn't pass. Your header and your data now disagree, silently β the same shape we've measured as Pinia Colada's static key and TanStack Router's missing loaderDeps.
const [data] = createResource(page, async (p) => { // β page as source
const r = await fetch(`${API}/products?page=${p}&limit=5`)
return r.json()
})
Measured on the same click sequence (page 1 β 2 β 3): exactly 3 fetches, and the list follows the header (row ids 1β¦, 6β¦, 11β¦). The source is the tracked half; the fetcher receives its value as an argument.
The source has one more behavior you should learn from a table, not from production. If it returns null, undefined, or false, the fetcher is skipped β that's the documented idiom for "don't fetch until ready". Measured on solid-js 1.9.15:
| source value | fetches? |
|---|---|
null / undefined / false | no β fetcher skipped, loading stays false |
0 | yes β we measured the request to /products/0 |
'' (empty string) | yes |
So createResource(userId, fetchUser) quietly fetches user 0 and user "" β only three specific falsy values act as "off". And one more measured wrinkle: setting the source back to null does not clear anything β the old record stayed rendered, loading: false, no fetch. If "deselected" should mean "empty screen", you have to render that branch yourself (or mutate(undefined)).
data() is undefined until the first response lands. This component:
<ul>{data().map(p => <li>{p.name}</li>)}</ul>
Measured: the page is completely blank β document.body.innerText is the empty string, the root's innerHTML is empty, and the console shows Uncaught TypeError: Cannot read properties of undefined (reading 'map'). Not a broken list β no app at all.
The instinctive fix is "wrap it in Suspense". Measured with the identical blind .map inside <Suspense fallback=β¦>: the same TypeError, zero rows. Suspense can't help, because reading a pending resource returns undefined synchronously while registering the suspension β your .map throws before any fallback logic gets a say. Three fixes, all measured clean:
<For each={data()}>{p => <li>{p.name}</li>}</For> // For tolerates undefined
<Show when={data()}>{...}</Show> // explicit guard
createResource(fetcher, { initialValue: [] }) // .map is now safe
With initialValue: [] we measured the blind .map version rendering 5 rows, zero errors.
Give the fetch a realistic 1.5s response (the demo API simulates it with ?mock_delay=1500 β no backend changes) under <Suspense fallback={<Skeleton/>}>. Measured timeline on a cold load: fallback visible at 300ms, data (5 rows) at ~2000ms, fallback gone. Solid shows the fallback immediately β there's no TanStack-Router-style one-second hold to configure away. data.loading is also just a boolean you can render inline. Dial mock_delay up to watch what your users watch on hotel wifi.
fetch doesn't reject on HTTP errors, and the fetcher is just an async function. This resource (simulating the outage with ?mock_status=500):
const [data] = createResource(async () => {
const r = await fetch(`${API}/products?limit=5&mock_status=500`)
return r.json() // no r.ok check
})
Measured: the resource "succeeds" β data.loading is false, data.error is undefined, and the screen renders the parsed error body β {"error":"simulated 500 error (mock_status)"} β as content, with zero console errors. Every Solid error feature you'd hope would fire is waiting for a throw that never came. Add the check: if (!r.ok) throw new Error('HTTP ' + r.status).
So you add the throw. What happens with no <ErrorBoundary> around the component reading data()? The intuition from other frameworks is "white screen and a red console". Measured on Solid:
null, exactly what it rendered before the response arrived. No crash. No error UI. The screen justβ¦ never updates.Error: HTTP 500) β the kind of line that never makes it into most error reporting.That's the worst failure shape: indistinguishable from "still loading" to the user, invisible to window.onerror-based monitoring. The fix is the boundary Solid ships:
<ErrorBoundary fallback={(err, reset) =>
<div>boom: {err.message} <button onClick={reset}>retry</button></div>}>
<ProductList/>
</ErrorBoundary>
Measured with the same thrown 500: the fallback renders boom: HTTP 500 with a working reset. Drill the whole matrix β mock_status=401, 429, 503 β against the demo API before your real API teaches you in production.
Classic setup: page 2's response takes 2s (mock_delay=2000), page 3's is instant. Click page 2, then page 3 300ms later. In a naive effect-based fetcher this ends with page 2's slow response clobbering page 3's screen β we've measured that clobber in Vue, Pinia, and NgRx. Measured here, source-driven createResource:
What you can't do is cancel it: the 2-second request ran to completion, and unlike TanStack Router's loader there is no abort signal to pass β the fetcher's second argument carries value and refetching, not an AbortSignal. If the wasted bandwidth matters (large payloads, metered APIs), you manage your own AbortController across fetcher calls. For most apps: accept the discard, it's correct.
data.latestHere's the one that makes Solid apps feel broken while working perfectly. Source-driven pager under Suspense, 1.5s responses. Measured on clicking page 2 after page 1 is happily rendered:
loading is true, and data.latest still holds the previous 5 items.Every source change re-suspends by default: read data() under Suspense and your users get a skeleton flash on every filter click. The escape hatch ships with the resource β data.latest returns the most recent value without re-triggering Suspense, so the old list stays visible (pair it with a data.loading dimmer). The other idiomatic fix is wrapping the source write in useTransition, which holds the old screen while the new one loads. Either way: decide deliberately between "skeleton flash" and "stale-but-visible", because the default decides for you.
"Keep it fresh," someone writes:
const [data, { refetch }] = createResource(fetchProducts)
createEffect(() => {
if (data()) refetch() // reads data(), writes data β a cycle
})
The effect tracks data(), refetch() resolves and updates data, the effect re-runs, forever. Measured: 42 fetches by the 2-second mark, 106 by 5 seconds β and the UI looks completely normal the whole time: list populated, zero console output. No effect_update_depth_exceeded guard fires (the write happens after an await, outside the synchronous tracking Solid's cycle detection can see). You find out from your API bill or a network tab someone left open. (Point it at a Mockbird project and the per-project daily cap turns this into a visible 429 instead of a surprise invoice.) Fresh-on-an-interval belongs in setInterval(refetch, 30_000) inside onMount β never in a tracked effect that reads what it refreshes.
Fetcher-level retry is easy to write and rarely tested against a real failure sequence. The demo API serves a deterministic one β mock_seq=503,503,200 returns those statuses in order per key:
const [data] = createResource(async () => {
for (let i = 0; i < 3; i++) {
const r = await fetch(`${API}/products?limit=5&mock_seq=503,503,200&mock_seq_key=run1`)
if (r.ok) return r.json()
await new Promise(res => setTimeout(res, 250 * (i + 1)))
}
throw new Error('gave up')
})
Measured: exactly 3 fetches β 503, 503, then the list renders from the 200. The x-mockbird-seq response header (1/3, 2/3, 3/3) makes each attempt assertable in tests. Use a fresh mock_seq_key per run to restart the sequence.
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. createResource comes out of these measurements mostly coherent: the untracked fetcher is a documented design decision (not a bug), the race was unloseable (Β§8 β 0 aborts, no clobber, even after the slow response landed), and the falsy-source idiom is genuinely useful once you know its exact boundary. The traps are the silent ones: a signal read that looks reactive and isn't, a thrown error that renders nothing anywhere (Β§7 β the worst shape we've measured across ecosystems), Suspense re-triggering over perfectly good data, and a storm with no guardrail. Note Solid 2.0's async story is being rebuilt around new primitives, and SolidStart apps increasingly use createAsync + query instead β but 1.x's createResource is what's running in production today, and the failure shapes (untracked deps, fetch-doesn't-reject, effect-refetch cycles) recur in every framework we've measured. Measurements taken Sep 24, 2026 on solid-js 1.9.15, Vite 5, client-side rendering. Related: mocking for SolidJS (states, retries, pagination recipes), React 19 use(), measured, TanStack Router loaders, measured, Svelte 5 $effect, measured.