โ† All guides

Pinia Colada + fetch, measured โ€” the static key that freezes pagination, the refetch that poisons your cache, and the 500 that reports success

Pinia Colada is the data-fetching layer for Pinia by Pinia's own author โ€” useQuery, useMutation, and a shared cache, in a library a fraction of TanStack Query's size. Its happy path is delightfully short. But the first query most people write hides a trap in its key, its default cache windows surprise in both directions, and โ€” like every fetch wrapper โ€” an unhandled HTTP 500 walks straight through it wearing a success badge.

Everything below was reproduced and measured in a real Chrome on @pinia/colada 1.4.6 + Pinia 4.0.3 + Vue 3.5.43 (Vite, Composition API) with an instrumented window.fetch before publishing. The numbers โ€” a pager frozen on page 1 with zero warnings, page-2 data cached under the page-1 key, status: 'success' over a 500 body, exactly 1 aborted request, exactly 1 POST + 1 refetch after a mutation โ€” are observed counts, not estimates.

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, CodeSandbox, CI โ€” anywhere. When you want your own schema it's one curl or one click โ€” see ยง11.

โšก 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 static key that freezes pagination โ€” zero warnings

Setup is two lines (app.use(createPinia()), app.use(PiniaColada)), and here is the paginated query almost everyone writes first:

import { ref } from 'vue'
import { useQuery } from '@pinia/colada'

const API = 'https://mockbird.mockbird.workers.dev/m/demo'
const page = ref(1)

const { data, refetch } = useQuery({
  key: ['products', page.value],   // โš  evaluated ONCE, at setup time
  query: () =>
    fetch(`${API}/products?page=${page.value}&limit=5`).then(r => r.json()),
})

key: ['products', page.value] reads the ref once, during setup(). The key is now the frozen array ['products', 1] forever. Measured, with a button doing page++:

This is the Colada cousin of a trap we've measured across ecosystems (NgRx's rxMethod called with a value, Angular's stale-closure httpResource URL): a dependency captured by value at setup time, failing silently.

3. The refetch() that poisons your cache

Here's where the static key gets genuinely nasty. The component above also renders a refresh button wired to the returned refetch(). A user on "page 2" (per the frozen UI, still showing page 1) clicks it. Measured:

# the two requests the harness logged, in order
/products?page=1&limit=5     โ† initial mount
/products?page=2&limit=5     โ† refetch()  โ€” but stored under key ['products', 1]!

The query function is a closure โ€” it reads page.value at call time and happily fetches page 2. But the cache entry it writes into is still ['products', 1]. After the click our UI showed first: 6 (page 2's first record) โ€” page 2's data is now cached as page 1. Navigate away and back, and every component asking for page 1 gets page 2 from cache. That's not a stale view; it's a corrupted entry, and no warning will ever point at it.

4. The one-character-class fix: make the key a getter

const { data } = useQuery({
  key: () => ['products', page.value],   // โœ” getter โ€” tracked, reactive
  query: () =>
    fetch(`${API}/products?page=${page.value}&limit=5`).then(r => r.json()),
})

Measured: click next page โ†’ exactly 2 fetches (?page=1, then ?page=2), UI updates to first: 6, and each page's data lives under its own key. Colada's own types push you here โ€” key accepts a getter/computed precisely so it can track dependencies. The shipped JSDoc even says to treat the key as "an array of dependencies of your queries". Rule: if the query function reads it, the key must contain it โ€” via a getter.

5. The 500 that reports status: 'success'

fetch() does not reject on HTTP error statuses, and Colada can only classify what your query function throws. The standard one-liner:

query: () => fetch(`${API}/products?limit=5&mock_status=500`).then(r => r.json())

Measured against a simulated 500 (every Mockbird endpoint accepts ?mock_status=):

status: success   error: null   typeof data: object
<li>{"error":"simulated 500 error (mock_status)"}</li>

The 500's JSON body resolved the promise, so the query is a success: error is null, your error branch never renders, and the error object itself is handed to the list as data โ€” our harness rendered it as a product row. Zero console errors. The fix is the same one every fetch wrapper needs:

query: async () => {
  const r = await fetch(`${API}/products?limit=5`)
  if (!r.ok) throw new Error(`HTTP ${r.status}`)
  return r.json()
}

Measured with the throw in place: status: 'error', error.message: "HTTP 500" โ€” the error branch renders. Now drill both branches deliberately: ?mock_status=500 for the sad path, plain URL for the happy one, in the same test run.

6. "Why isn't it refetching?" / "Why IS it refetching?" โ€” the 5-second default

Both questions usually have the same answer: staleTime defaults to 5000 ms (per the shipped type definitions). Measured with a toggle that unmounts and remounts a component using the query:

ActionFetches (cumulative)
Mount1
Unmount, remount ~1 s later (data still fresh)1 โ€” cache served, no request
Unmount, wait 5.2 s, remount (data now stale)2 โ€” automatic refetch on mount

So: edited a record server-side and the component "won't refetch"? You're inside the 5-second freshness window. Seeing "mystery" requests on every navigation? Your data is older than 5 seconds and refetchOnMount defaults to true (as does refetchOnWindowFocus โ€” both per the shipped .d.mts). Tune per query with staleTime, or invalidate explicitly after writes (ยง9).

The cache also deduplicates: we mounted two components using the same key simultaneously โ€” exactly 1 fetch, both rendered 5 records. That's the feature you're adopting Colada for, and it's why key discipline (ยง4) matters so much: the key IS the identity of the data.

7. The race Colada wins by design (and the abort it does for you)

The classic filter race โ€” user clicks page 2 (slow response), then page 3 (fast) โ€” is where naive Pinia actions showed stale data with zero aborts in our measurements. With a getter key we made page 2 slow via ?mock_delay=1500 and flipped to page 3 after 150 ms, without passing an abort signal. Measured:

Colada also hands your query function an AbortSignal that it aborts when a new call supersedes the same entry. Pass it to fetch:

query: ({ signal }) =>
  fetch(`${API}/products?limit=5&mock_delay=1500`, { signal }).then(r => r.json())

Measured: two refetch() clicks 200 ms apart on a 1.5-second endpoint โ†’ exactly 1 AbortError, one clean result. The superseded request didn't just get ignored โ€” it was cancelled on the wire. ?mock_delay= is what makes this reproducible: you can't demo a race against an API that answers in 40 ms.

8. Loading states you can actually see

const { data, isLoading, status, asyncStatus } = useQuery({
  key: ['loading'],
  query: () => fetch(`${API}/products?limit=5&mock_delay=2000`).then(r => r.json()),
})

Measured at t=300 ms with a 2-second simulated delay: isLoading: true, status: 'pending', asyncStatus: 'loading', data: undefined โ€” your skeleton is genuinely on screen. After resolution: false / 'success' / 'idle'. Note the split: status describes the data (pending/success/error), asyncStatus describes the request (idle/loading) โ€” a background refresh is status: 'success' + asyncStatus: 'loading', which is exactly the state your stale-while-revalidate spinner logic needs to distinguish. Combine mock_delay with mock_status to preview the slow-then-failed path.

9. Mutations that invalidate โ€” measured to the request

import { useMutation, useQueryCache } from '@pinia/colada'

const qc = useQueryCache()
const { mutate } = useMutation({
  mutation: (body) =>
    fetch(`${API}/products`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body),
    }).then(r => r.json()),
  onSettled: () => qc.invalidateQueries({ key: ['products'] }),
})

