Back FoxCode

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.

·11 min read

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:

server/api/auth/register.post.ts
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)
  },
})

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 },
})

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:

server/api/products.get.ts
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:

nuxt.config.ts
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:

server/lib/cached-queries.ts
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:

LayerToolLatencyWhen to use
MemoryNitro in-process<1msTiny, frequently-read, per-instance
RedisNitro + Redis driver~1msShared across instances, invalidatable
CDNCloudflare/Vercel cache<30ms globallyPublic, rarely-changing responses
BrowserCache-Control + ETag0ms on hitStatic 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!
}

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:

server/middleware/rate-limit.ts
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',
      },
    },
  },
})

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
})

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.

Interactive demo - feature flags
Flag dashboard
new-checkoutStep-based checkout flow
beta-badgeShow BETA badge on Blog nav
dark-uiDark card variant
composables/useFlag.ts
useFlag('new-checkout')
App preview live
Checkout
Product name
$49.99

How these five things connect

Each pattern answers one failure question:

PatternFailure question it answers
QueueWhat if the downstream service is slow or down?
CacheWhat if the database can't handle this much read traffic?
RetryWhat if this API call fails transiently?
Rate limitWhat if someone abuses this endpoint?
Feature flagsWhat 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