Cache Everything: 8 Layers of Caching in Vue 3 & Nuxt 4
Vue 3 and Nuxt 4 offer multiple caching layers that, when combined, can dramatically reduce network requests and component re-renders. The key is knowing which layer to apply where.
Layer 1: Reactive Variable Caching
Vue's reactivity system is powerful but can be wasteful. By default, ref() and reactive() deeply proxy every nested property. For large API responses, use shallowRef to track only the reference change:
const rows = shallowRef([])
async function loadPage(page: number) {
rows.value = await $fetch(`/api/rows?page=${page}`)
}
For truly static data (config objects, lookup tables), use Object.freeze to opt out entirely:
const countries = shallowRef(Object.freeze(await $fetch('/api/countries')))
In templates, v-once renders once and never updates; v-memo re-renders only when its dependencies change:
Layer 2: Computed Property Caching
computed() caches its last result and recomputes only when tracked dependencies change. Keep computed functions pure—avoid Math.random() or Date calls inside them. Chain smaller computeds for granular invalidation:
const paidOrders = computed(() => rawOrders.value.filter(o => o.status === 'paid'))
const sortedOrders = computed(() => [...paidOrders.value].sort((a, b) => b.total - a.total))
const topTenOrders = computed(() => sortedOrders.value.slice(0, 10))
For functions with arguments, implement a Map-based memo:
function useMemoized(fn: (arg: A) => R) {
const cache = new Map()
return (arg: A): R => {
if (cache.has(arg)) return cache.get(arg)!
const result = fn(arg)
cache.set(arg, result)
return result
}
}
Layer 3: Composable-Level Caching
Define a composable outside the component instance to create a singleton module-level cache:
// composables/useProductCatalog.ts
const catalog = shallowRef([])
const lastFetched = ref(null)
const TTL = 5 * 60 * 1000 // 5 minutes
export function useProductCatalog() {
async function fetchIfStale() {
const now = Date.now()
if (lastFetched.value && now - lastFetched.value < TTL) return
catalog.value = await $fetch('/api/products')
lastFetched.value = now
}
return { catalog, fetchIfStale }
}
All components sharing this composable reuse the same catalog ref. Note: In Nuxt, use useState() for server-safe singleton state.
Layer 4: Nuxt Data Fetching Cache
useFetch and useAsyncData deduplicate requests by key. Multiple components with the same key fire only one network request:
const { data: user } = await useAsyncData('user-123', () => $fetch('/api/users/123'))
Nuxt 4 forwards server-fetched data to the client payload, eliminating double-fetch. Use computed keys for dynamic data:
const userId = computed(() => `user-${route.params.id}`)
const { data: user } = await useAsyncData(userId, () => $fetch(`/api/users/${route.params.id}`))
The getCachedData option lets you implement custom TTL logic. Nuxt 4 also introduces staleTime as a first-class option:
const { data } = await useFetch('/api/products', {
key: 'products-list',
staleTime: 5 * 60 * 1000 // fresh for 5 minutes
})
Use useLazyFetch for non-blocking navigation and server: false to exclude from SSR payload. Abort signals cancel in-flight requests:
const { data, refresh } = await useAsyncData(
'live-feed',
(_nuxtApp, { signal }) => $fetch('/api/feed', { signal })
)
Layer 5: Server Route Caching with Nitro
Nitro provides cachedEventHandler to cache entire route responses:
// server/api/products.get.ts
export default cachedEventHandler(
async (event) => {
const products = await db.product.findMany({ where: { active: true } })
return products
},
{
maxAge: 60 * 10, // 10 minutes
staleMaxAge: 60 * 60, // 1 hour stale-while-revalidate
swr: true,
getKey: (event: H3Event) => event.path,
varies: ['accept-language']
}
)
Use cachedFunction for shared logic across routes. Cache storage backends (redis, fs, cloudflare-kv) are configured in nuxt.config.ts:
nitro: {
storage: {
cache: {
driver: 'redis',
url: process.env.REDIS_URL
}
}
}
Layer 6: Route-Level Cache Rules
routeRules set rendering strategy and caching per route pattern:
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/products/**': { isr: 60 },
'/api/catalog/**': { cache: { maxAge: 60 * 10 } },
'/dashboard/**': { ssr: true, cache: false },
'/account/**': { cache: false },
'/admin/**': { redirect: '/login' }
}
})
Layer 7: HTTP & Browser Caching Headers
Set fine-grained Cache-Control headers on any response:
export default defineEventHandler((event) => {
setResponseHeaders(event, {
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'CDN-Cache-Control': 'max-age=86400',
'Vary': 'Accept-Encoding'
})
return getStaticConfig()
})
Key directives: public, private, max-age, s-maxage, stale-while-revalidate, no-store, immutable. Nitro automatically fingerprints static assets with immutable headers.
Layer 8: Pinia as a Client-Side Cache Store
Use Pinia to cache client-side state that persists across routes. Define a store with actions that check TTL before fetching:
export const useProductStore = defineStore('product', () => {
const products = ref([])
const lastFetch = ref(0)
const TTL = 60000
async function fetchProducts() {
if (Date.now() - lastFetch.value < TTL) return
products.value = await $fetch('/api/products')
lastFetch.value = Date.now()
}
return { products, fetchProducts }
})
Quick Reference Cheat Sheet
| Layer | Tool | Use Case |
|---|---|---|
| 1 | shallowRef, v-memo | Large datasets, static data |
| 2 | computed chains, memoization | Derived data |
| 3 | Module-level composable | Shared state across components |
| 4 | useFetch, useAsyncData | Server-side data fetching |
| 5 | cachedEventHandler | API route responses |
| 6 | routeRules | Per-route caching strategy |
| 7 | Cache-Control headers | CDN and browser caching |
| 8 | Pinia | Client-side state persistence |
Start with conservative TTLs and increase them over time. Stale pages are almost always better than slow pages.
