跪拜 Guibai
← Back to the summary

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:

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

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:

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
};

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.