Three things a request–response handler is bad at: receiving large files, pushing data to clients, and doing slow work. Each has a standard answer in Nitro, and each has a platform dimension (what works on Node does not necessarily work on serverless). Interviewers ask "how would you handle image uploads" or "how do you send the weekly digest" to see whether you know those answers and their limits.
readMultipartFormData(event) gives you parts with name, filename, type and data (buffered), readFormData gives a web FormData; bound the body size at the proxy and the platform, validate by magic bytes, re-encode images (sharp), random file names, store via useStorage('uploads') (S3/R2 driver) or a bucket SDK, never under public/. Fine for small files; buffering large files in memory is not.PUT URL (S3-compatible: R2, S3, MinIO) with content type and size constraints, uploads directly to the bucket, then notifies your API, which verifies the object (head request, size, type) and records it; the server never touches the bytes. Multipart uploads for very large files; resumable uploads (tus) when networks are bad.sendStream) or redirects to a short-lived presigned GET; public files from the bucket/CDN with Content-Disposition and nosniff; images through @nuxt/image with a provider (Cloudflare Images, IPX) so resizing is cached at the edge.createEventStream(event) for SSE (works everywhere a response can stream; Last-Event-ID for resumption); defineWebSocketHandler for WebSockets behind nitro.experimental.websocket on crossws, with peer.subscribe/publish for rooms; the handshake carries cookies so getUserSession(peer) authenticates. Scaling: sockets live on one instance, so multi-instance needs a pub/sub backplane (Redis) or platform primitives (Cloudflare Durable Objects); serverless functions cannot hold sockets at all, only stream SSE within their time limit. Managed services when realtime is not the product.server/tasks/*.ts export defineTask({ meta, run({ payload, context }) }); enable with nitro.experimental.tasks: true; run on demand with runTask('name', { payload }) from a handler or plugin, or on a schedule with nitro.scheduledTasks: { '0 3 * * *': ['cleanup:sessions'] } (cron). In development /_nitro/tasks lists and triggers them. Scheduling support depends on the preset: in-process cron on node-server, Cloudflare Cron Triggers and Vercel Cron on those platforms.Idempotency-Key headers for client POSTs that create resources.event.waitUntil(promise) (Nitro, mapped to the platform's waitUntil on Cloudflare and Vercel) lets a handler respond immediately and finish logging, analytics or a small side effect afterwards; not for work that must not be lost.Presigned upload with server-side verification, and small-file upload through the server:
import { z } from 'zod'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const body = z.object({ contentType: z.enum(['image/png', 'image/jpeg', 'image/webp']), size: z.number().int().max(10 * 1024 * 1024) })
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { contentType, size } = await readValidatedBody(event, body.parse)
const key = `uploads/${user.id}/${crypto.randomUUID()}`
const s3 = new S3Client({ region: 'auto', endpoint: useRuntimeConfig(event).r2Endpoint, credentials: r2Credentials(event) })
const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket: 'app-uploads', Key: key, ContentType: contentType, ContentLength: size }), { expiresIn: 300 })
await useDb().insert(uploads).values({ key, userId: user.id, status: 'pending', contentType, size })
return { url, key }
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { key } = await readValidatedBody(event, z.object({ key: z.string() }).parse)
const record = await useDb().query.uploads.findFirst({ where: (u, { and, eq }) => and(eq(u.key, key), eq(u.userId, user.id)) })
if (!record) throw createError({ status: 404, statusText: 'Not found' })
const head = await headObject(key) // verify the object really exists with the promised type and size
if (!head || head.size !== record.size || head.contentType !== record.contentType) throw createError({ status: 400, statusText: 'Upload mismatch' })
await runTask('images:process', { payload: { key } }) // thumbnails, metadata strip, in the background
return useDb().update(uploads).set({ status: 'uploaded' }).where(eq(uploads.key, key)).returning()
})
Tasks: a scheduled cleanup and an on-demand processor with idempotency:
export default defineNuxtConfig({
nitro: {
experimental: { tasks: true, websocket: true },
scheduledTasks: { '0 3 * * *': ['sessions:cleanup'], '*/10 * * * *': ['outbox:drain'] },
},
})
export default defineTask({
meta: { name: 'images:process', description: 'Generate thumbnails and strip metadata' },
async run({ payload }) {
const key = String(payload.key)
const record = await useDb().query.uploads.findFirst({ where: (u, { eq }) => eq(u.key, key) })
if (!record || record.status === 'processed') return { result: 'skipped' } // idempotent: safe to run twice
const original = await getObject(key)
const thumb = await sharp(original).rotate().resize(320).webp().toBuffer()
await putObject(`${key}.thumb.webp`, thumb, 'image/webp')
await useDb().update(uploads).set({ status: 'processed' }).where(eq(uploads.key, key))
return { result: 'processed' }
},
})
export default defineTask({
meta: { name: 'outbox:drain', description: 'Deliver pending jobs with retries' },
async run() {
const jobs = await claimPendingJobs(useDb(), 50) // SELECT ... FOR UPDATE SKIP LOCKED: safe with several workers
for (const job of jobs) {
try {
await handlers[job.type]!(job.payload) // each handler idempotent by job.id
await markDone(useDb(), job.id)
}
catch (error) {
await markFailed(useDb(), job.id, error, { backoffSeconds: 60 * 2 ** job.attempts, maxAttempts: 8 }) // dead-letter after max
}
}
return { result: jobs.length }
},
})
The outbox written in the same transaction as the business change, and post-response work:
export async function placeOrder(db: Db, input: NewOrder) {
return db.transaction(async (tx) => {
const order = await insertOrder(tx, input)
await tx.insert(outbox).values({ id: crypto.randomUUID(), type: 'email:order-confirmation', payload: { orderId: order.id } }) // same transaction: never lost
return order
})
}
export default defineEventHandler(async (event) => {
const order = await placeOrder(useDb(), input)
event.waitUntil(trackEvent('order.placed', { orderId: order.id })) // respond now; analytics finishes after
setResponseStatus(event, 201)
return toOrderDto(order)
})
await sendEmail() inside POST /api/orders makes checkout as slow and as flaky as the email provider, and a timeout after the database commit leaves an order without a confirmation and no retry. The outbox row goes in the same transaction; a task with retries sends it.
An in-process cron on node-server runs on each replica, so a nightly report goes out three times. Use FOR UPDATE SKIP LOCKED claiming, a distributed lock in Redis, or the platform's single cron trigger (Cloudflare, Vercel) so exactly one worker runs the schedule.
Nitro 2's experimental.tasks and scheduledTasks options, which presets support scheduling, and the event.waitUntil availability per preset.
node-server instances with the cleanup schedule and prove with logs that only one executes it after adding SKIP LOCKED claiming."For anything beyond small files, the browser uploads directly to object storage with a presigned URL: the client asks my API for a URL scoped to the user, a content type and a maximum size, uploads to R2 or S3, and then tells my API the key; the API verifies the object with a head request, records it, and queues a task that re-encodes the image with sharp into thumbnails, which also strips metadata and any embedded payload. The server never buffers the bytes, so memory stays flat and serverless time limits do not matter. Files are named randomly under a per-user prefix, served from a separate origin through @nuxt/image or short-lived signed URLs with nosniff, and small uploads that do go through readMultipartFormData are bounded and validated by magic bytes, never by extension."
"Nitro tasks for the mechanics: defineTask files under server/tasks, run on demand with runTask or on a cron schedule through nitro.scheduledTasks, with platform cron triggers on Cloudflare and Vercel. For work that must not be lost I do not fire tasks from requests directly; the request writes a job into an outbox table in the same transaction as the business change, and a scheduled drain task claims jobs with FOR UPDATE SKIP LOCKED, runs idempotent handlers, retries with exponential backoff and parks failures in a dead-letter state with alerts. Small side effects that may be lost, analytics for instance, use event.waitUntil so the response is not delayed. On multiple instances the claiming makes the schedule run once, and on serverless the platform's cron replaces in-process timers."
Nothing yet. Notes filed here show up on this page and the section overview.
Server caching and performance
The server-side cache stack in Nuxt — defineCachedEventHandler and cachedFunction with their options, storage drivers for multi-instance deployments, route rules, CDN headers, invalidation without tags, request coalescing — and the render and query costs that caching cannot fix — sequential awaits, N+1, cold starts, payload size, streaming and observability.
API design and integration
REST conventions that make an API predictable — resources, methods, status codes, a consistent error shape, pagination, filtering, versioning, idempotency keys, conditional requests — plus OpenAPI from Nitro, when tRPC, oRPC or GraphQL earn their complexity, the backend-for-frontend pattern, proxies, webhooks in and out, API keys and rate limits.