All articles

Purge Fastly after Next.js 16 revalidation, not before

When a Next.js 16 site sits behind Fastly, purge the CDN only after Next.js has finished invalidating its own cache, never before or alongside it. In practice that means running revalidatePath() and the Fastly purge together inside after(), with the purge in a finally block, and purging a second time a few seconds later, because a request that arrives between invalidation and rebuild can still be handed the old page, and Fastly will store it again.

How the site's caches fit together

The site is a decoupled Drupal build for a public-sector client: Drupal holds the content, Next.js 16 renders it with the App Router, and Fastly caches the rendered pages at the edge. When an editor saves something, Drupal calls a revalidation route on the Next.js app with a path and a list of cache tags. The route calls revalidateTag() for each tag and revalidatePath() for the path, and returns quickly so the editor's save is not held up.

Drupal also purged Fastly on save, and that was the only CDN purge on the site. The route itself never touched the CDN.

Why editors still saw old pages

The problem showed up in regression testing of editorial journeys: edit a page, edit the homepage, publish a page at a URL that used to return 404, unpublish and republish. Some of these journeys failed, and the layer that needed fixing was the CDN.

There are two caches in series, and the purges were not ordered. Drupal's purge and the call to Next.js happen around the same time. If Fastly is purged first and a visitor requests the page before Next.js has dropped its own entry, Fastly fetches the page from Next.js, gets the old one, and caches it again. From then on the CDN serves stale content until its own TTL runs out, and nothing will purge it again because, as far as Drupal is concerned, the purge already happened.

The general rule is that an outer cache must be purged after every inner cache it fills from, or the purge just refills it with the same stale content.

First attempt: purge at the end of the route

The obvious fix is to purge Fastly from the revalidation route itself, once revalidation is done. The first version did exactly that, right before returning the response.

That still had a gap. The route already deferred revalidatePath() with after(), because Next.js 16 only passes the first kind of revalidation in a request to the cache handler, so path revalidation had to run in its own phase after the tag revalidation. Code that runs at the end of the route body runs before anything passed to after(). So the purge landed before path revalidation, which is the same race as before, just narrower.

Second attempt: purge inside after()

The next version moved the purge into the same after() callback, after revalidatePath(), and put it in a finally so that a throw from revalidation still purges the CDN:

// app/api/revalidate/route.ts
import { after, NextRequest, NextResponse } from "next/server";
import { revalidatePath, revalidateTag } from "next/cache";
import { purgeCdn } from "@/lib/cdn";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function GET(request: NextRequest) {
  const path = request.nextUrl.searchParams.get("path");
  const tags = request.nextUrl.searchParams.get("tags")?.split(",") ?? [];

  for (const tag of tags) revalidateTag(tag, { expire: 0 });

  after(async () => {
    try {
      if (path) revalidatePath(path, "page");
    } finally {
      await purgeCdn();
      // A request served between invalidation and rebuild may have put the
      // old page back in the CDN; purge once more after the rebuild.
      await sleep(5000);
      await purgeCdn();
    }
  });

  return NextResponse.json(
    { revalidated: true, path, tags },
    { headers: { "Cache-Control": "no-store" } },
  );
}

The response goes back to Drupal immediately, and the purge work runs once the response has been sent. That keeps saves fast, and it means the purge cannot overtake the revalidation it depends on.

Why one purge is not enough

Ordering the purge after invalidation still leaves a window. Invalidation marks the cached page as stale; it does not rebuild it on the spot. The rebuild happens on the next request, and depending on the cache life settings in play, a request in that window can be answered with the stale page while the fresh one is generated. If that request came through Fastly, Fastly now holds the stale page again, and the purge has already happened.

The fix is blunt but effective: purge, wait a few seconds for the page to be rebuilt, then purge again. The second purge clears whatever was cached during the rebuild, and the next request gets the fresh page from Next.js.

The delay is a heuristic, not a guarantee. A slow rebuild could take longer than the wait. A more precise version would request the page itself after revalidation, wait for the fresh response, and only then purge, but that means knowing every URL a tag affects. For a site where edits are occasional and the cost of an extra purge is low, a fixed delay is a reasonable trade.

How the purge call works

The helper calls Fastly's purge API with a surrogate key that covers every page on the host, so it clears the whole front end rather than one URL. It reads the API token and service id from the environment, skips with a log line if either is missing (so local and preview environments without Fastly still work), and returns a boolean rather than throwing, because a failed purge should never break revalidation itself.

Purging a whole host on every save is coarse. Two refinements are worth considering if editing volume grows:

  • Send Drupal's cache tags to Fastly as surrogate keys, so a purge only touches pages that used the changed content.
  • Use Fastly soft purge (the Fastly-Soft-Purge: 1 header), which marks content stale instead of removing it, so the edge can still serve something if the origin is struggling.

Both need the tagging to reach the CDN first, which is a larger change than fixing the ordering.

Key takeaways

  • Purge an outer cache such as Fastly only after every inner cache it fills from has been invalidated, or the purge refills it with stale content.
  • In a Next.js 16 route handler, code at the end of the handler runs before callbacks passed to after(), so a purge that depends on deferred revalidation belongs in the same callback.
  • Put the CDN purge in a finally block so a failed revalidation still clears the edge.
  • Invalidation is not a rebuild, so a second purge after a short delay catches stale pages cached while the page was regenerating.
  • A whole-host purge is a reasonable first fix; surrogate keys per content tag are the precise one.