JavaScript and React course Β· Module 10: React Ecosystem and Future

Web Workers in React

7 min read
In this lesson6

Imagine that your ship's main onboard computer (the main thread) has to render the cockpit, run the navigation systems and recalculate the flight trajectory all at the same time. If the trajectory calculations are too heavy, the cockpit freezes and the captain cannot click a single button. The solution? A dedicated computing unit, the Web Worker, which works in parallel and does not block the main system.

What Are Web Workers?

Web Workers are a browser mechanism that runs JavaScript code in a separate thread. The main thread is responsible for rendering the UI, handling events and interacting with the user, so heavy computations on it freeze the interface. This is what the problem looks like:

1// WITHOUT Web Worker - blocks main thread
2function HeavyCalculation() {
3  const [result, setResult] = useState(null);
4
5  const calculate = () => {
6    // This will block the UI for several seconds!
7    let sum = 0;
8    for (let i = 0; i < 1_000_000_000; i++) {
9      sum += Math.sqrt(i);
10    }
11    setResult(sum);
12  };
13
14  return (
15    <div>
16      <button onClick={calculate}>Calculate</button>
17      <p>Result: {result}</p>
18    </div>
19  );
20}

A loop with a billion iterations will occupy the main thread for several seconds: the button will not respond, and React will not even render the loading state. useState does not save anything here, because the problem is the busy thread, not the way the result is stored.

Creating a Web Worker

A worker is a separate JavaScript file with its own global scope, self. It talks to the main thread only through messages: postMessage sends, and onmessage receives:

1// worker.js - Worker file (separate thread)
2self.onmessage = function(event) {
3  const { type, data } = event.data;
4
5  if (type === 'CALCULATE_TRAJECTORY') {
6    const result = calculateTrajectory(data);
7    self.postMessage({ type: 'RESULT', result });
8  }
9};
10
11function calculateTrajectory(params) {
12  let trajectory = [];
13  for (let t = 0; t < params.steps; t++) {
14    const x = params.speed * t * Math.cos(params.angle);
15    const y = params.speed * t * Math.sin(params.angle) - 0.5 * 9.81 * t * t;
16    trajectory.push({ x, y, t });
17  }
18  return trajectory;
19}

The worker waits for a message of type CALCULATE_TRAJECTORY, computes and sends the result back through self.postMessage. It has no access to the DOM or to React state, it only knows what arrived in event.data.

On the React side you create the worker once, in an effect, and keep it in useRef:

1// App.jsx - React component (main thread)
2import { useState, useRef, useEffect } from 'react';
3
4function TrajectoryCalculator() {
5  const [result, setResult] = useState(null);
6  const [isCalculating, setIsCalculating] = useState(false);
7  const workerRef = useRef(null);
8
9  useEffect(() => {
10    workerRef.current = new Worker(new URL('./worker.js', import.meta.url));
11
12    workerRef.current.onmessage = (event) => {
13      if (event.data.type === 'RESULT') {
14        setResult(event.data.result);
15        setIsCalculating(false);
16      }
17    };
18
19    return () => workerRef.current?.terminate();
20  }, []);
21
22  const handleCalculate = () => {
23    setIsCalculating(true);
24    workerRef.current.postMessage({
25      type: 'CALCULATE_TRAJECTORY',
26      data: { speed: 1000, angle: 0.785, steps: 100000 }
27    });
28  };
29
30  return (
31    <div>
32      <button onClick={handleCalculate} disabled={isCalculating}>
33        {isCalculating ? 'Calculating...' : 'Calculate trajectory'}
34      </button>
35      {result && <p>Trajectory points: {result.length}</p>}
36    </div>
37  );
38}

The new Worker(new URL('./worker.js', import.meta.url)) notation is recognized by bundlers such as webpack 5 and Vite, which include the worker file by themselves. The cleanup calls terminate(), so after the component unmounts the thread does not keep working. The data from postMessage is copied with the structured clone algorithm, which is why you cannot send functions or DOM elements.

A Custom Hook: useWorker

So you do not repeat this logic in every component, wrap it in a reusable hook:

