Inline, then after()
The first rungs of background work in Next.js, a plain inline await and Vercel's after(), and the thresholds that tell you when to climb past them to a durable job.
A user clicks “Send invitation.” Your inviteMember Server Action wakes up, inserts a row in org_invitations, sends the email, and returns. That is three pieces of work behind one click, and you have a decision to make about each one.
Back when you wrote createInvoice in “After the write,” you met a question and walked past it. You sent the external call after the transaction committed, and the lesson admitted that wasn’t airtight: if the process dies between the commit and the email, the email never fires, and nobody finds out. The lesson named the durable fix, a background job, and said that was a later chapter. This is that chapter, and this is where we make good on the promise.
Before you reach for anything labeled “background job,” though, there’s a more basic question to answer for each piece of work. Which of these belongs inside the request-response cycle, blocking the user until it’s done? Which belongs after the response, once the user already has their answer? And which has no business on the request path at all? Getting that ordering wrong costs you in either direction. Defer too eagerly and you tell the user something happened when it hasn’t. Defer too little and the user stares at a spinner, waiting on work they never needed to see. This lesson teaches the bottom two rungs of the ladder, a plain await and then after(), and the precise moments each one breaks. The most useful thing you’ll take away is the discipline to not climb 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. It’s the five-seam Server Action you already know: parse the form data, authorize, mutate, revalidate, return, with the email send dropped in 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 else touches a database or a session. safeParse over Object.fromEntries(formData) is the entry seam you wrote for every action in “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 are written together: both commit or neither does. You wrote logAudit(tx, …) inside the work’s transaction back in the RBAC chapter for exactly this reason. An invitation that happened with no audit trail, or an audit row for an invitation that rolled back, is a record that doesn’t match reality. They are atomic on purpose.
'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 database connection open across a network call and starves the pool. The row is committed, so now we tell the user about it.
'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 a single ok(invite). One return, one Result. Everything the caller needs to know, success or the exact failure, comes back through that one value.
Notice what this shape doesn’t have. There’s no “invitation queued for sending” status to reconcile later. There’s no dispatcher to debug, no separate worker process to deploy and monitor, no second place where the work can fail quietly. The whole operation lives in one function, and its entire error story is the action’s own Result. If the email send throws, the action throws, the user sees a real failure, and you have one stack trace in one place.
That is the argument for tier 0, and it’s a strong one. Work that runs inline, synchronously from the user’s point of view, is the most observable and most debuggable shape you can write. Reach for this shape first, before anything cleverer, because most of the actions you write should look exactly like this.
Now look closely at that email send. It’s awaited, and it blocks the response: the user waits the ~200 milliseconds it takes Resend to accept the message before they see “invitation sent.” That is the right call here, because the user genuinely wants to know the invite went out. Blocking on it is the feature, not a bug. Hold onto that distinction, that the user needs to see this happen, because the rest of the lesson is about the work where that isn’t true.
Four thresholds that break inline
Section titled “Four thresholds that break inline”Inline await is the default, but it isn’t the only answer. There are exactly four conditions that take a piece of work off the blocking path. Each one is observable in production, and each one shows up as a specific kind of pain, so learn to recognize them by their symptoms.
1. The work is slow enough that the user feels it, and they don’t need to. Resend accepting your email at a p99 of ~400ms is fine to block on, because the user wants that result. But suppose the same action also pings a third-party CRM to sync the new contact, and that endpoint takes five seconds on a bad day. Now the user sits on a spinner for five seconds, waiting on work whose result they will never look at. The line is about necessity, not raw speed: anything the user must see the result of can block, and anything they won’t shouldn’t, once it’s slow enough to feel.
2. The work might not fit inside the function’s time limit. On Vercel, every request runs as its own function invocation with a hard wall-clock cap. It’s measured in minutes, not seconds: roughly five on the Hobby tier and thirteen on Pro, so you have real headroom. But it is a wall. When the function hits it, the function is killed mid-execution, and every second up to that wall is charged to the user’s request. Work that might run long, like looping over thousands of rows or waiting on a slow batch API, cannot live on the request path, because if it blows the budget it takes the user’s response down with it. The exact numbers shift with your plan and config, and we’ll get into that depth much later. Here, all you need is that a wall exists and the time before it belongs to the user.
3. The work has to keep going even if the user closes the tab. Fire-and-forget work by definition can’t depend on the user sticking around. The moment your response ships and the browser disconnects, anything still bound to that request is at risk of being cut off. If the work must finish but the user has no reason to wait for it, you need it to outlive their attention. This is the gap after() was built to fill, and, as you’ll see in a moment, the gap 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 a single attempt and then it’s over: there’s no second try, because the request is gone. Anything that needs to retry a transient failure needs somewhere to live and re-run from, somewhere that outlives the request. This is the one threshold inline await cannot meet at all, and, to get ahead of myself, neither can after(). Mark this one. It’s the sharpest point in the lesson and the bridge to everything after it.
Here’s the synthesis, and it’s the hinge the whole lesson turns on. Thresholds 1 through 3 push work off the blocking path but keep it on the same invocation, which is the band after() covers and the one you’ll meet next. Threshold 4, surviving a crash and retrying, blows clean past after() into a different tier of tooling entirely. The same word, “background,” covers two completely different problems. Keep them apart in your head and most of this chapter falls into place.
Before moving on, try pinning 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 is the one that 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. The thing none of tiers 0 or 0.5 can give you is a second attempt: re-running a transient 503 needs state that survives the request, which is threshold 4 — durability and retry. That’s the one threshold inline can’t meet and after() can’t either, 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”For thresholds 1 through 3, which cover slow non-essential work, work that mustn’t be charged to the user’s clock, and work that has to outlive the tab, Next.js gives you a primitive built exactly for the job.
import { after } from 'next/server';after(() => { … }) schedules a callback to run after the response has been sent, inside the same serverless invocation. That’s the whole contract. The user gets their bytes, and then your callback runs. It graduated out of experimental a while back and has been stable across several Next.js major versions now, so it’s a safe default, and it works in Server Components, Server Actions, Route Handlers, and your proxy.ts.
The rule worth memorizing is this: 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 you know the user is about to hit. Work that matters, but not work the user is waiting on.
Back to inviteMember. Say product wants an analytics event on every invite, a trackEvent('invitation_sent', …) call to PostHog. Where does it go? The naive answer is “right after the email send,” inline. The better answer is after(), run once the action has already returned. Compare the two.
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 now delays the user’s success message, all for an event they will never see. And if trackEvent throws here, it throws inside the action, after the row is already committed, so you either swallow it awkwardly or fail an invite that actually 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, and analytics fires after. The user sees success the moment the invite is committed and emailed. The analytics call runs on the same invocation, after the response, so a flaky PostHog can’t slow the user down and can’t roll back the database transaction. Same work, off the critical path.
The contrast is the whole point. Nothing about the analytics event changed: same call, same arguments. What changed is when it runs relative to the response, and that one move took a non-essential dependency off the user’s critical path.
How after() stays alive: the waitUntil tail
Section titled “How after() stays alive: the waitUntil tail”If the response has already been sent, how is your callback still running? It feels like it should be too late.
The answer is a platform primitive called waitUntil . When you call after(), Next.js registers your callback with waitUntil, which tells the platform not to tear the function down yet because there’s still work to do. The response bytes have shipped to the user, but the box stays warm and keeps running your callback until it finishes or the function hits its maxDuration wall. The user has long since seen their result, and the machine is still quietly working.
The picture is worth more than the words here, because “same invocation, but past the response, bounded by the same wall” is the one genuinely slippery thing about after().
after() callback runs
waitUntil keeps the box alive
maxDuration hard wall — same for the whole invocation Read it left to right. The solid segment is the action body, the transaction and the email, and the user waits through all of it. The bold marker is the moment the response ships and the user sees success. Everything to the right of that marker is the after() callback: same invocation, but past the response, so the user isn’t waiting on any of it. Notice where it ends, at the same dashed maxDuration wall that bounds the whole function. after() buys you time past the response, but it does not buy you time past the wall.
One portability note, so nothing surprises you. This works the same when you self-host on Node. On the rare platform with no waitUntil equivalent, after() degrades gracefully: it runs your callback before the response instead, or becomes a no-op, so you’re never silently broken, just not deferred. On Vercel and on Node, you get the tail.
after() is not a job queue
Section titled “after() is not a job queue”Read this section twice. The single most common background-work mistake in 2026 is reaching for after() when the work actually needed a real job system. The boundary is easy to state and easy to forget under deadline pressure, so let’s make it unmissable.
Here is everything after() is not, and each point falls straight out of a mechanic you’ve already seen:
- It runs your callback once. There is no second attempt.
- It runs in the same invocation, on the same box handling the request. It is not a separate process.
- It is bounded by the same
maxDurationwall as the request. Long work gets cut off at the same line. - If that invocation times out or crashes, the callback is lost, silently. No error reaches the user, because the response already shipped.
No retries. No durability. No visibility from any other process. after() is not a queue, not a worker, not a scheduler. It runs your code on the way out the door.
So when is it actually safe? Here’s the decision rule, and it’s blunt on purpose: after() is acceptable when losing the work once in a thousand times is acceptable. An analytics event dropped on a rare function timeout is fine, because nobody will ever notice. A cache warm that didn’t happen is harmless, because the next request pays for one cold read and moves on. That is the whole acceptable band. What’s not acceptable is the invitation email, a payment side effect, or anything a user or an auditor will later ask “did that actually happen?” about. That work is threshold-4 work: it needs to survive a crash and retry, and it belongs on a tier after() doesn’t reach.
Tie this directly back to the four thresholds, because it’s exactly where juniors get lost. after() solves “don’t block the response,” which is thresholds 1, 2, and 3. It does nothing for “survive a crash and retry,” which is threshold 4. Both feel like “background work,” which is precisely why they get conflated, and conflating them is how you end up trusting an invitation email to a fire-and-forget callback that vanishes the one time the box gets recycled. Separate them now and keep them separate.
Sort these. For each one, decide whether after() is a fine home or whether it needs a durable job that can survive a crash and retry, the kind of tooling the rest of this chapter builds. 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, and it’s the most instructive item on the board. A junior who has just learned after() looks at “write the audit row” and thinks it’s a side effect, so defer it. That’s wrong twice over. The audit row isn’t deferrable-but-risky like the email; it’s not deferrable at all. It was never supposed to leave the transaction. Remember step 3 of inviteMember: the audit row commits atomically with the invitation, both or neither. Push it into after() and you’ve broken that atomicity for a feature you didn’t even need to speed up. You can now have an invitation in the database with no audit trail, which is the exact failure the atomic write existed to prevent. Three different needs get three different homes: the audit row stays in the transaction, the email needs a durable job, and the analytics event is fine in after(). Telling them apart is the skill.
Reading request data inside after()
Section titled “Reading request data inside after()”There’s one concrete gotcha that will throw a runtime error in your own code the first time you hit it, so let’s defuse it now. It’s a single rule with a single fix.
Inside Route Handlers and Server Actions, cookies() and headers() work inside the after() callback. Call them freely, because the request context is still reachable. Your inviteMember action is a Server Action, so you’re in the safe band, and most of your after() calls will be too.
Inside Server Components, they do not work. Calling cookies() or headers() inside an after() callback in a Server Component throws. The reason is Partial Prerendering: Next.js needs to know, at render time, exactly which parts of your component tree read request data, so it can prerender the rest. But after() runs past React’s render lifecycle. By the time your callback fires, the render is done and that bookkeeping window has closed, so Next.js refuses the read rather than hand you stale or undefined values.
The fix is one line: read the value before the after() call, while you’re still inside the render, and close over it.
after(async () => { const ua = (await headers()).get('user-agent'); await logVisit({ ua });});Reading request data inside the callback throws. In a Server Component, after() runs after the render lifecycle, so headers() and cookies() are no longer readable there. 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() is called in the component body, where it’s valid, and the callback just uses the captured ua. Same data, no throw.
The takeaway isn’t to memorize a quirk; it’s to know where each rule applies so you can place your own code. In a Server Action or Route Handler, read request data wherever you like inside after(). The moment you reach for after() in a layout or a page to log something, read the request values first and pass them in.
Where after() earns its weight
Section titled “Where after() earns its weight”With the prohibitions covered, here’s the positive list: the three places you should actively reach for after(), so you have a concrete “yes, here” rather than only a wall of “no.”
- Structured access logging after a checkout. Once the response is out the door, log the user-agent, the request id, and the outcome, the fields you’d want at 3am to reconstruct what happened. It’s pure record-keeping, and the user has no reason to wait for a log line, so it goes after the response and never blocks it.
- Warming a cache for a page the user is about to navigate to. They just completed a step that almost always leads to a particular list view. Warm that list’s cache in
after()so the next click is instant. It’s fire-and-forget by nature: if the warm doesn’t happen, the next request just pays for one cold read. A miss costs milliseconds, not correctness. - Posting an analytics event from a Server Action where the analytics failure must not roll back the database write. This is the strongest case, because it isn’t really about latency. It’s about isolation.
That last one deserves its own beat. Put a non-essential external call inside your action’s main path, inside the try, in the flow that determines the action’s result, and you’ve coupled that call’s failure to your mutation’s success. A flaky analytics endpoint can now fail an invitation that was, by every measure that matters, successful. after() decouples them: the write commits, the user gets their answer, and the soft side effect lives or dies entirely on its own, with no power to undo anything real. That decoupling, beyond merely shaving latency, is why after() exists. It’s the senior reason to reach for it.
Errors in after() must be caught, or they vanish
Section titled “Errors in after() must be caught, or they vanish”The isolation that makes after() valuable cuts the other way too, and the cost is easy to miss.
An error thrown inside an after() callback does not propagate to the user. It can’t: the response already shipped, so there’s no longer a request to fail. An unhandled throw in there doesn’t surface anywhere. It just disappears. Your analytics could have stopped firing three weeks ago and you’d have no idea, because nothing ever told you. The rule to remember: after() is not fire-and-forget. It is fire-and-log.
So wrap the callback body in try/catch and log every failure through your structured logger, the requestId-tagged logger you’ll set up in the observability chapter. Catch it and log it, so that when the work fails you at least know it failed. Copy this shape every time:
after(async () => { try { await trackEvent('invitation_sent', { orgId, role: invite.role }); } catch (err) { logger.error({ err }, 'after: analytics failed'); }});That try/catch is the canonical shape. Copy it, not the bare callback. A bare after(() => track(evt)) is a side effect that gives you no warning when it fails.
The wrong way to use after()
Section titled “The wrong way to use after()”Now the deliberate counter-example. One mistake here is worse than all the others combined, because it doesn’t crash. It tells the user something happened when it didn’t, by deferring the work the user does need to see.
Imagine “fixing” the perceived slowness of inviteMember by moving sendInvitationEmail into after(). The action returns instantly, the UI flashes “invitation sent,” and everyone’s happy, until the email send throws in the callback, where there is no failure path and no retry, and the user was already told it worked. Look at both versions side by side.
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, and the email is deferred. If it throws in the callback, there’s no failure path and no retry, and the success message was already false: the invitee never gets the email and the inviter has no idea. You’ve raced a page reload against an email that may never arrive.
const invite = await db.transaction(async (tx) => { /* insert + audit */ });
await sendInvitationEmail(invite);
revalidatePath(`/org/${orgId}/members`);return ok(invite);The success message is true. The email is awaited inline, so the action only returns ok once Resend has actually accepted the message. The ~200ms the user waits buys them a success state that means what it says. The email can still fail and need a retry, which is the threshold-4, durable-job conversation, but it must never be silently deferred behind a success message.
That’s the lesson’s thesis in one diff: 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 before you sent the message.
The rest of the failure modes are smaller, and each is a one-liner once you’ve got the model. Avoid each of these:
- Don’t put work that exceeds
maxDurationinafter(). It gets silently truncated when the box hits the wall, because the tail runs on the same clock as the request. - Don’t “fix” a slow action by hiding its essential latency in
after(). The action is still slow on its critical path, because the slow part was always the part the user is waiting on.after()only moves non-essential work; it can’t speed up the work that determines the response. - Don’t rely on
after()for retries. There are none. One shot, and if it fails it’s gone. - Don’t call
cookies()insideafter()in a Server Component. It’s a runtime error. Read the value outside and close over it, exactly as you saw.
The ladder, and where this leaves you
Section titled “The ladder, and where this leaves you”Step back and look at the whole shape. You’ve climbed the bottom of a ladder, and there’s a precise order in which a senior engineer asks the questions that place a piece of work on it. Walk through it.
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.
Notice the order the walk forces, because the order is the lesson. Visibility comes first: does the user need to see this? Only if the answer is no do you even ask about durability and time budget. And “reach for a job queue” is the last leaf you land on, never the first reflex. That ordering is the whole senior corrective. Juniors start at “I need a background job” and work backwards; you now start at “can this just be an await?” and only climb when a named threshold forces you to.
So the three tiers, one last time. Tier 0 blocks the response: a plain await, and most of your work lives here, which is correct, not lazy. Tier 0.5 runs on the same invocation, after the response: after(), for the narrow band of work that must happen but the user needn’t wait on, and that can be lost once in a blue moon. Tier 1 and up runs outside the invocation entirely: the durable tooling for everything that must survive a crash, retry, run long, or run on a schedule. The rule that picks among them is the through-line of this entire chapter: code stays at the lowest tier that meets the durability, latency, and time-budget requirement. Not the fanciest tier you can justify, the lowest one that works.
Next comes the first step off the request entirely. In the next lesson you’ll meet Vercel Cron, the default for work that runs on a schedule rather than in response to a click. The heavier rung, the durable retries and multi-step orchestration that threshold 4 demands, comes later in the chapter, once you’ve earned the right to reach for it by knowing exactly when you don’t need to.
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.