System patterns in Nuxt
Queues, caching, retries, rate limiting, feature flags - five infrastructure patterns every production Nuxt app eventually needs, and why Nitro makes them less painful.
That tweet is half right. The half it gets right: most developers design exclusively for the happy path. User clicks button, server responds, data renders. Done. The half it gets wrong: you're always building a system - you're just deciding whether to design it intentionally or discover it on fire at 2am.
The difference between an "app" and a "system" it's whether you've answered the failure questions:
- What happens when the email service is down for 20 minutes?
- What happens when your database is crushed by N+1 queries at peak traffic?
- What happens when a third-party API returns a 503?
- What happens when a bot decides to hit your signup endpoint 500 times per minute?
- What happens when you need to change behavior for 5% of users without deploying?
Scheduled maintenance windows, traffic spikes, flaky dependencies, scrapers, changing product requirements - every app with real users hits all of these at some point.
You can handle all of it in Nuxt. Nitro (Nuxt's server engine) ships with the primitives you need: a storage layer, server tasks, event hooks, and a middleware system. For scale, you plug in Redis and BullMQ. But you can start without them.
Queue system
The naive pattern for sending a welcome email:
export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event)
const user = await db.users.create({ email, name })
await emailService.sendWelcome(user) // blocks the response
return { success: true }
})
If emailService.sendWelcome takes 800ms (it will), your API response takes 800ms. If the email service is down, registration fails. If it times out, the user never gets the email and you have no record of the failure.
A job queue decouples the trigger (user registered) from the work (send email). The API responds immediately; the worker processes asynchronously.
For simple cases - scheduled jobs, lightweight background work - Nitro's built-in server tasks are enough:
export default defineTask({
meta: {
name: 'email:welcome',
description: 'Send welcome email to new user',
},
async run({ payload }: { payload: { userId: string } }) {
const user = await db.users.findById(payload.userId)
await emailService.sendWelcome(user)
},
})
export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event)
const user = await db.users.create({ email, name })
// Returns immediately - task runs in background
await $fetch('/api/_nitro/tasks/run', {
method: 'POST',
body: { task: 'email:welcome', payload: { userId: user.id } },
})
return { success: true }
})
For production with real durability requirements - retries on failure, priority queues, concurrency control, dead letter queues - use BullMQ with Redis:
import { Queue, Worker } from 'bullmq'
import { redis } from './redis'
export const emailQueue = new Queue('emails', { connection: redis })
// Worker defined separately, can run in a different process
new Worker('emails', async (job) => {
if (job.name === 'welcome') {
await emailService.sendWelcome(job.data)
}
}, {
connection: redis,
attempts: 3, // retry 3 times before moving to dead letter
backoff: { type: 'exponential', delay: 1000 },
})
export default defineEventHandler(async (event) => {
const { email, name } = await readBody(event)
const user = await db.users.create({ email, name })
// Non-blocking, durable - job survives server restarts
await emailQueue.add('welcome', { userId: user.id }, {
removeOnComplete: 100, // keep last 100 completed jobs for debugging
removeOnFail: 1000,
})
return { success: true }
})
BullMQ persists jobs to Redis. If your server crashes before processing, the job is still there when it restarts. That's the difference between "I sent an email request" and "I know the email will be sent."
Caching strategy
Every database call that returns the same data twice is a candidate for a cache. Not because databases are slow, but because they have a finite capacity and your app's performance ceiling is that capacity divided by concurrent queries.
Nitro's defineCachedEventHandler wraps an API route with automatic cache-aside logic using whatever storage driver you configure:
export default defineCachedEventHandler(async (event) => {
// This query only runs on cache miss
return await db.products.findMany({ where: { active: true } })
}, {
maxAge: 60 * 5, // 5 minutes
name: 'products',
// Cache key can vary by query params, user region, etc.
getKey: (event) => {
const query = getQuery(event)
return `products:${query.category ?? 'all'}:${query.page ?? 1}`
},
})
By default this uses in-memory storage (lost on restart). Switch to Redis with a single config change:
export default defineNuxtConfig({
nitro: {
storage: {
cache: {
driver: 'redis',
url: process.env.REDIS_URL,
},
},
},
})
For function-level caching (not route-level), defineCachedFunction works the same way:
export const getActiveProducts = defineCachedFunction(
async (category: string) => db.products.findMany({ where: { category, active: true } }),
{ maxAge: 300, name: 'active-products', getKey: (category) => category }
)
The layering matters for real traffic:
| Layer | Tool | Latency | When to use |
|---|---|---|---|
| Memory | Nitro in-process | <1ms | Tiny, frequently-read, per-instance |
| Redis | Nitro + Redis driver | ~1ms | Shared across instances, invalidatable |
| CDN | Cloudflare/Vercel cache | <30ms globally | Public, rarely-changing responses |
| Browser | Cache-Control + ETag | 0ms on hit | Static assets, user-specific data |
The CDN layer is the easiest win most teams miss. Nitro lets you set Cache-Control headers directly from route handlers:
export default defineEventHandler(async (event) => {
setResponseHeader(event, 'Cache-Control', 'public, max-age=300, s-maxage=3600')
return await getPublicData()
})
max-age=300 for browsers, s-maxage=3600 for the CDN. The CDN serves the cached response globally; browsers revalidate at 5 minutes. Your server handles a fraction of the traffic it otherwise would.
Retry mechanism
Third-party APIs fail. Not continuously - intermittently. A 502 here, a connection reset there. Without retries, these become user-facing errors. With retries, they're invisible.
$fetch (which Nuxt uses via ofetch) has built-in retry support:
// Retries automatically on network errors and 5xx responses
const data = await $fetch('/api/external', {
retry: 3,
retryDelay: 500, // 500ms between attempts
retryStatusCodes: [429, 500, 502, 503, 504],
})
For exponential backoff - waiting progressively longer between attempts to avoid hammering a struggling service:
export async function withRetry<T>(
fn: () => Promise<T>,
{ attempts = 3, baseDelay = 100 } = {}
): Promise<T> {
let lastError: Error
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (err) {
lastError = err as Error
if (i < attempts - 1) {
// 100ms, 200ms, 400ms - doubles each time
await new Promise(r => setTimeout(r, baseDelay * 2 ** i))
}
}
}
throw lastError!
}
export default defineEventHandler(async (event) => {
const { userId } = getQuery(event)
return await withRetry(
() => externalApi.fetchUserProfile(userId),
{ attempts: 3, baseDelay: 200 }
)
})
Two things to get right:
Don't retry everything. A 404 is not transient - retrying it wastes time and money. Retry only status codes that indicate temporary failure: 429 (rate limited), 500, 502, 503, 504. A 400 means your request is wrong; retrying it won't help.
Respect Retry-After. When a 429 response includes a Retry-After header, that's the server telling you exactly how long to wait. Ignoring it and retrying immediately just gets you rate-limited again faster.
const retryAfter = response.headers.get('Retry-After')
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelay * 2 ** attempt
await new Promise(r => setTimeout(r, delay))
Rate limiting
Without rate limiting, your public API is an invitation for abuse. A form submission endpoint without limits gets spam. A search endpoint without limits gets scraped. An auth endpoint without limits gets brute-forced.
Nitro's server middleware runs before every request. Combined with useStorage (which uses the same storage you configured above), you get rate limiting with minimal code:
export default defineEventHandler(async (event) => {
// Skip rate limiting for non-API routes
if (!event.path.startsWith('/api/')) return
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
const key = `rate-limit:${ip}:${Math.floor(Date.now() / 60_000)}` // per-minute window
const storage = useStorage('cache')
const count = ((await storage.getItem<number>(key)) ?? 0) + 1
await storage.setItem(key, count, { ttl: 60 })
setResponseHeader(event, 'X-RateLimit-Limit', '100')
setResponseHeader(event, 'X-RateLimit-Remaining', String(Math.max(0, 100 - count)))
if (count > 100) {
throw createError({ statusCode: 429, message: 'Too many requests' })
}
})
This is fixed-window rate limiting: 100 requests per IP per minute. For production, sliding window is more accurate (it doesn't have the "burst at window boundary" problem), but this is enough to stop bots and casual abuse.
For per-route limits (auth stricter than search):
const LIMITS: Record<string, number> = {
'/api/auth/login': 10, // brute force protection
'/api/search': 300, // liberal for search
default: 100,
}
const limit = LIMITS[event.path] ?? LIMITS.default
getRequestIP returns the IP from the connection or X-Forwarded-For header. X-Forwarded-For is spoofable if your server is directly exposed - only trust it if requests go through a reverse proxy (Cloudflare, Nginx) that sets it. Check your deployment topology before relying on it for security-sensitive limits.Feature flags
Feature flags decouple deployment from release. You ship code to production with the new feature disabled, then enable it for 1% of users, then 10%, then everyone - or roll back without redeploying if something goes wrong.
The simplest version: environment variables read via useRuntimeConfig.
export default defineNuxtConfig({
runtimeConfig: {
public: {
features: {
newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
betaBadge: process.env.FEATURE_BETA_BADGE === 'true',
},
},
},
})
<script setup>
const config = useRuntimeConfig()
</script>
<template>
<NewCheckout v-if="config.public.features.newCheckout" />
<OldCheckout v-else />
</template>
The limitation: changing a flag requires redeploying. For truly dynamic flags (toggle without deploy, target specific users or percentages), you need runtime storage.
export default defineEventHandler(async (event) => {
// Add your own auth check here
const { flag, enabled } = await readBody(event)
const storage = useStorage('cache')
const flags = await storage.getItem<Record<string, boolean>>('feature-flags') ?? {}
flags[flag] = enabled
await storage.setItem('feature-flags', flags)
return flags
})
// 30s cache so changes propagate quickly
export default defineCachedEventHandler(async () => {
const storage = useStorage('cache')
return await storage.getItem<Record<string, boolean>>('feature-flags') ?? {}
}, { maxAge: 30, name: 'flags' })
export function useFlag(flag: string) {
const { data } = useFetch('/api/flags')
return computed(() => data.value?.[flag] ?? false)
}
<script setup>
const newCheckout = useFlag('new-checkout')
</script>
<template>
<NewCheckout v-if="newCheckout" />
<OldCheckout v-else />
</template>
For more sophisticated needs - percentage rollouts, user targeting, A/B testing with analytics - Unleash is open source and self-hostable. PostHog bundles feature flags with session recording and analytics. Both have SDKs that work in a Nitro server context.
useFlag('new-checkout')How these five things connect
Each pattern answers one failure question:
| Pattern | Failure question it answers |
|---|---|
| Queue | What if the downstream service is slow or down? |
| Cache | What if the database can't handle this much read traffic? |
| Retry | What if this API call fails transiently? |
| Rate limit | What if someone abuses this endpoint? |
| Feature flags | What if the new feature breaks in production? |
Building these reactively - after the 2am incident - is more expensive than building them while the code is in front of you.
Nitro's storage abstraction is the thing that makes this accessible in Nuxt. The same useStorage('cache') call works in rate limiting, flag storage, and caching. Backed by memory locally, Redis in production - same API everywhere. You don't need to wire up five different clients.
The tweet is right that most developers don't think about this until something breaks. Your checkout flow is already a system. Your auth is already a system.
Continue Reading
System design vs Nuxt fullstack developer
20 patterns from distributed systems - caching, circuit breakers, CAP theorem, sharding, event-driven architecture - mapped to real implementations in Nuxt and Nitro.
Frontend internals: 90 concepts
Hydration, fiber, QUIC, CRDTs, compositing, INP - from Vue perspective