1import { useState, useRef, useEffect, useCallback } from 'react';
2
3function useWorker(workerFactory) {
4  const [result, setResult] = useState(null);
5  const [error, setError] = useState(null);
6  const [isRunning, setIsRunning] = useState(false);
7  const workerRef = useRef(null);
8
9  useEffect(() => {
10    workerRef.current = workerFactory();
11
12    workerRef.current.onmessage = (event) => {
13      setResult(event.data);
14      setIsRunning(false);
15    };
16
17    workerRef.current.onerror = (err) => {
18      setError(err.message);
19      setIsRunning(false);
20    };
21
22    return () => workerRef.current?.terminate();
23  }, []);
24
25  const run = useCallback((data) => {
26    setIsRunning(true);
27    setError(null);
28    workerRef.current?.postMessage(data);
29  }, []);
30
31  return { result, error, isRunning, run };
32}

run sends the data, and the hook itself manages the result, error and isRunning states. You pass a factory, e.g. () => new Worker(url), because the worker should be created in the effect, not on every render.

Unpacking messages by hand quickly gets tiring. The Comlink library from Google Chrome Labs lets you treat a worker like an object with asynchronous methods. In the worker file you expose an object through Comlink.expose:

1// worker.js with Comlink
2import * as Comlink from 'comlink';
3
4const navigationComputer = {
5  async calculateRoute(origin, destination) {
6    const route = computeOptimalPath(origin, destination);
7    return route;
8  },
9  async analyzeStarField(stars) {
10    return stars.map(star => ({
11      ...star,
12      habitableZone: calculateHabitableZone(star.mass, star.luminosity)
13    }));
14  }
15};
16
17Comlink.expose(navigationComputer);

The calculateRoute and analyzeStarField methods still run in a separate thread, only the way you call them changes.

In the component you wrap the worker with Comlink.wrap and call the methods like regular async functions:

1// App.jsx with Comlink
2import * as Comlink from 'comlink';
3
4function NavigationPanel() {
5  const [route, setRoute] = useState(null);
6  const navComputer = useRef(null);
7
8  useEffect(() => {
9    const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
10    navComputer.current = Comlink.wrap(worker);
11    return () => worker.terminate();
12  }, []);
13
14  const planRoute = async () => {
15    // Call the method like a regular async function!
16    const result = await navComputer.current.calculateRoute(
17      { x: 0, y: 0, z: 0 },
18      { x: 4.2, y: 1.3, z: 0.7 }
19    );
20    setRoute(result);
21  };
22
23  return (
24    <div>
25      <button onClick={planRoute}>Plan route</button>
26      {route && <RouteVisualization data={route} />}
27    </div>
28  );
29}

Under the hood Comlink still sends postMessage, and every call returns a promise, which is why you need await. This worker file uses import statements, so new Worker gets the { type: 'module' } option and the browser runs it as a module worker. Vite's development mode relies on that too, and in the build the bundler includes the dependencies in the worker file.

When to Use Web Workers in React?

Workers are ideal for heavy mathematical computations, such as simulations and data analysis, and for parsing large data sets, for example a CSV file with 500,000 rows or a JSON file with thousands of records. They also work well for image processing (filters, compression) and fuzzy search in large collections.

You do not need them for simple state operations or for API requests with fetch or axios, because waiting for the network does not block the thread. They will not help with DOM manipulation either, because workers have no access to it. I recommend first measuring in the Performance tab whether the task takes longer than 50 ms, because from that threshold the browser considers it a long task, and only then moving it to a worker, preferably through Comlink.

Summary

  1. Web Workers - a separate thread for heavy computations
  2. Communication - postMessage / onmessage
  3. Custom useWorker hook - encapsulating the worker logic
  4. Comlink - a simplified API (async methods instead of messages)
  5. Limitations - no DOM access, a separate global scope

In the next lesson React Compiler tackles performance from the other side: instead of moving work to another thread, it skips unnecessary renders.

Remember: a Web Worker is a second onboard computer - it calculates the trajectory in the background, while the cockpit keeps listening to the captain the whole time.

