Offloading Heavy React Computation to Web Workers Without Freezing the UI
Today's Highlights
- Under JS's single thread, the Event Loop solves "waiting" but cannot solve "the computation itself being too heavy."
- Web Workers open independent threads; they cannot manipulate the DOM, but they can compute, make requests, and read/write storage.
- useRef persistently stores the Worker instance; component re-renders will not reset the thread object.
- useEffect initializes after mounting, letting rendering go first so the Worker does not block the first screen.
- postMessage sends, onmessage receives; the format is the same on both sides, only the direction differs.
- Destructure
const { num } = e.datato extract the needed field from the message object. - On unmount, terminate() kills the thread, then set to null to clear the reference.
Knowledge Relationship
useRef creates a box → useEffect mounts then new Worker is placed inside → postMessage/onmessage for two-way communication → on unmount, terminate + set to null for cleanup. This chain is the standard pattern for useRef + Worker.
The Single-Thread Dilemma
Key Points
JS is single-threaded and can only do one thing at a time. For "waiting-type" tasks like network requests, the Event Loop's asynchronous mechanism can cope — toss them aside to wait, and the main thread continues responding to the user. But pure computational tasks are different: a for loop running hundreds of millions of times occupies the thread completely, freezing the page.
Your notes summarize it: for non-UI heavy business logic like LLMs or games, Event Loop asynchrony cannot handle it.
Code
// Main thread directly runs a massive loop → page freezes
console.time('Main thread')
for (let i = 0; i < 1000000; i++) {
console.log(i)
}
console.timeEnd('Main thread')
// The user cannot click anything during the entire process
Execution Process
User triggers computation → Main thread enters for loop
→ JS engine is monopolized, rendering engine is blocked
→ Page freezes, clicks and scrolls are completely unresponsive
→ Only recovers after the loop finishes
Breakdown
- All code between
console.timeandconsole.timeEndexecutes on the main thread. - During this period, the browser cannot handle user interaction events, and rendering cannot proceed.
- Asynchrony can only postpone task execution, but it cannot make the task itself faster or smaller.
Why This Design
Browsers chose a single thread to avoid conflicts from multiple threads modifying the DOM simultaneously. But the cost is that heavy computation freezes the page. Web Workers were created precisely to remedy this defect — tasks that don't touch the DOM are all thrown to another thread.
Easy to Confuse
Event Loop and Web Worker are not the same thing.
| Event Loop | Web Worker | |
|---|---|---|
| Thread | Still the main thread | Independent new thread |
| Solves | Asynchronous waiting without blocking the main thread | Heavy computation without occupying the main thread |
| Suitable for | fetch, timers, event callbacks | Massive loops, image processing, game logic |
| Essence | A queuing mechanism within a single thread | True multi-threading |
Self-Test
- Can Event Loop asynchrony solve the problem of a for loop freezing the page? Why?
- Why doesn't the browser allow Workers to manipulate the DOM?
Reference Answers
- No. Asynchrony just moves the task to execute later, but the for loop itself still has to run on the main thread. While it runs, it still monopolizes the thread, and the page still freezes.
- To avoid conflicts from multiple threads modifying the same DOM node simultaneously. Data consistency takes priority over functional convenience.
What is a Worker
Key Points
A Web Worker is an independent thread provided by the browser, with its own memory space. It cannot manipulate the DOM (no document, no window), but it can do much more than just mathematical calculations — fetch requests, IndexedDB reads/writes, Blob processing, and timers can all be used.
Code
// worker.js — Script executed in an independent thread
self.onmessage = (e) => {
const { num } = e.data
let sum = 0
for (let i = 0; i < 50000000; i++) {
sum += num * i
}
self.postMessage({ result: sum })
}
Execution Process
Worker thread is created by new Worker() → Immediately executes top-level code
→ Registers self.onmessage listener
→ Waits for the main thread to send a message
→ After receiving the message, starts computing (main thread is completely unaffected)
→ Computation finishes → self.postMessage sends the result back to the main thread
Breakdown
selfin a Worker is equivalent towindowon the main thread, pointing to the Worker's own global scope.self.onmessageregisters a message listener; everypostMessagefrom the main thread triggers this callback.- Computation inside the Worker does not occupy the main thread; the two threads execute in parallel.
Why This Design
Workers have only a pure JS environment, deliberately removing DOM APIs. The reason is consistent with the original single-thread design: to avoid race conditions from multiple threads operating on the page.
Self-Test
- Can a Worker call
document.querySelector? Why? - Can a Worker make fetch requests?
Reference Answers
- No. There is no
documentobject or DOM tree inside a Worker; this is an intentional security restriction. - Yes. APIs like
fetch, WebSocket, and IndexedDB that do not depend on the DOM can all be used inside a Worker.
useRef Holds the Worker
Key Points
The Worker instance does not need to be displayed on the page; store it with useRef, not useState. The object returned by ref points to the same address on every render, so the Worker won't be lost or recreated due to component re-renders.
useEffect with an empty dependency array ensures it executes only once after mounting — letting the component render first and the page appear first, so the Worker does not block the first screen.
Code
const workerRef = useRef(null)
useEffect(() => {
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
)
// Logic for listening, communication, etc.
}, [])
Execution Process
Component function executes → useRef(null) creates { current: null }
→ Returns JSX → React renders DOM → Page appears
→ useEffect callback executes → new Worker() creates the thread
→ workerRef.current changes from null to the Worker instance
→ In subsequent renders, workerRef.current is always the same Worker instance
Breakdown
useRef(null)initial null: At this point, the Worker hasn't been created, and the component hasn't mounted.useEffect(..., []): Executes only after mounting, at which point the DOM is ready.workerRef.current = new Worker(...): Manually places the Worker instance into the ref's.current.- Difference from DOM refs: DOM refs have
.currentfilled automatically by React; Worker refs are filled manually by you.
Why This Design
Why not useState? Changes to the Worker instance don't need to trigger renders; using state would waste a meaningless update.
Why not directly new Worker() at the top level of the function body? The function body executes on every render, which would repeatedly create new Worker instances without destroying the old ones, causing a memory leak.
Why put it in useEffect? Render first — new Worker() has overhead. If placed on the rendering path, it would slow down the first screen. Putting it in useEffect is like "after rendering the first glance of the page, then slowly create the Worker."
Easy to Confuse
Two loading path approaches:
// Method 1: File in public/ directory, write the path directly
new Worker('/worker.js')
// Method 2: File in src/ directory, construct with URL
new Worker(new URL('./worker.js', import.meta.url))
Direct path /worker.js |
URL construction ./worker.js |
|
|---|---|---|
| File location | public/ directory | src/ directory |
| Vite handling | Loaded as-is, not compiled | Compiled and bundled by Vite |
| { type: 'module' } | Not needed (classic script) | Optional, Vite dev mode compatible |
Self-Test
- Why use useRef instead of useState for the Worker?
- Why put
new Worker()inside useEffect? - In
new URL('./worker.js', import.meta.url), what is./worker.jsresolved relative to?
Reference Answers
- The Worker instance does not need to trigger renders; useRef is sufficient for storage. Using useState would trigger a meaningless re-render on every modification.
- The function body executes on every render; putting it at the top level would repeatedly create Workers, causing a memory leak. useEffect with an empty dependency array executes only once after mounting. It also lets rendering complete first, so the Worker doesn't block the first screen.
- It is resolved relative to the current JS file (App.jsx).
import.meta.urlis the full URL of the current module;./worker.jsis appended to get the worker.js in the same directory.
Message Mechanism
Key Points
The main thread and Worker have no shared memory; the only communication method is messages — postMessage sends, onmessage receives. "message" means "message/information." You can think of it as an envelope: postMessage puts data into an envelope and sends it out, onmessage opens the letter upon receipt, and e.data is the content inside.
The format is exactly the same on both sides; the only difference is the calling object: the main thread uses workerRef.current.postMessage, the Worker uses self.postMessage.
Code
// App.jsx — Sends commands and receives results
const startHeavyCalc = () => {
setLoading(true)
workerRef.current.postMessage({ num: 88 })
}
// Register listener in useEffect
workerRef.current.onmessage = (e) => {
const { result } = e.data
setResult(result)
setLoading(false)
}
// worker.js — Receives commands and sends results
self.onmessage = (e) => {
const { num } = e.data
let sum = 0
for (let i = 0; i < 50000000; i++) {
sum += num * i
}
self.postMessage({ result: sum })
}
Execution Process
1. User clicks the button
2. startHeavyCalc → setLoading(true), button grays out
3. postMessage({ num: 88 }) → Message sent
4. Worker: onmessage triggers → e.data = { num: 88 }
5. Destructure to get num → Starts 50 million iterations
6. Main thread is idle, page is smooth, user can interact normally
7. Worker finishes → self.postMessage({ result: sum })
8. App: onmessage triggers → e.data = { result: sum }
9. Destructure to get result → setResult + setLoading(false) → Page displays result
Breakdown
workerRef.current.postMessage({ num: 88 }): Main thread sends;{ num: 88 }is the transmitted data.self.onmessage = (e) => {}: Worker receives;eis the MessageEvent,e.datais the data sent.self.postMessage({ result: sum }): Worker sends back;{ result: sum }will be in the main thread'se.data.- Whatever you pass, the other side's
e.datais exactly that; each side speaks its own content. const { num } = e.data: Object destructuring, equivalent toconst num = e.data.num, extracting only the needed property and ignoring others (liketype).
Easy to Confuse
The e and e.data on both sides:
Main thread onmessage:
e is the MessageEvent sent by the Worker
e.data = { result: sum } ← Sent by Worker's self.postMessage
Worker onmessage:
e is the MessageEvent sent by the main thread
e.data = { num: 88 } ← Sent by App's workerRef.current.postMessage
| App's e.data | Worker's e.data | |
|---|---|---|
| Sent by | Worker's self.postMessage | App's postMessage |
| Content | Computation result | Computation command |
| Direction | Worker → Main Thread | Main Thread → Worker |
Self-Test
- What does
e.datarepresent in App.jsx vs. worker.js? - Are
const { num } = e.dataandconst num = e.data.numequivalent? - The main thread uses
workerRef.current.postMessage(). What does the Worker use?
Reference Answers
- In App, e.data is the computation result
{ result: sum }sent back by the Worker; in Worker, e.data is the command{ num: 88 }sent by the main thread. - Completely equivalent; destructuring
{ num }is shorthand fore.data.num. - The Worker uses
self.postMessage();selfpoints to the Worker's own global scope.
Destroying the Worker
Key Points
The Worker must be destroyed when the component unmounts, otherwise the thread will persist and occupy memory. First call terminate() to immediately stop the thread, then set the reference to null to prevent subsequent code from mistakenly using the destroyed instance.
Code
useEffect(() => {
workerRef.current = new Worker(...)
return () => {
workerRef.current.terminate() // ① Kill thread, free memory
workerRef.current = null // ② Clear reference, prevent dangling pointer
}
}, [])
Breakdown
terminate(): Immediately terminates the Worker thread; the browser reclaims memory.= null: Clears the reference at the JS level. If subsequent code mistakenly calls.postMessage(), it will error immediately rather than fail silently.- The two steps cannot be reversed: kill the thread first, then clear the reference.
Why This Design
If you only terminate without setting to null, subsequent code accessing workerRef.current will still get the destroyed Worker. Calling postMessage on it will fail silently, making debugging difficult. Setting to null exposes errors as early as possible.
Self-Test
- What is the hidden danger of only calling
terminate()without setting to null? - When does the cleanup function
return () => {}execute?
Reference Answers
- The reference still points to the destroyed Worker instance; subsequent mistaken calls to postMessage will fail silently, making them hard to debug.
- When the component is unmounted from the DOM (e.g., route navigation, conditional rendering hiding), React executes the cleanup function returned by useEffect.
Stringing the Code Together
User clicks button
→ setLoading(true) button grays out
→ postMessage({ num: 88 }) sent to Worker
→ Worker onmessage triggers, e.data = { num: 88 }
→ Destructure { num } to get value
→ for loop 50 million times (main thread not blocked, page smooth)
→ self.postMessage({ result: sum }) sends result
→ App onmessage triggers, e.data = { result: sum }
→ Destructure { result } to get value
→ setResult(result) + setLoading(false) displays result
Component unmounts
→ terminate() kills thread
→ = null clears reference
Final Review
useRef + Web Worker boils down to three things: useRef creates a box for persistence → useEffect initializes new Worker after mounting → postMessage / onmessage for two-way communication. On unmount, terminate + set to null.
The core to remember: Workers don't touch the DOM but can do many things; the message mechanism is the only communication channel; useRef is not afraid of re-renders; useEffect lets rendering go first.
Self-Check Checklist
- Can the Event Loop solve a for loop freezing the page? Why?
- What can a Worker do, and what can't it do?
- Why use useRef instead of useState for the Worker?
- Why put
new Worker()in useEffect instead of at the function top level? - What content does
e.datahold on the main thread vs. the Worker? What fields does each contain? - What is
const { num } = e.dataequivalent to? - What is the difference between
new Worker('/worker.js')andnew URL('./worker.js', import.meta.url)? - Why terminate first and then set to null on unmount?
- What is
selfinside a Worker?
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
[smile]