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

LayerToolUse Case
1shallowRef, v-memoLarge datasets, static data
2computed chains, memoizationDerived data
3Module-level composableShared state across components
4useFetch, useAsyncDataServer-side data fetching
5cachedEventHandlerAPI route responses
6routeRulesPer-route caching strategy
7Cache-Control headersCDN and browser caching
8PiniaClient-side state persistence

Start with conservative TTLs and increase them over time. Stale pages are almost always better than slow pages.