Code for this lesson: App.jsx
1import React, { useState, useRef, useEffect, useCallback } from 'react';
2
3// === WEB WORKERS IN REACT ===
4// Simulation of heavy computations in a separate thread
5
6// Custom hook useWorker - encapsulation of the Worker logic
7function useWorker() {
8  const [result, setResult] = useState(null);
9  const [isRunning, setIsRunning] = useState(false);
10  const [progress, setProgress] = useState(0);
11
12  // Worker simulation (in Sandpack we can't create a real Worker file)
13  const run = useCallback((params) => {
14    setIsRunning(true);
15    setProgress(0);
16    setResult(null);
17
18    // Simulation of computations in a "separate thread" using setTimeout
19    const totalSteps = params.steps || 10;
20    let currentStep = 0;
21    const trajectory = [];
22
23    const step = () => {
24      for (let i = 0; i < 1000; i++) {
25        const t = currentStep * 0.01;
26        const x = params.speed * t * Math.cos(params.angle);
27        const y = params.speed * t * Math.sin(params.angle) - 0.5 * 9.81 * t * t;
28        trajectory.push({ x: +x.toFixed(2), y: +y.toFixed(2), t: +t.toFixed(3) });
29        currentStep++;
30      }
31      setProgress(Math.min(100, Math.round((currentStep / (totalSteps * 1000)) * 100)));
32
33      if (currentStep < totalSteps * 1000) {
34        setTimeout(step, 0); // Yield to main thread
35      } else {
36        setResult(trajectory);
37        setIsRunning(false);
38      }
39    };
40    setTimeout(step, 0);
41  }, []);
42
43  return { result, isRunning, progress, run };
44}
45
46// Trajectory panel - visualization
47function TrajectoryPanel({ trajectory }) {
48  if (!trajectory || trajectory.length === 0) return null;
49
50  const maxX = Math.max(...trajectory.map(p => Math.abs(p.x)));
51  const maxY = Math.max(...trajectory.map(p => Math.abs(p.y)));
52  const scale = Math.max(maxX, maxY) || 1;
53
54  const validPoints = trajectory.filter(p => p.y >= 0).slice(0, 200);
55
56  return (
57    <div style={styles.trajectoryPanel}>
58      <h3 style={styles.sectionTitle}>Flight Trajectory</h3>
59      <div style={styles.canvas}>
60        {validPoints.map((p, i) => (
61          <div
62            key={i}
63            style={{
64              position: 'absolute',
65              left: (p.x / scale * 45 + 50) + '%',
66              bottom: (p.y / scale * 80 + 5) + '%',
67              width: '3px', height: '3px',
68              borderRadius: '50%',
69              background: `hsl(${180 + (i / validPoints.length) * 60}, 100%, 60%)`,
70            }}
71          />
72        ))}
73      </div>
74      <p style={styles.stats}>
75        Points: {trajectory.length} | Max X: {maxX.toFixed(0)}m | Max Y: {Math.max(...trajectory.map(p => p.y)).toFixed(0)}m
76      </p>
77    </div>
78  );
79}
80
81// Control panel - interactive (UI stays responsive during computations)
82function ControlPanel({ isRunning }) {
83  const [clicks, setClicks] = useState(0);
84
85  return (
86    <div style={styles.controlPanel}>
87      <h3 style={styles.sectionTitle}>Control Panel (main thread)</h3>
88      <p style={styles.hint}>
89        {isRunning
90          ? 'Worker is computing... but the UI still works!'
91          : 'Click the button during computation - the UI does not freeze'}
92      </p>
93      <button onClick={() => setClicks(c => c + 1)} style={styles.clickBtn}>
94        Clicks: {clicks}
95      </button>
96      <div style={{ ...styles.indicator, background: isRunning ? '#ffaa00' : '#00ff88' }}>
97        {isRunning ? 'WORKER ACTIVE' : 'WORKER IDLE'}
98      </div>
99    </div>
100  );
101}
102
103function App() {
104  const { result, isRunning, progress, run } = useWorker();
105  const [speed, setSpeed] = useState(500);
106  const [angle, setAngle] = useState(45);
107  const [steps, setSteps] = useState(5);
108
109  const handleLaunch = () => {
110    run({
111      speed,
112      angle: (angle * Math.PI) / 180,
113      steps,
114    });
115  };
116
117  return (
118    <div style={styles.container}>
119      <header style={styles.header}>
120        <h1 style={styles.title}>Web Workers - Trajectory Calculations</h1>
121        <p style={styles.subtitle}>Heavy computations without blocking the UI</p>
122      </header>
123
124      <div style={styles.grid}>
125        <div style={styles.inputPanel}>
126          <h3 style={styles.sectionTitle}>Flight Parameters</h3>
127          <label style={styles.label}>
128            Speed: {speed} m/s
129            <input type="range" min="100" max="2000" value={speed}
130              onChange={e => setSpeed(+e.target.value)} style={styles.slider} />
131          </label>
132          <label style={styles.label}>
133            Angle: {angle} deg
134            <input type="range" min="10" max="80" value={angle}
135              onChange={e => setAngle(+e.target.value)} style={styles.slider} />
136          </label>
137          <label style={styles.label}>
138            Complexity: {steps}K steps
139            <input type="range" min="1" max="20" value={steps}
140              onChange={e => setSteps(+e.target.value)} style={styles.slider} />
141          </label>
142
143          <button onClick={handleLaunch} disabled={isRunning} style={styles.launchBtn}>
144            {isRunning ? 'Calculating...' : 'Calculate trajectory'}
145          </button>
146
147          {isRunning && (
148            <div style={styles.progressBar}>
149              <div style={{ ...styles.progressFill, width: progress + '%' }} />
150              <span style={styles.progressText}>{progress}%</span>
151            </div>
152          )}
153        </div>
154
155        <ControlPanel isRunning={isRunning} />
156      </div>
157
158      <TrajectoryPanel trajectory={result} />
159
160      <div style={styles.info}>
161        <h3 style={{ color: '#ffd700', fontSize: '13px', margin: '0 0 8px' }}>How it works:</h3>
162        <ul style={{ margin: 0, paddingLeft: '20px', fontSize: '12px', color: '#8892b0' }}>
163          <li>Trajectory calculations happen "in the background" (Web Worker simulation)</li>
164          <li>The control panel reacts to clicks DURING the computation</li>
165          <li>In a real app: new Worker() creates a separate thread</li>
166          <li>Communication: postMessage() / onmessage</li>
167        </ul>
168      </div>
169    </div>
170  );
171}
172
173const styles = {
174  container: { fontFamily: 'system-ui', background: '#0a0e17', color: '#e0e1dd', minHeight: '100vh', padding: '16px' },
175  header: { textAlign: 'center', marginBottom: '16px' },
176  title: { fontSize: '20px', color: '#00d4ff', margin: '0 0 4px' },
177  subtitle: { fontSize: '12px', color: '#8892b0', margin: 0 },
178  grid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' },
179  inputPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)' },
180  controlPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)' },
181  sectionTitle: { fontSize: '14px', color: '#00d4ff', margin: '0 0 10px' },
182  label: { display: 'block', fontSize: '12px', color: '#8892b0', marginBottom: '10px' },
183  slider: { width: '100%', marginTop: '4px' },
184  launchBtn: { width: '100%', padding: '10px', background: 'linear-gradient(135deg, #00d4ff, #0088cc)', color: '#0a0e17', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold', fontSize: '13px' },
185  progressBar: { marginTop: '8px', height: '20px', background: '#1a2a3c', borderRadius: '10px', position: 'relative', overflow: 'hidden' },
186  progressFill: { height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)', borderRadius: '10px', transition: 'width 0.3s' },
187  progressText: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '11px', fontWeight: 'bold', color: '#0a0e17' },
188  hint: { fontSize: '12px', color: '#8892b0', marginBottom: '10px' },
189  clickBtn: { width: '100%', padding: '12px', background: '#1a3a5c', color: '#00d4ff', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '6px', cursor: 'pointer', fontSize: '16px', fontWeight: 'bold', marginBottom: '10px' },
190  indicator: { textAlign: 'center', padding: '6px', borderRadius: '4px', fontSize: '11px', fontWeight: 'bold', color: '#0a0e17' },
191  trajectoryPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)', marginBottom: '12px' },
192  canvas: { position: 'relative', height: '200px', background: 'rgba(0,0,0,0.4)', borderRadius: '6px', overflow: 'hidden' },
193  stats: { fontSize: '12px', color: '#8892b0', marginTop: '8px', textAlign: 'center' },
194  info: { padding: '12px', background: 'rgba(255,215,0,0.05)', border: '1px solid rgba(255,215,0,0.15)', borderRadius: '8px' },
195};
196
197export default App;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What mechanism is used for communication between the main thread and a Web Worker?

  2. 2. Which task is an IDEAL candidate for moving to a Web Worker?

Useful articles