All posts
· 3 min readnextjscloudflare-workersopennextdebugging

Server Action hanging on Cloudflare Workers? Check your revalidatePath scope

A Next.js Server Action that worked everywhere else froze forever in production on Cloudflare Workers. The culprit: revalidatePath with layout scope under OpenNext. Here's the diagnosis and the fix.

We ship Next.js apps to Cloudflare Workers using OpenNext. It mostly Just Works — until it very much doesn't, and the failure modes are nothing like what you see on Node or Vercel.

This one cost us an evening: a Server Action that worked perfectly in next dev and in next start hung forever in production. The button stayed in its pending state, the POST never resolved, and there wasn't a single error in the logs — at first glance.

The symptom#

A settings modal with a Save button, backed by a plain Server Action:

export async function saveSettings(formData: FormData) {
  // ...validate, write to Postgres...
  revalidatePath("/dashboard/admin/emails", "layout");
  return { ok: true };
}

In production on Workers:

  • The UI stuck on "Saving…" indefinitely.
  • DevTools showed the POST as pending, then eventually failed.
  • The database write had actually succeeded — reloading the page showed the new data. That combination (silent success + frozen UI) is what made it so confusing.

The evidence#

wrangler tail on the production Worker showed the smoking gun:

POST https://example.com/dashboard/admin/emails - Canceled @ 7/26/2026, 10:07:58 PM
  (warn) waitUntil() tasks did not complete within the allowed timeout

The runtime was canceling the request because background work scheduled via waitUntil() never finished. No exception, no stack trace — the request just gets reaped.

What's actually happening#

revalidatePath(path, "layout") tells Next.js to invalidate the target route and everything below it in the route tree. Under OpenNext, cache invalidation work is bridged onto the Workers runtime through ctx.waitUntil().

With page scope (the default), that's a bounded, small amount of work and it completes fine. With layout scope, the invalidation fans out across the route tree — and somewhere in that fan-out, under OpenNext's cache adapter, the work never resolves. The waitUntil() budget expires, the runtime cancels the in-flight POST, and your Server Action response never reaches the client.

The nasty part: everything before the revalidate call (your database write) has already committed. So the operation half-succeeds — data saved, UI frozen — which sends you hunting in exactly the wrong places first (the DB layer, the form library, the fetch client).

The fix#

Replace the single layout-scope call with explicit page-scope calls, one per route that needs refreshing:

// Before — hangs on OpenNext/Workers
revalidatePath("/dashboard/admin/emails", "layout");
 
// After — bounded, completes instantly
const TEMPLATE_KEYS = ["welcome", "reset-password" /* … */] as const;
 
function revalidateAllEmailPages(): void {
  revalidatePath("/dashboard/admin/emails");
  for (const key of TEMPLATE_KEYS) {
    revalidatePath(`/dashboard/admin/emails/${key}`);
  }
}

Twenty-one page-scope calls sounds heavier than one layout-scope call. It isn't — each one is a targeted invalidation, the loop completes in milliseconds, and most importantly it completes. The Save button went from hanging forever to resolving instantly, verified end-to-end in production.

If your affected routes are dynamic and unbounded (so you can't enumerate them), reach for revalidateTag() with a shared cache tag instead — same bounded behavior, no enumeration.

Takeaways#

  1. On Workers, waitUntil() tasks did not complete after a Server Action is a revalidation smell. Check every revalidatePath call for "layout" scope before you suspect your own code.
  2. wrangler tail is non-negotiable for OpenNext debugging. The dashboard logs won't show you canceled requests with their warnings; a live tail will.
  3. A hanging action with a successful side effect means the hang is after your business logic. Binary-search from the return statement backwards, not from the top down.
  4. Parity between next dev and Workers is good but not perfect. Anything touching the cache layer (ISR, revalidation, use cache) deserves a production smoke test on every change.

This is the first post in a series on running Next.js on Cloudflare Workers in production. Coming up: why we pin Prisma to 6.x on Workers (WASM code generation), and connecting to external Postgres through Hyperdrive when direct TCP fails.


Fenix Codex builds and operates production web platforms — get in touch if you need a team that debugs at this depth.