BeyondIT logo
BeyondIT
React 19

The 5-Millisecond React 19 Fibre Deadlock Inside Next.js Server Actions That Froze 30% of Production UIs

8 min read
React 19
The 5-Millisecond React 19 Fibre Deadlock Inside Next.js Server Actions That Froze 30% of Production UIs
šŸ’”

I spent three days rewriting my revalidatePath calls. The network tab showed a perfect 200. The real failure was a 3-line dispatcher switch two abstraction layers below anything the docs told me to look at.

šŸ“– In this post

  • The Moment It Breaks: Why the network succeeds but the UI freezes
  • The React 19 Fibre Dispatcher: How it gets stuck in the wrong mode
  • Two Terminal Commands: The fix with no application code changes
  • The Pattern: What nobody warns you about during canary dependency bumps

The Moment It Breaks

The button greys out. The spinner appears. The network tab shows the Server Action fired — 200 OK, 47 milliseconds. The database updated. The React Server Component (RSC) payload came back with fresh data. You can see it sitting right there in the response.

But the DOM hasn't moved. The spinner doesn't stop. isPending is true. Forever.

It's the cache. Has to be. You rewrite revalidatePath('/dashboard'). You add { type: 'layout' }. You move the call. You double-check the path string. Nothing. You add router.refresh() after the action fires. The spinner mocks you. You add await before revalidatePath. Still spinning.

You rip out useTransition entirely. The action fires. The UI actually updates — but now there's no pending state at all. The button doesn't grey out. That's not a fix. You wrap it back in. isPending is true again. Three hours gone. The code is identical to the tutorial.

You Google Next.js Server Actions useTransition stuck pending. Fourteen threads. Every accepted answer says the same thing: "Check your revalidatePath call."

You've checked it. Seven times. You read our debugging blind post to find solace. And then — the thought every engineer dreads — maybe it's me.

It wasn't you. It was never you.

⚔

⚔ TL;DR

Next.js useTransition stuck pending after a server action succeeds is a documented React 19 Fibre Reconciler bug, not a caching mistake. It was introduced by a specific Next.js canary React dependency bump in July 2025 and silently broke production systems for months. Your revalidatePath call was correct the entire time. The fix is two terminal commands, not a cache rewrite.

But if the cache wasn't broken — what exactly was?


The React 19 Fibre Dispatcher That Got Stuck in the Wrong Mode

React's Fibre reconciler has two internal dispatcher modes.

There's re-render — replay mode, used when React 19's use() hook (a primitive for reading async resources inline) suspends a component, so React can reuse hook state from the previous attempt. And there's update — the normal execution path.

The switch between them is supposed to be automatic.

Andrew Clark (acdlite) on the React Core Team put it plainly in PR #29670, which first documented this exact dispatcher failure class in May 2024:

šŸ’”

"Once we run out of hooks from the previous attempt, we should switch back to the regular 'update' dispatcher."

The word should is doing a lot of work in that sentence.

That PR identified the diagnostic model. The July 2025 production regression — tracked in facebook/react Issue #35399 — re-introduced the same failure class via a new resolveLazy try-catch block inside ReactChildFiber.js.

Here's what that change actually did: The try-catch intercepted the thrown Promise. It stored it as suspendedThenable. Then it threw a generic SuspenseException instead of passing the Promise up the call stack naturally.

React now had to hunt for the suspended Promise after the catch block. It couldn't receive it from the call stack directly anymore. And if that Server Action resolved in a few milliseconds? The Promise already settled. The .then() listener was never attached.

GitHub Issue #28923 documents this failure class directly. Filed by alexeyraspopov, a React contributor:

šŸ’”

"Pending flag of transition correctly becomes true in the beginning, but doesn't go back to false after transition is complete, which means any pending state artefacts in the UI remain visible."

React marks the component as suspended. It waits for the resolution signal. But the signal already fired — in the gap between the catch block and the listener setup. React waits forever. isPending stays permanently true.

The 30% Incidence Matrix

One production incident report documented the intermittency precisely.

šŸ’”

