loke.dev
Header image for The 'Zombie' Action: How I Finally Debugged the React 19 Background Retry Loop

The 'Zombie' Action: How I Finally Debugged the React 19 Background Retry Loop

Explore the hidden logic behind React 19's automatic Action retries and why your idempotent backend might still be suffering from 'ghost' mutations during network instability.

· 4 min read

The 'Zombie' Action: How I Finally Debugged the React 19 Background Retry Loop

React 19 is effectively a gaslighter. It’ll tell you everything is fine while silently resubmitting your form data behind your back just because the WiFi flickered for a millisecond. If you’ve started seeing duplicate database entries or "ghost" logs that don't show up in your Chrome Network tab, you haven't lost your mind—you’ve just run into the new Action Retry logic.

I spent six hours last Tuesday chasing a bug where a "Create Project" button was hitting our API twice. I checked the component logic. I checked the debounce. I checked the server-side logs. The client showed one click; the server showed two requests. It felt like a poltergeist was living in my node_modules.

The "Feature" We Weren't Prepared For

In React 19, Actions (those lovely functions we pass to <form action={...}> or useActionState) aren't just wrappers for fetch. They are integrated into React’s transition system.

When you trigger an Action, React enters a transition. If that transition is interrupted—say, by a network hiccup or a fast navigation—React doesn't just give up. It assumes that because the Action is responsible for the "Current State" of the UI, it *must* finish. If it detects a failure that looks recoverable, it might try again.

Here is what a standard, seemingly innocent React 19 Action looks like:

// actions.js
'use server';

export async function createNote(prevState, formData) {
  const note = formData.get('note');
  
  // Imagine the network drops right here...
  const response = await db.notes.create({
    data: { content: note }
  });

  return { success: true, id: response.id };
}

On the surface, this is clean. But if the browser loses connection *after* the server receives the request but *before* the client receives the response, React 19 might interpret this as a failed transition and attempt to re-run the Action when the connection stabilizes or the state refreshes.

Why You Don't See It in the Network Tab

This is the part that drove me crazy. Sometimes, these retries don't look like traditional XHR/Fetch requests you can easily catch. If the retry happens during a hydration mismatch or a specific transition replay, it can feel invisible.

React tries to ensure the UI stays "consistent." If an Action was supposed to change the URL or update a useActionState hook, and that hasn't happened yet, React's internal logic kicks in to "retry" the transition to reach the desired end-state.

The Fix: Embracing Idempotency (For Real This Time)

Before React 19, we could be a bit lazy. If a request failed, we just showed an error toast and let the user click "Submit" again. Now, the framework handles the "clicking again" part for us, which means our backends must be idempotent.

The most robust way I’ve found to kill the "Zombie Action" is to pass a client-generated request ID (idempotency key) through the form itself.

// NoteComponent.jsx
import { useActionState } from 'react';
import { createNote } from './actions';

export default function NoteComponent() {
  // We generate a key once per "intent"
  const requestId = crypto.randomUUID(); 
  const [state, formAction] = useActionState(createNote, null);

  return (
    <form action={formAction}>
      <input type="hidden" name="requestId" value={requestId} />
      <textarea name="note" />
      <button type="submit">Save Note</button>
    </form>
  );
}

And on the server, we check that key:

// actions.js
export async function createNote(prevState, formData) {
  const requestId = formData.get('requestId');
  
  // Check if we've already processed this specific request ID
  const existing = await db.processedRequests.findUnique({
    where: { id: requestId }
  });

  if (existing) {
    return { success: true, alreadyProcessed: true };
  }

  const note = formData.get('note');
  const result = await db.notes.create({ data: { content: note } });

  // Mark this request as done
  await db.processedRequests.create({ data: { id: requestId } });

  return { success: true, id: result.id };
}

When Does React Actually Retry?

It isn't a random loop. React 19 typically retries Actions in a few specific scenarios:

1. Hydration Interruptions: If an action is triggered before the page is fully interactive and a re-render interrupts it.
2. Navigation Conflicts: If you trigger an action and immediately navigate away, but the browser cancels the request, React may attempt to ensure the action completes if it’s tied to a shared state.
3. Micro-outages: When using Server Actions over a flaky connection, the underlying startTransition logic has a built-in resilience that favors "completing the action" over "failing fast."

The "Optimistic UI" Gotcha

If you are using useOptimistic alongside these Actions, the "Zombie" effect is even more pronounced. The UI will jump to the "Success" state, then potentially flicker back to "Pending" if React decides to retry the underlying Action.

To prevent a jarring experience, make sure your optimistic update is keyed to the same unique ID you’re sending to the server. If the server returns that ID, React can reconcile the two without a flicker.

Final Thoughts

The React 19 Action retry loop isn't a bug; it's a shift in philosophy. React is moving toward being a distributed system coordinator rather than just a UI library. It wants to guarantee that the server and client stay in sync, even if the internet is garbage.

The takeaway: Stop trusting your "Submit" button to only fire once. If you're moving to React 19, treat every Action as if it might be called twice. Use hidden request IDs, implement server-side checks, and for the love of everything holy, keep an eye on your database logs—not just your browser's console.