ength > 0) {
return assetRegistry.value
}
const response = await $fetch<AssetRecord[]>('/api/inventory/assets')
// Freeze to opt out of proxy tracking entirely
assetRegistry.value = Object.freeze(response)
lastSync.value = now
return assetRegistry.value
}
return { assets: readonly(assetRegistry), refresh }
}
**Architecture Rationale:** `shallowRef` only triggers updates when the reference changes, not when nested properties mutate. `Object.freeze` removes the data from Vue's proxy system entirely, guaranteeing zero reactivity overhead for static or replace-only payloads. This is critical for large grids, charts, or configuration objects.
### Step 2: Implement Composable-Level TTL Caching
Module-level state in composables acts as a singleton across component instances. This is ideal for shared data that should persist across route changes without refetching.
```typescript
// composables/useWarehouseCache.ts
import { ref, shallowRef } from 'vue'
interface WarehouseStock {
locationId: string
quantity: number
lastUpdated: string
}
const stockCache = shallowRef<Record<string, WarehouseStock>>({})
const cacheTimestamp = ref(0)
const CACHE_DURATION = 60_000 // 1 minute
export function useWarehouseCache() {
async function fetchStock(locationId: string): Promise<WarehouseStock> {
const now = Date.now()
const isFresh = now - cacheTimestamp.value < CACHE_DURATION
const cached = stockCache.value[locationId]
if (isFresh && cached) return cached
const data = await $fetch<WarehouseStock>(`/api/warehouse/${locationId}`)
stockCache.value = { ...stockCache.value, [locationId]: data }
cacheTimestamp.value = now
return data
}
return { fetchStock }
}
Architecture Rationale: Module-scoped refs survive component unmounts, acting as a lightweight client-side cache. The TTL check prevents stale data from persisting indefinitely. In Nuxt SSR, this pattern must be wrapped with useState() to avoid cross-request state leakage, which is covered in the Pitfall Guide.
Step 3: Leverage Nuxt 4 Data Fetching Primitives
Nuxt 4's useAsyncData and useFetch provide built-in request deduplication, SSR payload forwarding, and declarative cache controls.
// pages/dashboard.vue
const route = useRoute()
const dashboardKey = computed(() => `dash-${route.params.orgId}`)
const { data: metrics, pending, refresh } = await useAsyncData(
dashboardKey,
async (_nuxtApp, { signal }) => {
return $fetch(`/api/orgs/${route.params.orgId}/metrics`, { signal })
},
{
staleTime: 120_000, // 2 minutes freshness window
getCachedData(key) {
// Custom cache interception: return payload data if available
const nuxtApp = useNuxtApp()
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
}
}
)
Architecture Rationale:
staleTime replaces manual getCachedData TTL logic in most cases. Nuxt automatically serves cached data within the window and triggers background refreshes.
getCachedData allows custom cache resolution strategies, such as checking localStorage or a custom Map before falling back to network.
AbortSignal integration ensures in-flight requests are cancelled during rapid navigation or component teardown, preventing memory leaks and race conditions.
- Reactive keys (
computed) automatically purge stale cache entries when route parameters change, eliminating manual cache clearing.
Step 4: Deploy Nitro Server-Side Caching
Nitro's caching primitives operate at the server route level, reducing origin load and improving response times for public or semi-public endpoints.
// server/api/orgs/[orgId]/metrics.get.ts
import { cachedEventHandler } from 'nitropack/runtime'
import type { H3Event } from 'h3'
export default cachedEventHandler(
async (event) => {
const orgId = getRouterParam(event, 'orgId')
const metrics = await db.orgMetrics.findUnique({ where: { orgId } })
return metrics || { error: 'Not found' }
},
{
maxAge: 120, // 2 minutes
staleMaxAge: 600, // Serve stale for 10 minutes during background refresh
swr: true,
getKey: (event: H3Event) => `metrics:${getRouterParam(event, 'orgId')}`,
varies: ['accept-language', 'x-region']
}
)
Architecture Rationale: cachedEventHandler wraps the route handler and stores responses in Nitro's unstorage layer. SWR (staleMaxAge) delivers cached responses immediately while triggering a background refresh, eliminating latency spikes during cache expiration. varies ensures cached responses are scoped to relevant headers, preventing cross-user data leakage.
Nuxt's routeRules centralize caching and rendering strategies per route pattern, removing the need for middleware or manual header injection.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/api/public/**': { swr: 300 },
'/dashboard/**': { ssr: true, cache: false },
'/assets/**': { static: true, swr: 86400 }
}
})
Architecture Rationale: routeRules declaratively map URL patterns to caching behaviors. Public API routes use SWR for balanced freshness. Dashboard routes disable caching to respect user-specific data. Static asset routes use long-term caching with SWR for zero-downtime deployments. This layer acts as the final invalidation boundary before HTTP headers are generated.
Pitfall Guide
1. Deep Proxying Replace-Only Data
Explanation: Using ref() or reactive() on large API responses forces Vue to traverse and proxy every nested property. This consumes memory and triggers unnecessary dependency tracking.
Fix: Use shallowRef for data that is replaced wholesale. Apply Object.freeze() to truly static payloads to opt out of reactivity entirely.
2. Impure Computed Dependencies
Explanation: Computed properties cache results based on tracked dependencies. Introducing non-deterministic logic (e.g., Math.random(), Date.now(), or external API calls) breaks caching guarantees and causes infinite re-evaluation loops.
Fix: Keep computed functions pure. Move side effects to watch or watchEffect. Use manual Map-based memoization for argument-dependent calculations.
3. SSR State Leakage in Module Singletons
Explanation: Module-level ref or shallowRef variables are shared across all SSR requests in Nuxt. Without isolation, User A's data can leak to User B.
Fix: In Nuxt, wrap shared state with useState('unique-key', () => initialValue). This ensures request-scoped isolation on the server while maintaining client-side singleton behavior.
Explanation: Nitro caches responses by default using the request path. If your API returns localized, region-specific, or user-tiered data without varies, cached responses will be served to the wrong clients.
Fix: Always configure varies: ['accept-language', 'authorization', 'x-tenant-id'] in cachedEventHandler when responses differ based on headers.
5. Over-Caching Dynamic User Data
Explanation: Applying aggressive SWR or long staleTime to user-specific endpoints (profiles, dashboards, carts) results in stale or cross-user data exposure.
Fix: Disable caching for authenticated routes (cache: false in routeRules). Use short staleTime (10β30s) for semi-dynamic user data, and rely on client-side useAsyncData deduplication instead of server SWR.
6. Blocking Navigation with useFetch
Explanation: useFetch and useAsyncData block route navigation until the promise resolves. On slow networks, this creates a perceived hang and degrades UX.
Fix: Use useLazyFetch for non-critical data (recommendations, analytics, secondary lists). Pair with pending state to render skeleton UI immediately.
7. Manual Cache Invalidation Without TTL
Explanation: Relying on manual refresh() calls or button-triggered cache clears leads to inconsistent state and forgotten invalidations.
Fix: Always pair caching with explicit TTLs (staleTime, maxAge, or composable Date.now() checks). Use background refresh patterns to maintain freshness without blocking user interactions.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public read-heavy API (e.g., product catalog) | Nitro cachedEventHandler + SWR + routeRules | Eliminates origin hits, serves stale instantly, background refresh | Reduces server CPU by 60β80% |
| User-specific dashboard | useAsyncData with staleTime: 30000 + cache: false in routeRules | Balances freshness with deduplication, prevents cross-user leakage | Minimal server cost, client-side memory efficient |
| Real-time feed (e.g., notifications) | useLazyFetch + AbortSignal + short staleTime | Non-blocking navigation, cancels stale requests, avoids waterfall | Slightly higher network calls, but zero UX blocking |
| Static configuration/lookup tables | shallowRef + Object.freeze + module-level TTL | Zero reactivity overhead, instant access, no network after first load | Near-zero memory/CPU cost |
Configuration Template
// nuxt.config.ts
export default defineNuxtConfig({
// Centralized routing & caching rules
routeRules: {
'/api/public/**': { swr: 300 },
'/api/auth/**': { cache: false },
'/assets/**': { static: true, swr: 86400 }
},
// Nitro storage backend configuration
nitro: {
storage: {
cache: {
driver: 'redis',
url: process.env.REDIS_URL || 'redis://localhost:6379'
},
// Fallback to filesystem for local development
fallback: {
driver: 'fs',
base: './.data/cache'
}
},
experimental: {
// Enable payload compression for faster SSR hydration
payloadCompression: true
}
},
// Vue reactivity optimization
vue: {
runtimeCompiler: false,
devtools: false // Disable in production to reduce bundle size
}
})
Quick Start Guide
- Initialize Nuxt 4 Project: Run
npx nuxi@latest init my-app and install dependencies. Ensure you're on Nuxt 4+ for staleTime and payload forwarding support.
- Configure Route Rules: Add
routeRules to nuxt.config.ts mapping your public, authenticated, and static paths to appropriate caching strategies.
- Implement Server Cache: Wrap public API routes with
cachedEventHandler, configure maxAge, staleMaxAge, and varies headers based on response variability.
- Optimize Client Fetching: Replace manual
useFetch calls with useAsyncData using explicit keys. Apply staleTime for declarative freshness and useLazyFetch for non-blocking data.
- Audit Reactivity: Scan components for large datasets. Convert
ref() to shallowRef, apply Object.freeze to static payloads, and verify v-memo usage in v-for loops with stable lists.
This architecture eliminates redundant computation, aligns cache boundaries with data lifecycle expectations, and provides explicit invalidation controls across the full stack. Deploy it incrementally, monitor cache hit ratios via Nitro metrics, and adjust TTLs based on actual data mutation frequency.