"This issue occurs intermittently — around 70% of the time, everything works as expected. But during the remaining 30%, the useTransition hook never resolves." — Production deployment, Next.js 16, January 2026 (vercel/next.js Discussion #88767)

That 30% isn't random noise. It correlates directly with Server Action speed. Slower actions — typical of cold-start cloud functions — miss the timing window entirely. Fast actions, like those hitting a warm in-region database, hit it consistently.

This is why local development almost never reproduced it. Your localhost latency was too high to trigger the race. Your cloud production database was too fast for React to catch up.

The Shadow Bugs Lurking

Even when isPending resolves correctly, a second failure mode lurks. Next.js Issue #61184 has a fully reproducible repo proving that revalidatePath correctly invalidates the server-side data cache — but doesn't touch the client-side router cache. Hard refresh shows fresh data. Back-navigation shows stale data.

Two separate failures. Most debugging sessions treat them as one.

The third failure mode is structural. Next.js bundles React canary builds — pre-release React binaries from the bleeding edge of the main branch — into its stable semver releases. The July 2025 bump (eaee5308 → 9be531cd) shipped with no changelog entry, no breaking change marker, no migration note. It arrived with npm install.

Adding router.refresh(), disabling cacheComponents, or rewriting your revalidatePath path strings won't attach a .then() listener that was never registered.


Two Terminal Commands. No Application Code Changes.

Stop debugging the cache. The cache is not the problem.

revalidatePath and useTransition don't operate on the same abstraction layer. revalidatePath is a Next.js data cache primitive. useTransition is a React Fibre scheduler primitive.

When isPending freezes permanently, you're not looking at a stale cache entry — you're looking at a Fibre dispatcher that entered re-render mode and never got the signal to exit. They need completely different fixes.

Version isolation is the fix.

You need to land either side of the July 2025 regression. Below it — before the resolveLazy try-catch existed. Or above it — past the January 17, 2026 patch in ReactFiberHooks.js that fixed the listener timing. Both paths are two terminal commands. Neither one touches a single line of your application code.

Option A is the downgrade path. react@19.0.0 predates the fatal canary commit. No try-catch means no intercepted Promise. isPending resolves. Option B force-pulls past PR #35518 — merged January 17, 2026 — which repaired the listener attachment sequence directly.

I know what you're thinking. Downgrading feels like giving up on the stable release track. But here's the thing — you were never on a stable React binary to begin with. Next.js was already bundling a canary build that shipped a timing vulnerability with zero changelog signal. Pinning to react@19.0.0 is, by definition, more stable than what you were actually running.

# ─────────────────────────────────────────────────────────────────
# OPTION A - Downgrade path (stable; trades Turbopack performance)
# Pins React to 19.0.0 - predates the resolveLazy try-catch commit
# ─────────────────────────────────────────────────────────────────

npm install react@19.0.0 react-dom@19.0.0 --save-exact
npm install next@15.3.6 --save-exact

# Confirm the binary - must output exactly 19.0.0
node -e "console.log(require('react/package.json').version)"

# ─────────────────────────────────────────────────────────────────
# OPTION B - Canary upgrade path (keeps Turbopack; carries more risk)
# Force-pulls past PR #35518, merged 2026-01-17
# NOTE: --force suppresses peer dep warnings.
# Verify version output before deploying to production.
# ─────────────────────────────────────────────────────────────────

npm install react@canary react-dom@canary --force

# Confirm you're past the fix.
# The canary date is embedded in the version string: 19.0.0-canary-YYYYMMDD
# The 8-digit date must be >= 20260117
node -e "console.log(require('react/package.json').version)"

# ─────────────────────────────────────────────────────────────────
# OPTION B ALTERNATIVE - Lock the version floor (add to package.json)
# Use "overrides" (npm) or "resolutions" (yarn / pnpm)
# NOT the "engines" field - that sets Node/npm runtime requirements only
# ─────────────────────────────────────────────────────────────────
# "overrides": {
#   "react": ">=19.0.0-canary-20260117",
#   "react-dom": ">=19.0.0-canary-20260117"
# }

After version-pinning, if you're still seeing stale data on back-navigation — that's the second, independent failure mode from Issue #61184. The Fibre deadlock fix and the router cache are separate layers. For back-nav stale data: add export const dynamic = 'force-dynamic' to the affected route segment, or call router.refresh() explicitly after the action.

šŸ’”

[!CAUTION] Don't remove useTransition as a workaround. The bug is fixed at the version level. Removing it trades a fixed freeze for an unresponsive UI with no pending state — objectively worse for your users, and buries the actual problem.


The Pattern Nobody Warns You About

Every team hits this in exactly the same order.

Rewrites revalidatePath. Adds router.refresh(). Disables cacheComponents. Removes useTransition. Re-adds useTransition. Opens a GitHub issue. Gets told "works for me." Spends a week on it.

The pattern is so consistent it could be a flowchart. And at no point does the standard Next.js debugging playbook point you at a canary dependency bump.

The real problem isn't caching configuration. It's that a meta-framework bundling canary internals provides no semantic signal when a reconciler patch ships a timing vulnerability. You can't diff a dependency bump for production-visible race conditions. You just know the spinner won't stop.

šŸ’”

"The issue affects more than just useTransition — it's probably why it took so long for someone to notice." — Andrew Clark (acdlite), React Core Team, Meta (facebook/react — PR #29670)

If you found the exact Next.js version where your spinner started breaking — drop it in the comments. Let's build the definitive affected-version map together.