Offloading Heavy Computation in React with Web Workers and useRef
useRef + Web Worker in Practice: How React Gracefully Embraces Multithreading
Starting from the bottleneck of JavaScript's single thread, this article delves layer by layer into the inadequacy of the Event Loop, the multithreading approach of Web Workers, and the bridging role of useRef within it—using a complete demo to connect the entire chain of React side-effect management, message communication, and resource cleanup.
1. JavaScript is Single-Threaded: Why Was It Designed This Way?
1.1 The Frontend's Original Job
From its birth, JavaScript was meant to do one thing: add interactivity to web pages—form validation, click pop-ups, small animations. These tasks are characterized by:
- Lightweight tasks, high frequency
- Must work closely with the DOM
- Users expect instant responses
If JavaScript were multithreaded, what would happen if two threads tried to modify the same <div> at the same time? One thread wants to delete a node, the other wants to change its text—this creates race conditions and data inconsistency. For "display and operation consistency," single-threading is the safest default choice.
1.2 The Cost of Single-Threading
// This code will completely freeze the page
for (let i = 0; i < 100000000; i++) {
console.log(i);
}
During the execution of 100 million iterations, the browser is completely unable to respond to any user actions—clicks are ineffective, scrolling freezes, animations drop frames. Because the main thread is monopolized by this synchronous code.
Main Thread Timeline:
┌──────────────────────────────────────────────────────┐
│ [100 million iterations... CPU 100%] │
│ ↓ User clicks button (queued waiting) │
│ ↓ User scrolls page (queued waiting) │
│ ↓ ... │
│ ↓ Loop ends │
│ → Process queued events │
│ → User feels the "lag" │
└──────────────────────────────────────────────────────┘
2. Can the Event Loop Save Us?
2.1 Asynchronous ≠ Multithreaded
console.log('1');
setTimeout(() => {
console.log('2'); // Asynchronous, placed in the macrotask queue
}, 0);
console.log('3');
// Output order: 1 → 3 → 2
The Event Loop's mechanism is non-blocking—it suspends time-consuming operations and goes off to do other things first. But the key insight is:
The Event Loop is just a "task scheduling strategy" on the same thread; it does not open new threads.
The Main Thread's Single Execution Stack:
┌──────────────────────────────────────────────┐
│ Sync Code → Microtask Queue (Promise) → Macrotask Queue (setTimeout) │
│ All tasks in the queues eventually return to the same main thread for execution │
│ It still lags—it just "lags later" │
└──────────────────────────────────────────────┘
2.2 What Scenarios Can the Event Loop Not Handle?
When a task is computationally intensive—LLM inference, game physics engines, massive encryption/decryption—even if you break it into multiple asynchronous chunks, the execution of each chunk still occupies the main thread's single lane. The page will stutter intermittently.
Requirements have changed: It's no longer just "click a button to pop up a window"
It's "run a local LLM model for inference"
"Render a 3D game scene in real-time"
"Encrypt 1GB of data"
→ Event Loop's asynchronous scheduling is no longer sufficient
→ True parallel computation is needed
→ Web Workers arrive
3. Web Workers: The Browser's Multithreading Solution
3.1 JavaScript's Single Thread Hasn't Changed—But the Browser Is Multithreaded
This is a key insight:
┌──────────────────────────────────┐
│ Browser (C++ Program) │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ JS Main │ │ Worker │ │
│ │ Thread │ │ Thread │ │
│ │ (V8 Engine)│ │ (Independent V8) │
│ │ │ │ │ │
│ │ DOM Ops ✓ │ │ DOM Ops ✗ │ │
│ │ UI Render ✓│ │ Pure Calc ✓│ │
│ └──────────┘ └──────────┘ │
│ │ │ │
│ └── postMessage ─┘ │
│ Message Communication │
└──────────────────────────────────┘
The browser itself is a multi-process, multi-threaded software written in C++. A Web Worker is a new JS runtime environment provided by the browser—an independent V8 engine instance, independent memory heap, independent event loop. It is physically isolated from the main thread and does not interfere with it.
Therefore:
JavaScript's single-threaded mechanism hasn't changed—you still think in a single-threaded way when writing JS code. It's just that when needed, the browser opens another "single-threaded JS environment" to help you do parallel work.
3.2 Hard Limitations of Workers
- ❌ Cannot access the DOM—there is no
document, nowindowinside a Worker - ❌ Cannot directly manipulate the page—UI updates can only be done by notifying the main thread via messages
- ✅ Has its own API subset—
self,postMessage,onmessage,fetch,Math, etc.
This fits perfectly with React's philosophy—React already helps us manipulate the DOM, so the Worker can focus on pure computation.
4. Complete Demo Breakdown
4.1 Main Thread: App.jsx
import { useRef, useState, useEffect } from 'react';
function App() {
console.log('main thread');
// Make way for component render mount — prioritize UI rendering, Worker can wait
const workerRef = useRef(null); // Persistently hold the Worker instance
const [result, setResult] = useState(null); // Reactive: calculation result
const [loading, setLoading] = useState(false); // Reactive: loading state
useEffect(() => {
// ① After component mount: Create Worker (expensive, put inside effect)
workerRef.current = new Worker(
new URL("./worker.js", import.meta.url)
);
// ② Listen for Worker messages
workerRef.current.onmessage = (e) => {
console.log(e);
const { result } = e.data;
setResult(result); // Write calculation result to state → trigger UI update
setLoading(false); // Turn off loading state
};
// ③ On component unmount: Clean up Worker
return () => {
workerRef.current.terminate(); // Immediately terminate the thread
workerRef.current = null; // Manually reclaim the reference
};
}, []);
const startHeavyCalc = () => {
setLoading(true);
// ④ Message mechanism: Send task instructions to Worker
workerRef.current.postMessage({
num: 88
});
};
return (
<div style={{ padding: "30px" }}>
<h2>useRef + WebWorker Time-Consuming Calculation</h2>
<p>Start a web worker thread to execute 5 billion iterations, notify the main thread upon completion</p>
<button
onClick={startHeavyCalc}
disabled={loading}
>
{loading ? "Calculating in background..." : "Start Heavy Calculation Task"}
</button>
{result && <h3>Calculation Result: {result}</h3>}
</div>
);
}
4.2 Worker Thread: worker.js
// Web Worker independent sub-thread — cannot use DOM APIs, has its own API subset
// The self keyword points to the Worker's own global scope
self.onmessage = (e) => {
const { num } = e.data;
console.log('Worker received main thread task, parameter is:', e.data);
let sum = 0;
for (let i = 0; i < 5000000000; i++) { // 5 billion iterations!
sum += num * i;
}
// Calculation complete, send the result back to the main thread
self.postMessage({
result: sum
});
};
4.3 Complete Data Flow Timeline
User clicks button
│
▼
startHeavyCalc()
├── setLoading(true) → UI immediately shows "Calculating..."
└── workerRef.current.postMessage({ num: 88 })
│
▼ (Message crosses thread boundary)
Worker thread receives onmessage
│
▼
Execute 5 billion iterations
(CPU 100%, but not on the main thread!
The main thread can still respond to user clicks and scrolling at this moment!)
│
▼
self.postMessage({ result: xxx })
│
▼ (Message crosses thread boundary)
Main thread workerRef.current.onmessage triggers
│
▼
setResult(result) → UI updates, displays result
setLoading(false) → Button becomes clickable again
Core Experience: While the 5 billion iterations are running, the page's buttons, scrolling, and input fields all function normally—because the computation happens in another thread's another V8 instance, and the main thread's event loop is completely undisturbed.
5. Line-by-Line Interpretation of Key Design Decisions
5.1 Why useRef Instead of useState to Store the Worker?
const workerRef = useRef(null);
| Comparison | useRef | useState |
|---|---|---|
| Storing Value | workerRef.current = ... |
setWorker(...) |
| Triggers Re-render | ❌ No | ✅ Yes |
| Value After Re-render | Still exists | Still exists |
| Need to Trigger Render | Worker instance changing ≠ UI needs refresh | — |
The Worker instance is just "the guy working in the background." Its transition from null to a Worker object, sending messages, receiving messages—none of these operations need to drive a UI update. What truly needs to drive the UI are result and loading, which use useState. This is "each doing its own job."
5.2 Why new Worker Inside useEffect?
The meaning of the comment "Make way for component render mount":
Timeline:
① Render Phase: App() executes → returns JSX → generates DOM
(At this point, workerRef.current is still null)
② Commit Phase: DOM mounts to page → User sees the interface
③ Effect Phase: useEffect callback executes → new Worker → workerRef.current points to Worker
(The user has already seen the interface; Worker creation does not block the first render)
If new Worker() were written inside the component function body (render phase), the first render would wait for the Worker to be created before displaying the interface—although Worker creation is fast, this is a good architectural habit: rendering first, side effects later.
5.3 postMessage: A Message Mechanism Based on String Serialization
// Main → Worker
workerRef.current.postMessage({ num: 88 });
// Worker → Main
self.postMessage({ result: sum });
Underlying mechanism: Internally, postMessage performs structured cloning (Structured Clone Algorithm)—it doesn't pass references but makes a complete copy of the data to send to the other thread.
Main Thread Memory Worker Thread Memory
{ num: 88 } ──copy──► { num: 88 }
This is the embodiment of "two threads being isolated"—they share no memory, and communication relies entirely on message copying. This also means:
- Passing large objects incurs a copying overhead; it's recommended to pass key data rather than entire datasets
- Cannot pass functions, cannot pass DOM nodes
5.4 Cleanup Function: Whoever Creates, Destroys
return () => {
workerRef.current.terminate(); // ① Immediately terminate the Worker thread
workerRef.current = null; // ② Clear the reference to help GC
};
terminate(): Browser API, immediately kills the Worker thread and releases its independent memory. No matter what the Worker is doing—looping, waiting, calculating—it terminates directly.= null: Semantically marks "this Worker is dead" and helps V8 garbage collection.
Why is this step necessary?
Without cleanup, the Worker remains alive after the component unmounts:
Component Unmounts → Fiber Reclaimed → JS Reference Lost
→ Worker thread still running 💀
→ Memory leak
→ Worker completes and postMessage → No one receives it → Error
This is the core paradigm of React side-effect management: symmetry—resources created in Setup must be destroyed in Cleanup.
6. What Scenarios Are Web Workers Suitable For?
According to the summary from the readme notes:
| Suitable | Not Suitable |
|---|---|
| Game engine physics calculations | DOM operations (no DOM API in Worker) |
| Local LLM model inference | Tasks requiring direct UI modification |
| Intensive encryption/decryption | Lightweight async requests (fetch is enough) |
| Big data processing / sorting | Simple state management |
| Image / audio-video processing | Logic dependent on the window object |
A one-sentence judgment: Is this task pure computation (doesn't touch the DOM) and takes more than 50ms (one frame's budget)? Yes → Consider a Worker.
7. Complete Correspondence Table for Readme Notes
| Note Point | Article Location |
|---|---|
| JS single-threaded, Event Loop mechanism | Chapters 1, 2 |
| Complex tasks the Event Loop can't handle | Chapter 2, Section 2 |
| Web Worker threads — independent threads provided by the browser | Chapter 3 |
| Worker cannot access DOM, communicates via message mechanism | Chapter 3, Section 2 + Chapter 5, Section 3 |
Instantiating Worker: new Worker(new URL(...)) |
Chapter 4, Section 1 |
| Message mechanism: postMessage / onmessage | Chapter 4, Section 1 + Chapter 5, Section 3 |
| JS single thread hasn't changed; the browser is multithreaded | Chapter 3, Section 1 |
| Main thread and Worker are isolated, do not interfere with each other | Chapter 3, Section 1 + Chapter 5, Section 3 |
| useRef persistently holds the Worker instance | Chapter 5, Section 1 |
| useEffect initializes after mount, prioritizes rendering | Chapter 5, Section 2 |
| Listening + Sending data | Chapter 4, Section 1 + Chapter 5, Section 3 |
| Destroy thread on component unmount (terminate + = null) | Chapter 5, Section 4 |
| JS is still a single-threaded language | Chapter 3, Section 1 |
8. Summary
┌─────────────────────────────────────────────────────┐
│ Architecture Panorama │
│ │
│ Main Thread Worker Thread│
│ ┌──────────────┐ ┌──────────┐ │
│ │ React Component│ │ Pure Calc │ │
│ │ Tree │ postMessage │ Logic │ │
│ │ │ │ │ │
│ │ useState ────┼──── Drives UI ─────►│ Intensive│ │
│ │ ↑ │ │ Loop │ │
│ │ │ Data Bind│ ◄─── onmessage ───│ LLM Inf │ │
│ │ │ │ Result Return │ Encrypt │ │
│ │ useRef ──────┼── Holds Ref, No Render│ Game Eng │ │
│ │ │ └──────────┘ │
│ │ useEffect ───┼── Create/Destroy Worker │
│ └──────────────┘ │
│ │
│ Core Principles: │
│ · Reactive belongs to useState (result, loading) │
│ · Non-reactive belongs to useRef (Worker instance)│
│ · Creation and destruction belong to useEffect (symmetry) │
│ · Pure computation belongs to Worker (no DOM) │
│ · Communication relies on postMessage (structured clone) │
└─────────────────────────────────────────────────────┘
One-sentence conclusion: useRef holds a Worker reference that doesn't trigger renders, useEffect manages its lifecycle, the main thread sends tasks via postMessage and receives results via onmessage—the page remains smooth throughout the entire process. JavaScript is still a single-threaded language, but the browser, through the "auxiliary channel" of Web Workers, gives the frontend the genuine ability to handle complex computations.