Mastering Web Workers: Boost Performance and Avoid Common Pitfalls

This article explains how to use Web Workers to offload heavy IndexedDB log processing, compares copy versus transferable data transfer, demonstrates a Promise‑based Worker wrapper, and outlines the limitations and best practices for improving front‑end performance.

Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
Mastering Web Workers: Boost Performance and Avoid Common Pitfalls

Background

When building a user replay system we needed to read large logs from IndexedDB and report them, but the sheer volume caused heavy computation that blocked the main thread and made the browser lag. Several optimization ideas were considered: using a Web Worker, chunked reading with timed polling and retries, and gzip compression.

Use Web Worker to read and process data.

Chunked reading, timed polling, retry on error.

Compress data with gzip.

Because we lacked practical experience, we ran into pitfalls with Web Workers; this article summarizes the usage and lessons learned.

Basic Introduction

JavaScript runs on a single thread, so long‑running low‑priority tasks (such as log processing) block high‑priority UI work, causing jank. Web Workers provide a multi‑threaded environment supplied by the browser, allowing background scripts to run independently of the main page.

A web worker is a JavaScript that runs in the background, independently of other scripts, without affecting the performance of the page. You can continue to click, select, etc., while the worker runs.

In practice we can assign heavy calculations to a Worker, keeping the main thread free for UI interactions, resulting in smoother user experience.

Usage

The main thread and a Worker communicate via postMessage to send messages and addEventListener or onmessage to receive them.

Main Thread

const worker = new Worker('reportWorker.ts');
worker.postMessage({ type: WorkerReportType.ReadEventTblStart, data: count });
worker.onmessage = (event) => {
  const { type, data } = event.data;
  switch (type) {
    case WorkerReportType.ReadEventTblFinish:
      console.log('Data from worker', data);
      break;
    // ... other cases
  }
};

Worker Thread

self.onmessage = (event) => {
  const { type, data } = event.data;
  switch (type) {
    case WorkerReportType.ReadEventTblStart:
      // read and process logs
      readIndexedDB();
      break;
    // ... other cases
  }
};

Besides the two primary APIs, Workers can listen for errors:

worker.onerror((event) => {
  console.log('worker error');
});

When a Worker is no longer needed, it should be terminated:

// main thread
worker.terminate();
// worker thread
self.close();

Data Communication

Even though heavy computation in a Worker does not affect the UI, transferring large amounts of data (e.g., 5‑10 MB) can still impact performance.

Copy Transfer

By default postMessage copies data: the browser serializes the object, sends it, and deserializes it on the other side, incurring extra memory and time proportional to the data size. The following code simulates linear growth of transferred data:

for (let i = 0; i <= 50; i += 5) {
  const mockData = new Uint8Array(1024 * 1024 * i);
  const start = Date.now();
  tasks.postMessage({ type: ReadEventTblFinish, data: { mockData, size: i, start } });
}
// In the main thread, measure the time after receiving each message

Chrome output shows the transfer time increasing linearly with size.

Transferable Objects

To avoid the copy overhead, postMessage accepts a second argument – a list of transferable objects. Ownership of these objects (e.g., ArrayBuffer, MessagePort, ImageBitmap) is transferred to the receiver, and the sender can no longer use them.

Example:

tasks.postMessage({ type: ReadEventTblFinish, data: { mockData, size: i, start } }, [mockData.buffer]);

Chrome output shows a dramatic reduction in transfer time.

After the transfer the data becomes empty, confirming that ownership was moved.

Two caveats: only certain types implement the Transferable interface (ArrayBuffer, MessagePort, ImageBitmap); converting large objects to an ArrayBuffer adds its own cost. Also, if many small elements need to be transferred, the mapping step can become a performance bottleneck.

SharedArrayBuffers

SharedArrayBuffer allows both the main thread and a Worker to read/write the same memory region, eliminating transfer latency. However, it introduces race conditions and has limited browser support, so it is generally discouraged.

Promise‑Wrapped Worker Communication

Using postMessage and onmessage works but scatters logic across files. Wrapping the communication in a Promise simplifies usage.

Key points of the PromiseWorker class:

Maintain a Map from message type to the corresponding resolve function. postMessage sends the message and stores the resolver.

The Worker’s onmessage handler looks up the resolver by type, resolves the Promise with the data, and removes the entry.

export default class PromiseWorker {
  private handlerMap = new Map<number, Function>();
  private worker: Worker;
  constructor(worker: Worker) {
    this.worker = worker;
    this.worker.onmessage = (event) => {
      const { type, data } = event.data;
      const resolve = this.handlerMap.get(type);
      if (!resolve) return;
      resolve(data);
      this.handlerMap.delete(type);
    };
  }
  postMessage(message: WorkerMessage) {
    const { type } = message;
    return new Promise((resolve) => {
      this.worker.postMessage(message);
      this.handlerMap.set(type, resolve);
    });
  }
}

Usage example:

// Main thread
const reportWorker = new PromiseWorker(new ReportWorker());
reportWorker.postMessage({ type: WorkerReportType.ReadEventTbl, data: count })
  .then((data) => {
    console.log('read event table finish', data);
  });

// Worker thread
self.onmessage = async (event) => {
  const { type, data } = event.data;
  switch (type) {
    case WorkerReportType.ReadEventTbl:
      const result = await /* processing */;
      self.postMessage({ type, data: result });
      break;
    // ... other cases
  }
};

This pattern lets developers focus on business logic without manually matching request and response types.

Limitations of Web Workers

Workers cannot access the DOM (no window, document, parent), though they can use navigator and location.

Data is transferred by copy; functions or other non‑serializable values cannot be sent.

Workers have no access to localStorage.

Worker scripts must be served from the same origin as the main page.

Workers cannot call alert or confirm, but can use APIs like setTimeout and XMLHttpRequest.

Workers cannot read local files via file://; scripts must be loaded over the network.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaScriptWeb WorkerPromiseTransferable
Tencent IMWeb Frontend Team
Written by

Tencent IMWeb Frontend Team

IMWeb Frontend Community gathering frontend development enthusiasts. Follow us for refined live courses by top experts, cutting‑edge technical posts, and to sharpen your frontend skills.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.