Unlocking Web Workers with React: A Step-by-Step Guide

Hey everyone! I recently gave a presentation to my team about using Web Workers with React, and the reception was so positive that I decided to turn the content into a blog post.
In this guide, we'll explore how Web Workers can help us keep our React interfaces responsive, even when dealing with heavy computational tasks. We'll go through a practical example, starting with a common problem and evolving to more robust solutions.
The complete source code for each step is available in my GitHub repository.
Part 1: The Problem - The UI That Freezes
Imagine you have a React application that needs to calculate the Fibonacci number for a relatively high value, like 42. The simplest way to implement the Fibonacci function is using recursion:
// src/App.tsx (part-01-heavy-work-lock-ui)
function fib(n: number): number {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
Now, let's call this function when a button is clicked:
// src/App.tsx (part-01-heavy-work-lock-ui)
const handleClick = () => {
setResults((results) => [
...results,
{ id: idRef.current++, result: fib(42) },
]);
};
What happens when we click the button? The user interface (UI) freezes. Buttons stop responding, transitions stutter, and the app feels like it’s crashed. This happens because JavaScript is single-threaded, the long-running Fibonacci calculation blocks the main thread, which also handles user interactions and rendering.
You might notice that the UI is in Portuguese, this demo was originally built for an internal presentation at work. I decided to keep it that way since the interface is simple enough to follow even if you don't speak the language. 😀
Also, if you’re wondering why the rotating icon keeps spinning even
when everything else is frozen, that’s a neat detail! CSS animations
(like transform: rotate
) are handled by the browser’s compositor
thread, not JavaScript, so they continue running smoothly even when
the main thread is blocked.
Part 2: The Solution - Web Workers to the Rescue
To solve the UI freezing problem, we can move the heavy calculation to a Web Worker. A Web Worker runs JavaScript code in a separate thread, allowing the main thread to remain free to handle user interactions and UI updates.
First, let's create our worker:
// src/workers/fibWorker.ts (part-02-03-heavy-work-with-workers)
self.onmessage = ({ data }) => {
const result = fib(data.n);
self.postMessage({ id: data.id, result });
};
function fib(n: number): number {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
Next, in our React component, we create an instance of the worker and communicate with it:
// src/App.tsx (part-02-03-heavy-work-with-workers)
useEffect(() => {
workerRef.current = new Worker(
new URL("./workers/fibWorker.ts", import.meta.url),
{ type: "module" },
);
workerRef.current.onmessage = (e: MessageEvent<Result>) => {
setResults((prev) => [...prev, e.data]);
};
}, []);
const handleClick = () => {
if (workerRef.current) {
workerRef.current.postMessage({ id: idRef.current++, n: 42 });
}
};
Now, when we click the button, the Fibonacci calculation runs in the background, and the UI remains responsive.
Part 3: Handling Multiple Tasks
What happens if we click the button multiple times in quick succession? With the current implementation, each click sends a new message to the worker, and it will process all requests concurrently, with no guaranteed order of completion.
If we, for example, change our fibWorker.ts
to performe some
asynchronous tasks:
// src/workers/fibWorker.ts (part-02-03-heavy-work-with-workers)
function fib(n: number): number {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
self.onmessage = async (e: MessageEvent<{ id: number; n: number }>) => {
await delay(Math.random() * 2000);
const result = fib(e.data.n);
self.postMessage({ id: e.data.id, result });
};
This is the result, showing no "output order" is guaranteed.
Part 4: Controlling the Order with a Queue
To ensure that tasks are executed in the order they were requested, we can implement a queue inside our worker:
// src/workers/fibWorker.ts (part-04-heavy-work-with-workers-queue)
interface Task {
id: number;
n: number;
}
const queue: Task[] = [];
let processing = false;
self.onmessage = (e: MessageEvent<Task>) => {
queue.push(e.data);
if (!processing) processNext();
};
function processNext() {
if (queue.length === 0) {
processing = false;
return;
}
processing = true;
const task = queue.shift();
if (task) {
const result = fib(task.n);
self.postMessage({ id: task.id, result });
}
setTimeout(processNext, 0);
}
With this queue, each task is enqueued and executed one at a time, ensuring order and control over the workflow.
Part 5: Sharing Workers Across Tabs with Shared Workers
What if we want to share the same worker across multiple components or even multiple browser tabs? That's where Shared Workers come in.
A Shared Worker can be accessed by different contexts (tabs, iframes, etc.) from the same origin. This is ideal for centralized tasks, like caching results or managing a WebSocket connection.
Let's create a Shared Worker with a cache for the Fibonacci results:
// src/workers/sharedFibWorker.ts (part-05-heavy-work-shared-workers)
const fibCache = new Map<number, number>();
onconnect = (e: MessageEvent) => {
const port = e.ports[0];
port.start();
port.onmessage = (evt: MessageEvent) => {
// ... (queue logic)
};
};
function fib(n: number): number {
if (fibCache.has(n)) return fibCache.get(n)!;
const result = rawFib(n);
fibCache.set(n, result);
return result;
}
And in our React component, we connect to the Shared Worker:
// src/App.tsx (part-05-heavy-work-shared-workers)
useEffect(() => {
sharedWorkerRef.current = new SharedWorker(
new URL("./workers/sharedFibWorker.ts", import.meta.url),
{ type: "module" },
);
sharedWorkerRef.current.port.start();
sharedWorkerRef.current.port.onmessage = (e: MessageEvent<Result>) => {
setResults((prev) => [...prev, e.data]);
};
}, []);
Now, if you open the application in two different tabs and calculate the same Fibonacci number, the second tab will get the result from the cache instantly.
Conclusion
Web Workers are an incredibly powerful tool for improving the performance and responsiveness of web applications. We've seen how:
➖ Web Workers can prevent UI freezing by moving heavy tasks to a separate thread.
➖ A queue can be used to control the execution order of tasks in a worker.
➖ Shared Workers allow for state sharing and communication between different tabs or components.
It's important to remember that Web Workers do not have access to the DOM. Communication with the main thread is done through messages.
For more complex scenarios, you might want to explore libraries like Google's Comlink, which simplifies communication with workers, allowing you to call functions in the worker as if they were local functions.
A Quick Comparison: Web, Shared, and Service Workers
While this guide focused on Web Workers and Shared Workers, it's helpful to understand how they differ from Service Workers. Each has a distinct purpose.
| Feature / Capability | Web Worker | Shared Worker | Service Worker |
| ------------------------------- | ----------------------- | -------------------------------- | -------------------------------------------- |
| Scope | Single page/tab | Shared across tabs (same origin) | Global (site-wide, independent of tabs) |
| Shared across tabs | No | Yes | Yes |
| Communication | `postMessage` (1:1) | `port.postMessage` (many:1) | `postMessage`, `fetch`, Push API, etc. |
| Persists after tab closes | No | No | Yes (managed by browser) |
| Use case | Offload CPU-heavy tasks | Coordinate logic across tabs | Background sync, caching, push notifications |
| DOM access | No | No | No |
| Network interception | No | No | Yes (`fetch` interception) |
| Requires secure context (HTTPS) | No | No | Yes (HTTPS required) |
Key Observations
➖ Web Workers are the simplest and most common choice. Their goal is to take a specific, computationally intensive task off the main thread to keep a single page responsive. Think of them as temporary helpers for a single view.
➖ Shared Workers are all about coordination. Use them when multiple tabs or windows of your application need to share a single resource, like a WebSocket connection, a shared cache (as in our example), or a centralized state.
➖ Service Workers are fundamentally different. They act as a network proxy for your entire site and are the foundation of Progressive Web Apps (PWAs). While they also run on a separate thread, their primary role is not to perform calculations for a specific page. Instead, they handle tasks that require a longer lifecycle, such as enabling offline functionality by intercepting network requests and serving cached assets, or managing push notifications even when the user doesn't have your site open.
So, while we didn't build a Service Worker today, it's a crucial tool for creating modern, resilient, and engaging web applications. For CPU-bound tasks, stick with Web Workers; for cross-tab communication, use Shared Workers; and for offline capabilities and push notifications, the Service Worker is your go-to.
I hope this guide has been helpful! Feel free to explore the code on GitHub and try it out for yourself.