Next.js after() Function Not Running in Production Fix

Next.js after() callback fires in dev but never runs in production on Vercel? Fix the cache directive, runtime, and proxy interaction with working code.
Next.jsReactVercel
May 7, 20266 min read1045 words

The Problem

I shipped a small analytics hook last week using after() from next/server: log the request, push a row to PostHog, fire-and-forget. Worked fine in next dev, worked fine in next start locally, and the moment it hit Vercel preview the callback never ran. No error, no log line, nothing in the function logs. The page rendered, the user got a response, and the after() body was simply skipped.

If you upgraded to Next.js 16.2 and your after() callbacks fire in development but not in production, silently, with zero error in the build or the runtime logs, you are looking at the same combination of issues I hit on this client project. It is fixable in three places and you do not need to roll back.

Why It Happens

after() schedules work to run after the response has been sent to the client, inside the same Vercel function invocation. In Next.js 16.2 the implementation moved from the unstable_after import to the stable next/server export, and the runtime was tightened in three ways that break older patterns:

  1. The "use cache" directive cancels after(). If a Server Component above the after() call is wrapped in "use cache", the cached response is replayed on hit and the after() callback was never serialised into the cache entry. So on a cache miss it runs, on a cache hit it does not, which on a warm production deployment looks like "it never runs". This is the case I see most often.
  2. Edge Runtime drops after() past the response. If your route exports export const runtime = "edge", the platform terminates the isolate as soon as the response stream closes. Vercel documents this for the Edge Runtime: there is no post-response execution window, so after() runs synchronously inside the response or not at all.
  3. proxy.ts (formerly middleware) wraps the response and disconnects the context. A proxy.ts that calls NextResponse.next() and then mutates headers is fine, but if it returns a fresh NextResponse.json(...) or rewrites to a different route, the after() context attached to the original request never gets resumed. The callback is queued against a request lifecycle that already ended.

The painful one is the cache directive case, because it works the first time you load the page in preview and then silently fails on every subsequent visitor.

The Fix

Step 1: Move after() out of cached components. This is non-negotiable. after() belongs in a route handler, a server action, or an uncached Server Component. Here is the pattern I use now:

// app/products/[slug]/page.tsx
import { after } from "next/server";
import { logProductView } from "@/lib/analytics";
import { ProductDetail } from "./product-detail";

// NOTE: no "use cache" here, the cached part is the child below.
export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  after(async () => {
    await logProductView(slug);
  });

  return <ProductDetail slug={slug} />;
}
// app/products/[slug]/product-detail.tsx
"use cache";

import { cacheLife } from "next/cache";
import { getProduct } from "@/lib/products";

export async function ProductDetail({ slug }: { slug: string }) {
  cacheLife("hours");
  const product = await getProduct(slug);
  return <article>{/* ... */}</article>;
}

The page itself stays uncached and dynamic, the heavy data fetch is the "use cache" boundary. after() lives at the dynamic level and runs on every request.

Step 2: Switch off Edge Runtime for routes that need after(). The Node.js runtime is the only one with a real post-response execution window on Vercel:

// app/api/track/route.ts
import { after } from "next/server";

export const runtime = "nodejs"; // not "edge"

export async function POST(req: Request) {
  const body = await req.json();

  after(async () => {
    await fetch("https://posthog.example/api/event", {
      method: "POST",
      body: JSON.stringify(body),
    });
  });

  return Response.json({ ok: true });
}

If you absolutely need Edge for latency, do the work inline before returning the response. Edge cannot host fire-and-forget callbacks reliably, and trying to fake it with waitUntil() from the platform context behaves differently per host.

Step 3: Stop returning new responses from proxy.ts. If you must mutate the response, mutate it on the existing one. This preserves the request context that after() relies on:

// proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(req: NextRequest) {
  const res = NextResponse.next();
  res.headers.set("x-request-id", crypto.randomUUID());
  return res;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

If you currently have return NextResponse.json(...) or return NextResponse.rewrite(newURL) in the same proxy that touches after()-using routes, exclude those routes from the matcher or move the rewrite to a route handler.

Step 4: Verify it actually ran. Trust nothing until the function logs say so. Add a sync log before the after() and an async one inside it:

after(async () => {
  console.log("[after] start");
  try {
    await logProductView(slug);
    console.log("[after] done");
  } catch (err) {
    console.error("[after] failed", err);
  }
});

If you see [after] start but never [after] done, the function timed out before the callback finished. Bump maxDuration on the route or move the work to a background job. The Next.js after() reference covers the lifecycle in detail.

The Lesson

after() runs after the response, but only if the runtime, the cache layer, and the proxy all keep the request context alive. In production that means: no "use cache" on the same component, no Edge Runtime, no proxy returning fresh responses, and a long-enough maxDuration to actually finish the work. Get those four things right and after() is the cleanest way to do post-response work in Next.js 16.

If your production is silently dropping analytics, webhooks, or revalidation calls because of a misplaced after(), this is the kind of Next.js triage I do, see my services. For a related upgrade gotcha I covered, look at Next.js 16 cookies async error in route handlers.

Back to blogStart a project