Fullstack with Nitro

Files, realtime and background work

Uploads through the server or with presigned URLs, validation and processing, serving files safely; WebSockets and server-sent events from the server's point of view and how they scale; Nitro tasks and scheduled tasks, queues, idempotency, retries and the outbox pattern; and post-response work with waitUntil.

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.

Know

  • Uploads through the server: 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.
  • Presigned URLs for large files: the client asks your API for a presigned 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.
  • Serving files: private files through a handler that authorises then streams (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.
  • Realtime from the server: 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.
  • Nitro tasks: 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.
  • Queues for reliability: a task started from a request is best-effort; for work that must happen (emails, invoices, webhooks out) use a queue with retries, backoff and a dead-letter queue: Cloudflare Queues, BullMQ on Redis, pg-boss on Postgres, or a managed workflow service (Inngest, Trigger.dev). The outbox pattern writes the job in the same transaction as the business change, and a worker drains it, so nothing is lost between "order saved" and "email sent".
  • Idempotency everywhere: jobs and webhooks carry an idempotency key and handlers check it, because retries and at-least-once delivery will run things twice; Idempotency-Key headers for client POSTs that create resources.
  • Post-response work: 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.
  • Long-running work: never in a request handler on serverless (time limits) and never blocking the event loop on Node; chunk it, move it to a worker thread or a task, and report progress over SSE or by polling a job record.
  • Observability for jobs: job records with status, attempts and last error, alerts on dead letters and on schedule misses, and a way to re-run.

How it works

Presigned upload with server-side verification, and small-file upload through the server:

server/api/uploads/presign.post.ts
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 }
})
server/api/uploads/complete.post.ts
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:

nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    experimental: { tasks: true, websocket: true },
    scheduledTasks: { '0 3 * * *': ['sessions:cleanup'], '*/10 * * * *': ['outbox:drain'] },
  },
})
server/tasks/images/process.ts
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' }
  },
})
server/tasks/outbox/drain.ts
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:

server/utils/orders.ts (excerpt)
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
  })
}
server/api/orders.post.ts (excerpt)
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)
})
Gotcha· Sending the email from the request handler

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.

Gotcha· Scheduled tasks on every instance

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.

Verify before the interview:

Nitro 2's experimental.tasks and scheduledTasks options, which presets support scheduling, and the event.waitUntil availability per preset.

docs ↗

Exercise

Exercise
  • Implement presigned uploads against a local MinIO or R2 bucket; upload a 50 MB file and confirm the Nitro process memory does not grow.
  • Create the outbox table, the drain task on a 10-second schedule, and a handler that fails twice before succeeding; watch the backoff and the final delivery.
  • Run two node-server instances with the cleanup schedule and prove with logs that only one executes it after adding SKIP LOCKED claiming.

Be able to say

Be able to say· How would you handle image uploads in a Nuxt app?

"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."

Be able to say· How do you run background and scheduled work in Nuxt?

"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."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.