The work that did not happen
A server action saves an enquiry, returns success, and then sends a notification email. The email never arrives. A route handler responds to a webhook and then writes an audit log. The log is empty. A page render kicks off a cache warm in the background. The cache is cold.
None of these threw an error. On a long-running server they would have worked. On a serverless platform, the function's lifetime ends when the response is sent, and any promise still pending at that moment is abandoned. The code ran up to the return and then the lights went out.
This is the single most common source of "it works locally, not in production" on Vercel and similar platforms, and Next.js has specific tools for it.
after()
after() from next/server schedules work to run after the response has been sent, and tells the platform to keep the function alive until it finishes. It is the right tool for anything the user should not wait for but which must complete.
// app/contact/actions.ts
'use server'
import { after } from 'next/server'
import { saveEnquiry } from '@/lib/db'
import { sendNotification } from '@/lib/mail'
import { track } from '@/lib/analytics-server'
export async function submitEnquiry(formData: FormData) {
const enquiry = await saveEnquiry(formData) // must complete before responding
after(async () => {
await sendNotification(enquiry) // user does not wait for this
await track('enquiry_received', { id: enquiry.id })
})
return { ok: true }
}The response goes out as soon as saveEnquiry resolves. The email and the tracking run after, with the platform holding the function open. If they throw, the error is logged but the user already has their success, which is the correct trade for a notification.
after() works in Server Components, Server Actions, Route Handlers and Middleware. It runs in the same request context, so cookies() and headers() are readable inside it. It is stable since Next.js 15.1.
Rules: anything the response depends on stays before the return. Anything that is a side effect of the request goes in after(). Nothing goes in a bare, un-awaited promise.
waitUntil, for non-Next contexts
Under the hood, after() uses the platform's waitUntil where available. If you are writing an Edge function or a handler outside the Next.js request model, import { waitUntil } from '@vercel/functions' gives you the primitive directly:
import { waitUntil } from '@vercel/functions'
export async function POST(req: Request) {
const body = await req.json()
waitUntil(processWebhook(body))
return new Response('accepted', { status: 202 })
}For webhooks especially, this is the pattern: acknowledge fast so the sender does not retry, process in the background with the function kept alive.
Where after() is not enough
after() extends the function's life to the platform's maximum duration (a few tens of seconds on the default plan, longer on higher tiers). It does not make work durable. If the function is killed by a timeout, a deploy, or a platform incident during the after() work, that work is lost.
For work that must not be lost (charging a card, syncing an order to an ERP, sending a legally required notice), the answer is a queue: write a job to a durable store (a database table, Vercel Queues, Upstash QStash, Inngest, a cron-polled table) inside the request, and have a separate worker process it with retries. after() is for best-effort side effects; a queue is for guaranteed ones.
instrumentation.ts
instrumentation.ts at the project root (or in src/) exports a register() function that Next.js calls once when a server instance starts, before any request. It exists for exactly one category of thing: setting up observability and other process-level hooks that need to exist before your code runs.
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { registerOTel } = await import('@vercel/otel')
registerOTel({ serviceName: 'qasimcode-web' })
}
}
export async function onRequestError(err, request, context) {
// ships every uncaught server error, with route and render context, to your error tracker
await reportError(err, { path: request.path, routeType: context.routeType })
}onRequestError is the hook to know about: it fires for uncaught errors in Server Components, Route Handlers, Server Actions and Middleware, with enough context to find the route. Without it, server errors on Vercel are visible only in the function logs, which nobody reads. With it, they land in Sentry or wherever you send them.
Do not put per-request logic in register(). It runs once per instance, and instances come and go; it is for initialisation only. The NEXT_RUNTIME check matters because the file is loaded in both Node and Edge runtimes and most SDKs support only one.
Other things that quietly do not run
setTimeout and setInterval in server code. The function is gone before the timer fires. Use after() for one-shot delays; use a cron (Vercel Cron Jobs hitting a route) for anything periodic.
Module-level state as a cache. A Map at module scope persists only within one warm instance, and there may be many instances or none. Fine as a micro-optimisation, wrong as a source of truth. Use the data cache (use cache) or an external store.
Streams left unconsumed. A fetch whose body is never read, or a response stream the client disconnected from, can be cancelled with the function. Read what you need before responding.
Fire-and-forget analytics from the server. Every void track(...) without after() is a coin flip. Wrap it.
Long generateStaticParams or ISR revalidation exceeding the function limit fail silently as stale pages. Watch the build and function logs for duration warnings.
A checklist for Vercel deployments
- Every side effect after a response is in
after()or a queue. - Webhooks acknowledge quickly and process in
after()or a queue. instrumentation.tsregisters error reporting viaonRequestError.- No server-side timers; periodic work is a cron route.
- Function duration limits checked against the slowest action and the longest page render.
- Logs and errors ship somewhere with alerts, not just the Vercel dashboard.
Where this sits
The missing-notification bug is one we have chased on more than one inherited Next.js site, and after() is the fix nearly every time. It is in the template for every Next.js project we deliver, alongside instrumentation.ts with onRequestError, because a serverless site with no error reporting is a site whose failures are invisible until a customer describes them.