Inline, then after()
The first two rungs of background work in Next.js, inline await and Vercel's after(), and when each one breaks.
A user clicks “Send invitation.” Your inviteMember Server Action inserts a row in org_invitations, sends the email, and returns: three pieces of work behind one click, each needing a decision.
In “After the write” you sent createInvoice’s email after the transaction committed, and flagged the gap: if the process dies between the commit and the email, the email never fires and nobody notices. The durable fix is a background job, deferred to this chapter.
The decision is the same for each piece. Does it belong inside the request-response cycle, blocking the user until it finishes? After the response, once the user already has their answer? Or off the request path entirely? Get it wrong in either direction and you pay: defer too eagerly and you report success before the work happens; defer too little and the user waits on a spinner for work they never needed to see. This lesson covers the bottom two rungs of the ladder, a plain await and then after(), the moment each one breaks, and the discipline to climb no higher than the work demands.
The default is a plain await
Section titled “The default is a plain await”Here’s inviteMember in the shape you’d actually ship: the five-seam Server Action you already know, with the email send after the transaction commits.
'use server';
export async function inviteMember(formData: FormData) { const parsed = inviteMemberSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the email address and try again.'); } const { orgId } = await requireOrgUser('admin');
const invite = await db.transaction(async (tx) => { const [row] = await tx .insert(orgInvitations) .values({ orgId, email: parsed.data.email, role: parsed.data.role }) .returning(); await logAudit(tx, { action: 'invitation.sent', subjectType: 'invitation', subjectId: row.id, payload: { email: row.email } }); return row; });
await sendInvitationEmail(invite);
revalidatePath(`/org/${orgId}/members`); return ok(invite);}Parse first, before anything touches a database or a session. safeParse over Object.fromEntries(formData) is the entry seam from “Validate on the server.” A bad email returns an err Result and the action stops here.
'use server';
export async function inviteMember(formData: FormData) { const parsed = inviteMemberSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the email address and try again.'); } const { orgId } = await requireOrgUser('admin');
const invite = await db.transaction(async (tx) => { const [row] = await tx .insert(orgInvitations) .values({ orgId, email: parsed.data.email, role: parsed.data.role }) .returning(); await logAudit(tx, { action: 'invitation.sent', subjectType: 'invitation', subjectId: row.id, payload: { email: row.email } }); return row; });
await sendInvitationEmail(invite);
revalidatePath(`/org/${orgId}/members`); return ok(invite);}Then authorize. requireOrgUser('admin') lifts the session, the org, and the role check out of the body, so only an admin gets past this line.
'use server';
export async function inviteMember(formData: FormData) { const parsed = inviteMemberSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the email address and try again.'); } const { orgId } = await requireOrgUser('admin');
const invite = await db.transaction(async (tx) => { const [row] = await tx .insert(orgInvitations) .values({ orgId, email: parsed.data.email, role: parsed.data.role }) .returning(); await logAudit(tx, { action: 'invitation.sent', subjectType: 'invitation', subjectId: row.id, payload: { email: row.email } }); return row; });
await sendInvitationEmail(invite);
revalidatePath(`/org/${orgId}/members`); return ok(invite);}The transaction is the heart of the action. The org_invitations row and the logAudit row commit together or not at all, the pattern from the RBAC chapter. An invitation with no audit trail, or an audit row for an invitation that rolled back, is a record that doesn’t match reality.
'use server';
export async function inviteMember(formData: FormData) { const parsed = inviteMemberSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the email address and try again.'); } const { orgId } = await requireOrgUser('admin');
const invite = await db.transaction(async (tx) => { const [row] = await tx .insert(orgInvitations) .values({ orgId, email: parsed.data.email, role: parsed.data.role }) .returning(); await logAudit(tx, { action: 'invitation.sent', subjectType: 'invitation', subjectId: row.id, payload: { email: row.email } }); return row; });
await sendInvitationEmail(invite);
revalidatePath(`/org/${orgId}/members`); return ok(invite);}The email send sits after the commit, never inside the transaction. That’s the rule from “After the write”: an await on an external service inside db.transaction holds a connection open across a network call and starves the pool. The row is committed, so now we tell the user.
'use server';
export async function inviteMember(formData: FormData) { const parsed = inviteMemberSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the email address and try again.'); } const { orgId } = await requireOrgUser('admin');
const invite = await db.transaction(async (tx) => { const [row] = await tx .insert(orgInvitations) .values({ orgId, email: parsed.data.email, role: parsed.data.role }) .returning(); await logAudit(tx, { action: 'invitation.sent', subjectType: 'invitation', subjectId: row.id, payload: { email: row.email } }); return row; });
await sendInvitationEmail(invite);
revalidatePath(`/org/${orgId}/members`); return ok(invite);}revalidatePath refreshes the members list, then the action returns one ok(invite). Everything the caller needs, success or the exact failure, comes back through that single value.
Notice what this shape doesn’t have. No “queued for sending” status to reconcile later, no dispatcher to debug, no separate worker to deploy and monitor, no second place where work fails quietly. The whole operation lives in one function, and its error story is the action’s own Result: if the email send throws, the action throws, the user sees a real failure, and you get one stack trace in one place. That is tier 0, the most observable, most debuggable shape you can write, and most of the actions you write should look exactly like it. Reach for it first.
Now look at that email send. It’s awaited, so it blocks the response: the user waits the ~200ms Resend takes to accept the message before they see “invitation sent.” That’s correct here. The user wants to know the invite went out, so blocking is the feature. The rest of the lesson is about work where the user doesn’t need to wait.
Four thresholds that pull work off inline
Section titled “Four thresholds that pull work off inline”Inline await is the default, not the only option. Four conditions pull a piece of work off the blocking path, and each shows up in production.
1. The work is slow enough to feel, and the user won’t see its result. Resend accepting your email at a p99 of ~400ms is fine to block on, because the user wants that result. But if the same action also syncs the contact to a third-party CRM that takes five seconds on a bad day, the user watches a spinner for a result they will never look at. The test is necessity, not speed: slow work whose result the user won’t see shouldn’t block.
2. The work might not fit inside the function’s time limit. Each request runs as its own Vercel function with a hard wall-clock cap, measured in minutes (roughly five on Hobby, thirteen on Pro), and every second up to it is charged to the user’s request. Work that can run long, like looping over thousands of rows or waiting on a slow batch API, can’t live on the request path: blow the budget and the user’s response dies with it.
3. The work has to keep going after the user closes the tab. Once your response ships and the browser disconnects, anything still bound to that request can be cut off. Work that must finish but gives the user no reason to wait has to outlive their attention. This is the gap after() was built to fill, and, as you’ll see, the one it fails to fill durably.
4. The work has to survive a failure and be retried. A Server Action runs and returns exactly once. If a downstream service is flaky and the call fails, inline await gives you one attempt and then the request is gone. Anything that must retry a transient failure needs somewhere to live and re-run from that outlives the request: a real job system.
Here is the hinge the lesson turns on. Thresholds 1 through 3 push work off the blocking path but keep it on the same invocation, the band after() covers and the one you’ll meet next. Threshold 4, surviving a crash and retrying, blows past after() into a different tier of tooling. The same word, “background,” covers two different problems; keep them apart and most of this chapter falls into place.
Now pin the threshold in this one.
Your checkout action charges the card inline — the user must see that succeed — then fires a request to a partner’s fulfilment API. That endpoint is fast, but it occasionally returns a 503, and a dropped order is a refund and an angry email; it has to keep being attempted until it goes through. Which fact about the fulfilment call tells you it can’t live inline and won’t be rescued by after() either?
503 has to be re-run until it lands.after() was built for. What neither tier 0 nor 0.5 gives you is a second attempt: re-running a transient 503 needs state that survives the request. That is threshold 4, durability and retry — the one neither inline nor after() can meet, so it points you past both to a real job system.after() runs your code after the response
Section titled “after() runs your code after the response”Thresholds 1 through 3 share a shape: the work isn’t on the user’s critical path, but it has to run on this invocation. Next.js has a primitive built for it.
import { after } from 'next/server';after(() => { … }) schedules a callback to run after the response has been sent, inside the same serverless invocation. It works in Server Components, Server Actions, Route Handlers, and your proxy.ts.
The rule worth memorizing: after() is for “the user does not need to see this happen, but it must happen on this same invocation.” Analytics events. Structured logs that depend on the response you just rendered. Warming a cache for a page the user is about to hit.
Back to inviteMember. Say product wants an analytics event on every invite, a trackEvent('invitation_sent', …) call to PostHog. The naive place is inline, right after the email send; the better place is after(), run once the action has already returned.
const invite = await db.transaction(async (tx) => { /* insert + audit */ });await sendInvitationEmail(invite);
await trackEvent('invitation_sent', { orgId, role: invite.role });
revalidatePath(`/org/${orgId}/members`);return ok(invite);The response waits on analytics. A slow or down PostHog endpoint delays the user’s success message, for an event they will never see. And if trackEvent throws, it throws inside the action, after the row is committed, so you either swallow it or fail an invite that already succeeded.
const invite = await db.transaction(async (tx) => { /* insert + audit */ });await sendInvitationEmail(invite);
revalidatePath(`/org/${orgId}/members`);
after(() => trackEvent('invitation_sent', { orgId, role: invite.role }));return ok(invite);The response ships immediately; analytics fires after. The user sees success the moment the invite is committed and emailed. The call runs on the same invocation, after the response, so a flaky PostHog can’t slow the user down or roll back the transaction.
Nothing changed about the analytics event, same call, same arguments. Only when it runs relative to the response did, and that one move took a non-essential dependency off the user’s critical path.
waitUntil keeps the function alive past the response
Section titled “waitUntil keeps the function alive past the response”If the response has already shipped, how is your callback still running?
The answer is a platform primitive called waitUntil. When you call after(), Next.js registers your callback with waitUntil, which tells the platform to keep the box warm and run the callback until it finishes or hits the function’s maxDuration wall.
after() callback runs
waitUntil keeps the box alive
maxDuration hard wall — same for the whole invocation after() buys you time past the response, not past the wall: the callback ends at the same maxDuration line that bounds the whole invocation.
On the rare platform with no waitUntil equivalent, after() degrades gracefully: it runs your callback before the response, or becomes a no-op, so you degrade loudly, not silently. On Vercel and on self-hosted Node, you get the tail.
after() is not a job queue
Section titled “after() is not a job queue”The most common background-work mistake is reaching for after() when the work needed a real job system. Everything after() is not falls straight out of a mechanic you’ve seen:
- It runs your callback once. There is no second attempt.
- It runs in the same invocation, not a separate process.
- It is bounded by the same
maxDurationas the request. - If that invocation crashes or times out, the callback is lost silently — the response already shipped, so no error reaches anyone.
So when is it safe? The rule is blunt: after() is acceptable when losing the work once in a thousand times is acceptable. A dropped analytics event nobody notices, a cache warm that costs the next request one cold read — that is the whole acceptable band. The invitation email, a payment side effect, anything a user or auditor will later ask “did that actually happen?” about, is threshold-4 work: it must survive a crash and retry, and it belongs on a tier after() doesn’t reach.
after() solves “don’t block the response,” thresholds 1 through 3. It does nothing for “survive a crash and retry,” threshold 4. Both feel like background work, which is how an invitation email ends up in a fire-and-forget callback that vanishes the one time the box gets recycled.
Sort these into where each belongs, and watch for the trap.
Drop each piece of work where it belongs. One item is a trap — it belongs in neither of the first two buckets. Drag each item into the bucket it belongs to, then press Check.
The trap is the audit-log row. Seeing a side effect, a junior who just learned after() defers it — but this row isn’t deferrable-but-risky like the email, it’s not deferrable at all. Recall step 3 of inviteMember: the audit row commits atomically with the invitation, both or neither. Push it into after() and you break that atomicity, leaving an invitation with no audit trail — the exact failure the atomic write existed to prevent. Three needs, three homes: the audit row stays in the transaction, the email needs a durable job, the analytics event is fine in after().
Reading request data inside after()
Section titled “Reading request data inside after()”Inside Route Handlers and Server Actions, cookies() and headers() still work inside the after() callback, because the request context is still reachable. Your inviteMember action is a Server Action, so you’re safe.
Inside Server Components, they throw. Partial Prerendering needs to know at render time which parts of the tree read request data, but after() runs past React’s render lifecycle, so by the time your callback fires Next.js refuses the read.
The fix is one line: read the value before the after() call, while you’re still rendering, and close over it.
after(async () => { const ua = (await headers()).get('user-agent'); await logVisit({ ua });});Reading request data inside the callback throws. after() runs past the render lifecycle, so headers() and cookies() are no longer readable. This line throws at runtime.
const ua = (await headers()).get('user-agent');after(async () => { await logVisit({ ua });});Read it during the render, then close over the value. headers() runs in the component body, where it’s valid; the callback just uses the captured ua.
Decoupling a side effect from the mutation
Section titled “Decoupling a side effect from the mutation”The strongest reason to reach for after() isn’t latency. It’s isolation. Structured access logging, cache warming, an analytics event: each is a non-essential side effect that should not be able to break the mutation it rides along with.
Put that side effect in the action’s main path and you’ve coupled its failure to your mutation’s success. A flaky analytics endpoint can now fail an invitation that already succeeded. after() decouples them: the write commits, the user gets their answer, and the side effect lives or dies on its own. That isolation, not the saved latency, is the senior reason to reach for after().
Errors in after() must be caught, or they vanish
Section titled “Errors in after() must be caught, or they vanish”That same isolation cuts the other way.
An error thrown inside an after() callback does not propagate to the user. It can’t: the response already shipped, so there’s no request left to fail. The throw surfaces nowhere and disappears. Your analytics could have stopped firing three weeks ago and you’d have no idea. So remember the rule: after() is not fire-and-forget. It is fire-and-log.
Wrap the callback body in try/catch and log every failure through your structured logger. Copy this shape, not the bare callback:
after(async () => { try { await trackEvent('invitation_sent', { orgId, role: invite.role }); } catch (err) { logger.error({ err }, 'after: analytics failed'); }});Don’t defer work the user is waiting on
Section titled “Don’t defer work the user is waiting on”One misuse of after() is worse than the rest because it doesn’t crash: it tells the user something happened when it didn’t, by deferring work the user does need to see.
Picture “fixing” the slowness of inviteMember by moving sendInvitationEmail into after(). The action returns instantly, the UI flashes “invitation sent,” then the email throws in the callback, where there’s no failure path and no retry. The user was already told it worked.
const invite = await db.transaction(async (tx) => { /* insert + audit */ });
revalidatePath(`/org/${orgId}/members`);after(() => sendInvitationEmail(invite));return ok(invite);The user is told “sent” before the email sends. The action returns success the instant the row commits. If the deferred send throws, the invitee never gets the email, the inviter never finds out, and the success message was already a lie.
const invite = await db.transaction(async (tx) => { /* insert + audit */ });
await sendInvitationEmail(invite);
revalidatePath(`/org/${orgId}/members`);return ok(invite);The success message is true. Awaiting inline returns ok only once Resend has accepted the message. The ~200ms the user waits buys a success state that means what it says. The send can still fail and need a retry, but it must never be silently deferred behind a success message.
Defer what the user doesn’t need to see, and never defer what they do. If the success message claims something happened, that something had better have happened first.
The smaller failure modes follow from the same model:
- Work that exceeds
maxDurationis silently truncated; the tail runs on the request’s clock. - A slow action’s essential latency is the part the user waits on;
after()only moves non-essential work, so hiding it there fixes nothing. - Retries don’t exist: one shot, and if it fails it’s gone.
cookies()insideafter()is a runtime error in a Server Component; read the value outside and close over it.
The decision order: visibility first, durability second
Section titled “The decision order: visibility first, durability second”Here is the order a senior engineer walks to place a piece of work on the ladder.
The default, and the right answer most of the time. Do the work in the action body and return the Result once it’s done. Most observable, most debuggable, one place to fail.
Same invocation, but past the response. The user already has their answer, and the work runs on the way out, bounded by the same maxDuration. Wrap it in try/catch and log: fire-and-log, never fire-and-forget.
Off the invocation entirely. This is the durability, retry, and schedule tier the rest of the chapter builds. The next lesson takes the first step off with scheduled jobs, and durable retries come later with a real job runner.
You ask about visibility first, and only when the answer is no do you ask about durability and time budget. “Reach for a job queue” is the last leaf you can land on, never the first reflex: juniors start at “I need a background job,” you start at “can this just be an await?” and climb only when a named threshold forces you up.
This is the through-line of the chapter: code stays at the lowest tier that meets the durability, latency, and time-budget requirement — the lowest one that works, not the fanciest you can justify.
The next lesson takes the first step off the request with Vercel Cron, for work that runs on a schedule rather than in response to a click.
External resources
Section titled “External resources”The full API reference for after(), including the request-data rules per render context.
The platform primitive after() builds on. It extends a function's life past the response, bound by the same timeout.
Why this tier has no retries and isn't a job queue, the exact threshold-4 boundary this lesson draws.