loke.dev
Header image for Why Does Your React 19 Action Still Freeze the UI During Heavy Computation?

Why Does Your React 19 Action Still Freeze the UI During Heavy Computation?

Uncover the common misconception that React 19 Actions run off the main thread and learn how to prevent synchronous logic from hijacking your UI transitions.

· 4 min read

You upgraded to React 19, wrapped your expensive logic in a transition, and sat back waiting for that buttery-smooth UI performance everyone promised. Instead, your spinning loader is frozen in time, the browser tab is gasping for air, and your "Action" feels more like an "Inaction."

It’s a frustrating rite of passage for many developers diving into React 19. We’ve been told that Actions and useTransition are the keys to non-blocking UIs, but there’s a massive architectural misunderstanding hiding in plain sight: React Actions do not magically move your code off the main thread.

The "Magic" We All Hoped For

In a perfect world, wrapping a function in startTransition would spin up a tiny invisible worker to handle the heavy lifting while the UI stays responsive. Unfortunately, JavaScript is still single-threaded, and React 19 hasn't changed the laws of physics.

If you have a function that does something CPU-intensive—like processing a 50MB JSON file or calculating the meaning of life through a million iterations—it’s going to hijack the main thread.

Check out this common mistake:

import { useTransition } from 'react';

function HeavyComponent() {
  const [isPending, startTransition] = useTransition();

  const handleAction = () => {
    startTransition(() => {
      // 🚩 PROBLEM: This runs synchronously on the main thread!
      const start = performance.now();
      while (performance.now() - start < 2000) {
        // Simulating a heavy computation (2 seconds of blocking)
      }
      console.log("Computation done!");
    });
  };

  return (
    <button onClick={handleAction} disabled={isPending}>
      {isPending ? "Computing..." : "Run Heavy Task"}
    </button>
  );
}

When you click that button, the browser will likely freeze. The "Computing..." text might not even show up immediately because the main thread is too busy running that while loop to even paint the button's disabled state.

Why is it still freezing?

The core purpose of React 19 Actions and transitions isn't to make code run in the background; it’s to make state updates interruptible.

When you use startTransition, you're telling React: "Hey, if the user clicks something else while you're busy *rendering* the result of this, feel free to drop what you're doing and handle the new click."

However, the logic inside the transition still executes as a standard JavaScript task. If that task takes 500ms of CPU time, React can't "interrupt" it because the JavaScript engine is literally stuck on that line of code. React can only interrupt the *re-rendering phase* that happens after your logic finishes.

How to actually stop the freeze

If you're dealing with heavy computation, you have three real choices to keep that UI snappy.

1. Offload to the Server (The "Server Action" Way)

React 19 leans heavily into Server Actions. By moving the heavy computation to the server, you're truly moving it off the user's main thread.

// actions.js
'use server';

export async function processData(data) {
  // This runs on your server, not the user's browser!
  return heavyComputation(data);
}

// Component.js
function MyComponent() {
  const [isPending, startTransition] = useTransition();

  const handleAction = async () => {
    startTransition(async () => {
      const result = await processData(someData); // Main thread is free while waiting
      console.log(result);
    });
  };
}

Since the logic is async and happens over the network, the browser's main thread is free to animate, let the user click other things, and keep the "Pending" state active.

2. The Web Worker Escape Hatch

If the work *must* happen on the client side, you need a Web Worker. This is the only way to run JavaScript on a separate thread in the browser.

const handleAction = () => {
  startTransition(() => {
    const worker = new Worker(new URL('./worker.js', import.meta.url));
    worker.postMessage(data);
    
    worker.onmessage = (e) => {
      // Update state with the result
      setResult(e.data);
    };
  });
};

3. Yielding to the Main Thread

If your computation can be broken into chunks, you can "yield" control back to the browser periodically. This allows the UI to paint between chunks of work.

I’ve used this trick for years, and while it's a bit "manual," it works wonders for things like large list processing:

const handleAction = () => {
  startTransition(async () => {
    for (let i = 0; i < chunks.length; i++) {
      processChunk(chunks[i]);
      
      // Every few iterations, yield back to the browser
      if (i % 10 === 0) {
        await new Promise(resolve => setTimeout(resolve, 0));
      }
    }
  });
};

The "Pending" State Catch

One more thing I noticed: developers often expect isPending to show up instantly. But if your heavy logic starts *immediately* inside the transition without any await, React might not have a chance to paint the "Pending" state before the thread locks up.

If you find your loader isn't appearing, try wrapping your heavy sync logic in a tiny await:

const handleAction = () => {
  startTransition(async () => {
    // Yield for 1 tick to let React render the 'pending' state
    await new Promise(res => setTimeout(res, 0)); 
    
    doMassiveSyncWork(); // Now the loader is already visible
  });
};

Summary

React 19 Actions are a massive step forward for handling form submissions and async states, but they aren't a multi-threading engine.

- Sync logic = Blocking. No matter where you put it.
- Async logic (Promises) = Non-blocking. The main thread is free while waiting.
- Transitions make the *UI update* low priority, not the *code execution* itself.

Next time your UI hangs, check your logic. If it doesn't have an await or a worker.postMessage, it’s probably hogging the microphone and refusing to let React speak.