after() is a genuinely useful, fairly recent addition to Next.js, and it has a specific, narrow boundary for what belongs inside it. Cross that boundary in either direction, something that should block the response ends up in after(), or something that shouldn't block the response stays out of it, and the mistake doesn't throw an error, it just quietly produces the wrong user experience.
What after() Actually Does
// actions/orders.ts
'use server';
import { after } from 'next/server';
export async function createOrder(formData: FormData) {
const order = await db.orders.create({ /* ... */ });
after(() => {
sendOrderConfirmationEmail(order);
logAnalyticsEvent('order_created', order.id);
});
return { success: true, orderId: order.id };
}
The response, { success: true, orderId: order.id }, gets returned to the user immediately, without waiting for the email or the analytics call inside after() to actually finish. Those run afterward, in the background, on infrastructure that stays alive briefly past the response specifically to let this code complete. This is exactly the right tool for work the user doesn't need to wait on and doesn't need to know the real-time outcome of.
Mistake One: Putting Something User-Dependent Inside after()
// ❌ The user needs this value, but it's being generated after the response already went out
export async function checkout(formData: FormData) {
const order = await db.orders.create({ /* ... */ });
let trackingNumber;
after(async () => {
trackingNumber = await shippingProvider.generateTrackingNumber(order.id);
await db.orders.update(order.id, { trackingNumber });
});
return { success: true, trackingNumber }; // undefined, after() hasn't run yet
}
trackingNumber is undefined at the point the return statement executes, since after()'s callback hasn't run, and won't run, until after this function has already returned and the response has already been sent. Anything the actual response depends on needs to be awaited normally, in the main request path, before the response is constructed, not deferred into after(), which runs too late to affect anything the user is about to receive.
Mistake Two: Putting Something That Can Fail Silently and Matters Inside after()
// ❌ If this fails, the user already got a success response and will never know
export async function createOrder(formData: FormData) {
const order = await db.orders.create({ /* ... */ });
after(async () => {
await chargePaymentMethod(order.paymentMethodId, order.total); // can genuinely fail
});
return { success: true, orderId: order.id }; // already told the user it worked
}
This is the more dangerous version of the same category of mistake. The user receives a success response before the payment charge has even been attempted, since it's deferred into after(). If the charge genuinely fails, the user has already been told their order succeeded, with no mechanism in this flow to inform them otherwise, since after() runs after the response is already gone, there's no remaining connection back to the user to report a failure through. Anything where failure needs to actually change what the user sees or does next cannot live in after(), regardless of how tempting it is to defer for perceived speed.
Mistake Three: The Opposite Problem, Not Using after() for Genuinely Deferrable Work
// ❌ The user waits on work that has nothing to do with their actual result
export async function createPost(formData: FormData) {
const post = await db.posts.create({ /* ... */ });
await sendSlackNotificationToTeam(post); // the user is waiting on an internal Slack ping
await logAnalyticsEvent('post_created', post.id); // and an analytics call
return { success: true, postId: post.id };
}
Here the mistake runs the other direction. A Slack notification to an internal team and an analytics log call have no bearing on what the user actually needs to see, yet both are awaited directly in the main path, meaning the user's response is delayed by however long both of those genuinely unrelated operations take. This is exactly the shape of work after() exists for, moving it there gets the user their actual response faster, with zero change to what they ultimately see.
// ✅ The user gets their response immediately, unrelated work happens after
export async function createPost(formData: FormData) {
const post = await db.posts.create({ /* ... */ });
after(() => {
sendSlackNotificationToTeam(post);
logAnalyticsEvent('post_created', post.id);
});
return { success: true, postId: post.id };
}
The Actual Test for What Belongs in after()
Does the user's response depend on this value or its outcome? If yes, it must be awaited in the main path, not deferred.
Does a failure here need to change what the user sees, or what they should do next? If yes, it must be awaited and handled in the main path, where a failure can still actually affect the response.
If neither is true, does this work genuinely not need to block the response at all? If so, after() is exactly the right place for it, and leaving it in the main path is needlessly slowing down every single response for work the user was never actually waiting on in any meaningful sense.
Logging Failures Inside after() Still Matters
Even for work that genuinely belongs in after(), a failure there shouldn't just vanish silently either, it should at minimum be logged somewhere you'll actually see it, even though the user themselves can no longer be informed through this specific response:
after(async () => {
try {
await sendOrderConfirmationEmail(order);
} catch (error) {
console.error('Confirmation email failed:', order.id, error);
// genuinely logged, even though the user's response already went out
}
});
The Actual Rule
after() is for work the user's response genuinely doesn't depend on, and whose failure doesn't need to change what the user sees or does next. Anything the response actually needs, or anything whose failure should alter what the user is told, belongs in the normal, awaited request path, not deferred. Anything else, logging, non-critical notifications, analytics, genuinely belongs in after(), and leaving it in the main path instead is an unnecessary tax on every response for work the user was never actually waiting on.
I audit every Server Action for exactly this boundary now, across client work and the templates I build at pixelanas.com, and I cover more patterns like this on the blog as I run into them.
If you're using after() anywhere, check both directions, anything inside it the response secretly depends on, and anything still sitting in the main path that has nothing to do with what the user actually needs back. Drop what you find in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)