Measured against the demo API (writes persist): one mutate({ name: 'Colada Test', price: 9 }) click โ†’ exactly 1 POST + exactly 1 list refetch, list went 30 โ†’ 31 records, mutation status success. No manual store surgery, no double-refetch. Because Mockbird writes are real (the POST-ed record comes back in the refetched list), this verifies the full invalidation loop โ€” something read-only fake APIs (JSONPlaceholder et al.) can never exercise.

10. Drill recovery without breaking anything real

Colada's core has no automatic retry (there's an official retry plugin) โ€” but before configuring retries, test recovery deterministically. ?mock_seq= scripts a status sequence per client:

const seqKey = Date.now()  // fresh sequence for this session
query: async () => {
  const r = await fetch(
    `${API}/products?limit=5&mock_seq=503,503,200&mock_seq_key=${seqKey}`)
  if (!r.ok) throw new Error(`HTTP ${r.status}`)
  return r.json()
}

Measured with two manual refresh() clicks after the initial load: status went error โ†’ error โ†’ success in exactly 3 fetches, list rendered 5 records on the third. The server answers 503, 503, then 200, every run, in that order (the x-mockbird-seq response header shows 1/3 โ€ฆ 3/3). Your "retry" button, your error toast, and your recovery render all get exercised on demand โ€” no flaky-network luck required.

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

12. Honest notes

Written by the Mockbird maker โ€” bias disclosed. Pinia Colada itself comes out of these measurements looking good: the key-based cache made the classic race unloseable by design (ยง7), dedup worked exactly as advertised, and the abort machinery cancelled the superseded request with zero code beyond passing signal. The traps are the static key (silent, and cache-corrupting with refetch), the universal fetch-doesn't-reject 500 hole, and default freshness windows you haven't read about yet โ€” all fixable in one line each once you can see them. Colada is younger than TanStack Query and pre-2.0; if you need its huge plugin/devtools ecosystem, TanStack Query (which has an official Vue adapter) is the mature pick โ€” our measurements of its React side are in the TanStack Query guide. If you're on plain Pinia stores without a query layer, the failure modes are different and nastier โ€” measured in the Pinia stores guide. Measurements here were taken Sep 23, 2026 on @pinia/colada 1.4.6, Pinia 4.0.3, Vue 3.5.43, Vite 5 โ€” versions move; the failure shapes are what to remember. Related: mocking for Vue, watchEffect + fetch, measured.

โ† All guides