Most bugs in data-heavy frontends are cache bugs wearing another name: a list that does not refresh after a save, a detail page showing the previous item for a frame, a request fired six times because six components asked. The vocabulary that fixes them, keys, staleness, invalidation, deduplication, optimistic updates, is what this page and the interview questions behind it are about.
['products', { page, sort, q }]). Two callers with the same key share one request and one entry (deduplication); changing an input changes the key and produces a new entry.staleTime, swap it in when it arrives. That is the model behind TanStack Query, Pinia Colada, Nitro's swr route rule and the HTTP header of the same name.invalidateQueries({ queryKey: ['products'] })), on window focus, on reconnect, on an interval, or on a server push. Time-based expiry alone is a fallback, not a strategy.?page=3) is simple and jumps anywhere but drifts when items are inserted; cursor pagination (?after=<id>) is stable under inserts and is what infinite lists should use. Keep the previous page visible while the next loads (placeholderData: keepPreviousData), and treat "infinite query" as a list of pages with a cursor per page.useAsyncData/useFetch cache per key in the payload and in memory for the app instance, deduplicate in-flight calls (dedupe), refetch on reactive key changes, and expose getCachedData for custom staleness. It is page-oriented and SSR-first; it has no background refetch on focus, no mutation state, no retries and no garbage collection policy.@tanstack/vue-query) adds staleTime/gcTime, retries with backoff, refetch on focus and reconnect, useMutation with optimistic helpers, useInfiniteQuery, devtools, and SSR through dehydrate/hydrate wired into the Nuxt payload. Pinia Colada is the Pinia-team equivalent built on Pinia (useQuery, useMutation, query keys, a Nuxt module) with a smaller surface. Use one when the app is a client-heavy dashboard with many interdependent queries; keep useAsyncData for page-level SSR data.defineCachedEventHandler, routeRules swr/isr, CDN Cache-Control and ETag. The fullstack section covers it; the client cache sits on top and must never cache personalised responses that the server also cached by URL.Optimistic update with rollback and invalidation using TanStack Query in a Nuxt component:
<script setup lang="ts">
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const props = defineProps<{ todo: { id: string, done: boolean } }>()
const client = useQueryClient()
const toggle = useMutation({
mutationFn: (done: boolean) => $fetch(`/api/todos/${props.todo.id}`, { method: 'PATCH', body: { done } }),
async onMutate(done) {
await client.cancelQueries({ queryKey: ['todos'] }) // do not let an in-flight refetch overwrite the optimistic state
const previous = client.getQueryData<typeof props.todo[]>(['todos'])
client.setQueryData<typeof props.todo[]>(['todos'], old => old?.map(t => (t.id === props.todo.id ? { ...t, done } : t)))
return { previous }
},
onError(_err, _vars, context) {
if (context?.previous) client.setQueryData(['todos'], context.previous) // rollback
},
onSettled() {
client.invalidateQueries({ queryKey: ['todos'] }) // reconcile with the server
},
})
</script>
<template>
<label>
<input :checked="todo.done" type="checkbox" :disabled="toggle.isPending.value" @change="toggle.mutate(!todo.done)">
{{ todo.id }}
</label>
</template>
Wiring TanStack Query into SSR so the server's fetches hydrate the client cache:
import { dehydrate, hydrate, QueryClient, VueQueryPlugin, type DehydratedState } from '@tanstack/vue-query'
export default defineNuxtPlugin((nuxtApp) => {
const state = useState<DehydratedState | null>('vue-query', () => null)
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5_000 } } })
nuxtApp.vueApp.use(VueQueryPlugin, { queryClient })
if (import.meta.server) nuxtApp.hooks.hook('app:rendered', () => { state.value = dehydrate(queryClient) })
if (import.meta.client) hydrate(queryClient, state.value)
})
Cursor pagination as an infinite query, keeping the previous pages while the next loads:
import { useInfiniteQuery } from '@tanstack/vue-query'
export function useProductFeed(category: MaybeRefOrGetter<string>) {
return useInfiniteQuery({
queryKey: ['products', 'feed', { category }],
queryFn: ({ pageParam, signal }) => $fetch<{ items: Product[], nextCursor: string | null }>('/api/products', {
query: { category: toValue(category), after: pageParam },
signal, // cancelled when the key changes or the component unmounts
}),
initialPageParam: null as string | null,
getNextPageParam: last => last.nextCursor,
staleTime: 60_000,
})
}
The same idea with Nuxt's built-in layer when a full query library is not warranted: a mutation followed by targeted invalidation.
await $fetch(`/api/todos/${id}`, { method: 'PATCH', body: { done } })
await refreshNuxtData(['todos', `todo:${id}`]) // refetch only the affected keys
refreshNuxtData() or invalidateQueries() with no key after every mutation is the cache equivalent of restarting the server: it works, and it makes every list flicker and every API endpoint hot. Invalidate the keys the mutation could have changed, and structure keys hierarchically (['products'] → ['products', id]) so a prefix match covers them.
Marking a payment as "done" optimistically and rolling back on failure shows the user a success that never happened. Reserve optimism for reversible, high-success actions and show pending state for the rest.
The current TanStack Query major for Vue and its recommended Nuxt SSR wiring, and Pinia Colada's status and Nuxt module name.
docs ↗useFetch('/api/products') in three components on one page and count requests; then give two of them different transform options and read the console warning.useInfiniteQuery; insert an item at the top server-side between two page loads and check for duplicates in both versions."Cache. The server owns the data, so the client keeps a copy keyed by everything that determines the result, with a staleness policy and explicit invalidation after mutations. In Nuxt the built-in layer is useAsyncData and useFetch: keyed, deduplicated, SSR-hydrated, refetched when the key changes, with getCachedData for custom staleness. When an app is a client-heavy dashboard with dozens of interdependent queries, I add TanStack Query or Pinia Colada for stale-while-revalidate, retries, refetch on focus, mutation state with optimistic updates, infinite queries and devtools, and wire it into the payload so the server's fetches hydrate the client cache. A Pinia store is for client-owned state; putting API data in it is how lists go stale."
"An optimistic update writes the expected result into the cache before the server confirms it: I cancel in-flight refetches for that key, snapshot the previous value, apply the change, and on error restore the snapshot; on settle I invalidate the key so the cache reconciles with the server. It is right for reversible, high-success actions where latency would feel like lag: toggles, reordering, likes. It is wrong for actions whose false success misleads, such as payments or sending a message, where a pending state and a confirmed result are the honest interface."
Nothing yet. Notes filed here show up on this page and the section overview.
Pinia
Setup stores versus option stores, how Pinia works in Nuxt SSR, storeToRefs and the reactivity traps, plugins and persistence, testing with createTestingPinia, Pinia 4, and when a store is the wrong tool.
Forms and validation
One schema for client and server with Standard Schema libraries, Nuxt UI's UForm, VeeValidate and FormKit compared, field components with defineModel, async validation, accessible error display, and mapping server errors back to fields.