跪拜 Guibai
← Back to the summary

Offloading Heavy React Computation to Web Workers Without Freezing the UI

Today's Highlights

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

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

  1. Can Event Loop asynchrony solve the problem of a for loop freezing the page? Why?
  2. Why doesn't the browser allow Workers to manipulate the DOM?

Reference Answers

  1. 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.
  2. 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

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

  1. Can a Worker call document.querySelector? Why?
  2. Can a Worker make fetch requests?

Reference Answers

  1. No. There is no document object or DOM tree inside a Worker; this is an intentional security restriction.
  2. 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

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

  1. Why use useRef instead of useState for the Worker?
  2. Why put new Worker() inside useEffect?
  3. In new URL('./worker.js', import.meta.url), what is ./worker.js resolved relative to?

Reference Answers

  1. 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.
  2. 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.
  3. It is resolved relative to the current JS file (App.jsx). import.meta.url is the full URL of the current module; ./worker.js is 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

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

  1. What does e.data represent in App.jsx vs. worker.js?
  2. Are const { num } = e.data and const num = e.data.num equivalent?
  3. The main thread uses workerRef.current.postMessage(). What does the Worker use?

Reference Answers

  1. 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.
  2. Completely equivalent; destructuring { num } is shorthand for e.data.num.
  3. The Worker uses self.postMessage(); self points 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

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

  1. What is the hidden danger of only calling terminate() without setting to null?
  2. When does the cleanup function return () => {} execute?

Reference Answers

  1. The reference still points to the destroyed Worker instance; subsequent mistaken calls to postMessage will fail silently, making them hard to debug.
  2. 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

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

dzhd

[smile]