Let’s be real for a second. We’ve all been there: you build a beautiful dashboard, implement a complex data filter or a large CSV parser, and the moment the user clicks “Process,” the UI locks up. The spinner freezes, the hover effects die, and the browser screams “Page Unresponsive.”
In the era of React 19 and beyond, we talk a lot about Concurrent Mode, Suspense, and optimistic updates. But there’s a hard truth many developers overlook: Concurrency in React handles rendering priority, it does not magically make CPU-bound JavaScript faster.
If you calculate the 40th Fibonacci number on the main thread, React can’t help you. The main thread is blocked, period.
Today, we aren’t just applying band-aids like useMemo. We are going to architect a robust solution using Web Workers to offload heavy calculations, keeping your React UI buttery smooth at 60 (or 120) FPS.
The Single-Threaded Bottleneck #
JavaScript is single-threaded by design. It shares the same thread for:
- Running your JavaScript logic.
- Parsing CSS.
- Layout and Reflow.
- Painting pixels to the screen.
When your logic takes 500ms to sort an array of 100,000 objects, the browser cannot paint a single frame during that half-second. To the user, your app just crashed.
The Architecture of Offloading #
Web Workers allow you to spawn a background thread. This thread has its own event loop and memory space. It communicates with your main React app exclusively via message passing.
Here is how the data flow looks when we implement this correctly:
Prerequisites & Environment #
We are building this for the modern ecosystem. Forget create-react-app; it’s legacy code at this point. We are using Vite because its handling of Web Workers is superior and standardized.
Requirements:
- Node.js 20+
- A Vite + React + TypeScript project
- Basic understanding of
useEffectanduseRef
If you are setting up a fresh sandbox:
npm create vite@latest react-workers-demo -- --template react-ts
cd react-workers-demo
npm installStep 1: The “Heavy” Logic #
Let’s simulate a CPU-heavy task. We’ll use a naive Prime Number generator. It’s perfect for benchmarking because it’s computationally expensive but conceptually simple.
Create a file named heavy-computation.ts.
// heavy-computation.ts
/**
* Checks if a number is prime.
* Intentionally unoptimized to burn CPU cycles.
*/
function isPrime(num: number): boolean {
for (let i = 2, s = Math.sqrt(num); i <= s; i++) {
if (num % i === 0) return false;
}
return num > 1;
}
/**
* Generates N prime numbers starting from a specific offset.
*/
export function generatePrimes(amount: number, startFrom: number): number[] {
const primes: number[] = [];
let current = startFrom;
while (primes.length < amount) {
if (isPrime(current)) {
primes.push(current);
}
current++;
}
return primes;
}If you ran generatePrimes(20000, 1000000) inside a useEffect, your browser would hang for several seconds.
Step 2: Creating the Worker #
In Vite, creating a worker is incredibly straightforward. You don’t need worker-loader or complex Webpack magic anymore.
Create a file named prime.worker.ts. The .worker.ts extension is a convention that helps Vite identify it, though modern Vite handles imports explicitly.
// prime.worker.ts
import { generatePrimes } from './heavy-computation';
// Define the shape of messages we expect/send
type WorkerMessage = {
type: 'START';
amount: number;
startFrom: number;
};
type WorkerResponse = {
status: 'SUCCESS';
data: number[];
executionTime: number;
};
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
const { type, amount, startFrom } = e.data;
if (type === 'START') {
const start = performance.now();
// Perform the heavy lifting
const result = generatePrimes(amount, startFrom);
const end = performance.now();
// Send result back to Main Thread
const response: WorkerResponse = {
status: 'SUCCESS',
data: result,
executionTime: end - start,
};
self.postMessage(response);
}
};
export {};Key Takeaway: Notice we are importing TypeScript functions directly into the worker. Vite handles bundling this dependency graph into a separate worker.js file at build time.
Step 3: Integrating with React (The Robust Way) #
Now, let’s build a component that consumes this. We won’t just instantiate the worker loosely; we’ll manage its lifecycle properly to avoid memory leaks—a common pitfall in Single Page Applications (SPAs).
We’ll use useRef to hold the worker instance so it persists across re-renders without being recreated, and useEffect to terminate it when the component unmounts.
// App.tsx
import { useEffect, useRef, useState } from 'react';
// Vite allows this syntax to create a new Worker constructor
import PrimeWorker from './prime.worker?worker';
function App() {
const [primes, setPrimes] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [timeTaken, setTimeTaken] = useState(0);
// Ref to hold the worker instance
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
// 1. Initialize the worker
workerRef.current = new PrimeWorker();
// 2. Set up the listener for incoming messages from the worker
workerRef.current.onmessage = (e) => {
const { data, executionTime } = e.data;
setPrimes(data);
setTimeTaken(executionTime);
setLoading(false);
};
// 3. Cleanup: Terminate worker when component unmounts
return () => {
workerRef.current?.terminate();
};
}, []);
const handleGenerate = () => {
setLoading(true);
setPrimes([]);
// Send message to worker
workerRef.current?.postMessage({
type: 'START',
amount: 50000, // Large number to verify performance
startFrom: 1000000
});
};
return (
<div className="container">
<h1>Web Worker Demo</h1>
<div className="card">
<p>
Status:
<span style={{
color: loading ? 'orange' : 'green',
fontWeight: 'bold',
marginLeft: '8px'
}}>
{loading ? 'Crunching Numbers...' : 'Idle'}
</span>
</p>
{/* Visual Proof of unblocked UI */}
<div className="animation-test">
<div className="spinner"></div>
<p><small>If this spinner stops, the main thread is blocked.</small></p>
</div>
<button
onClick={handleGenerate}
disabled={loading}
className="primary-btn"
>
{loading ? 'Processing...' : 'Generate 50k Primes'}
</button>
</div>
{!loading && primes.length > 0 && (
<div className="results">
<h3>Results</h3>
<p>Calculation time: {timeTaken.toFixed(2)}ms</p>
<div className="data-box">
{primes.slice(0, 100).join(', ')} ...
</div>
</div>
)}
</div>
);
}
export default App;Performance Analysis & Comparison #
Why go through this trouble instead of just using useMemo?
useMemo is excellent for avoiding re-calculation on re-renders. However, the first calculation still happens on the main thread. If that first calculation takes 2 seconds, your app freezes for 2 seconds.
Here is the breakdown of different optimization strategies:
| Strategy | Prevents Re-calculation? | Unblocks Main Thread? | Complexity | Best Use Case |
|---|---|---|---|---|
| Standard Execution | ❌ | ❌ | Low | Simple logic (< 10ms) |
useMemo |
✅ | ❌ | Low | Filtering small lists, derivation |
React.startTransition |
❌ | ❌ | Medium | UI State updates (Rendering) |
| Web Workers | N/A (Isolated) | ✅ | High | Image processing, Big Data, Crypto |
Pro Tip: The Serialization Cost #
Web Workers are not free. Data passed between the main thread and the worker is copied via the Structured Clone Algorithm.
- Small Data: Negligible cost.
- Huge Data (10MB+ JSON): The cloning itself can block the main thread.
Solution: For massive datasets (like image buffers or large binary arrays), use Transferable Objects.
// Main Thread
const buffer = new ArrayBuffer(1024 * 1024 * 10); // 10MB
worker.postMessage({ buffer }, [buffer]); // The second argument transfers ownership
Note: Once transferred, the main thread loses access to that memory instantly. Zero-copy, high performance.
Handling “Zombie” Workers #
One of the biggest issues in React development with Workers is React’s Strict Mode (in development) and component remounting.
In React 18/19 Strict Mode, effects run twice. If you aren’t careful, you might spawn two workers for every component instance.
- Component Mounts -> Worker A created.
- Strict Mode Unmounts -> Worker A should be terminated.
- Strict Mode Remounts -> Worker B created.
If you skip the cleanup function in useEffect:
// BAD CODE - DO NOT DO THIS
useEffect(() => {
const worker = new Worker(...); // Worker created
// No cleanup returned!
}, [])You will end up with orphaned threads eating up RAM until the user refreshes the tab. Always call .terminate() in the cleanup return.
Libraries to Consider #
While the raw API (as shown above) is best for understanding, in production, you might want abstractions to make the code cleaner (RPC style):
- Comlink: Built by the Google Chrome team. It makes the worker look like a normal async function. It handles the
postMessageboilerplate for you. - Partytown: Specifically for offloading third-party scripts (Analytics, Ads) to workers.
Conclusion #
Web Workers are an underutilized superpower in the React ecosystem. As we build applications that rival native desktop software—handling data visualization, cryptography, or complex parsing—keeping the main thread free for UI interactions is non-negotiable.
Don’t let React’s concurrent features fool you into thinking CPU bottlenecks are solved. They aren’t. Move that heavy logic off the main thread, and your users will thank you for an app that feels instant, even when it’s doing the heavy lifting.
Further Reading #
Found this article useful? Share it with your team and stop the spinner madness.