Use Web Workers for Heavy Computation Without Freezing UI

Advanced18 min interview
Skills tested:
Designing a worker-based pipeline for CPU-intensive operations like image processingUsing Transferable objects (ArrayBuffer) for zero-copy data transferImplementing a worker pool for parallelism across multiple CPU coresProviding a fallback when workers are unavailable or for environments without worker supportError handling across the postMessage boundary using structured error objects

Advertisement

🧩 Scenario

In a real codebase, image processing tasks (filters, resizing, color analysis) on user-uploaded files are the most common reason to reach for Web Workers. These operations can take 200ms to several seconds on large images. Running them on the main thread freezes scrolling, animations, and input handling for that entire duration. Offloading to a worker keeps the UI responsive while the computation runs in parallel.

Architecture Walkthrough

Designing the Message Protocol

A worker communicates with the main thread only through postMessage. Without a protocol, a worker handling multiple task types becomes hard to extend. A structured message protocol uses a type field to identify the operation and a payload field for the data. The response uses the same structure plus an id field to match responses to the original requests, enabling multiple in-flight tasks to a single worker without confusing their responses.

Workers are long-lived objects. Creating a new Worker for each task incurs OS thread startup cost (typically 50 to 100ms) and memory overhead for the new JavaScript runtime. Reusing a pool of pre-created workers eliminates this overhead for repeated tasks.

Transferable Objects for Large Data

postMessage copies data by default using the structured clone algorithm. Transferring a 10MB ArrayBuffer via structured clone copies 10MB of memory, which takes measurable time and doubles the memory footprint temporarily. Transferable objects transfer ownership instead of copying: the main thread passes the buffer in the second argument to postMessage as an array of transferable items. The transfer is O(1); the main thread's buffer becomes neutered (.byteLength === 0) and the worker owns the same memory.

After the worker finishes processing, it can transfer the result buffer back to the main thread using the same mechanism, keeping data transfer fast in both directions.

Worker Pool for Parallelism

A worker pool creates N workers at initialization (typically navigator.hardwareConcurrency or a fixed limit like 4) and maintains a queue of pending tasks. When a worker finishes a task, it picks up the next task from the queue. Each task is represented as a pending Promise that resolves when the worker posts a result.

The pool maps task IDs to their resolve/reject callbacks so the onmessage handler can route each response to the correct caller.


Key Code Explained

// worker.js: message protocol with type and id
self.onmessage = function ({ data }) {
  const { type, id, payload } = data;

  try {
    let result;

    if (type === 'GRAYSCALE') {
      result = applyGrayscale(payload.imageData); // returns ImageData or ArrayBuffer
    } else if (type === 'RESIZE') {
      result = resize(payload.imageData, payload.width, payload.height);
    } else {
      throw new Error(`Unknown task type: ${type}`);
    }

    // Transfer the result buffer back — no copy
    self.postMessage({ id, result }, [result.buffer]);
  } catch (err) {
    self.postMessage({ id, error: err.message });
  }
};

// main.js: simple worker wrapper with Promise interface
class ImageWorker {
  constructor() {
    this.worker = new Worker('/workers/image.js');
    this.pending = new Map(); // id -> { resolve, reject }
    this.nextId = 0;

    this.worker.onmessage = ({ data }) => {
      const { id, result, error } = data;
      const { resolve, reject } = this.pending.get(id);
      this.pending.delete(id);
      if (error) reject(new Error(error));
      else resolve(result);
    };

    this.worker.onerror = (err) => {
      // Reject all pending tasks if the worker crashes
      this.pending.forEach(({ reject }) => reject(new Error(err.message)));
      this.pending.clear();
    };
  }

  process(type, payload, transferables = []) {
    const id = this.nextId++;
    return new Promise((resolve, reject) => {
      this.pending.set(id, { resolve, reject });
      this.worker.postMessage({ type, id, payload }, transferables);
    });
  }

  terminate() {
    this.worker.terminate();
  }
}

// Usage: transfer the ArrayBuffer to avoid copying
const worker = new ImageWorker();
const buffer = await file.arrayBuffer(); // user-uploaded image as ArrayBuffer

const result = await worker.process('GRAYSCALE', { imageData: buffer }, [buffer]);
// buffer.byteLength === 0 here — ownership transferred to worker
// result is a new ArrayBuffer transferred back from the worker

// Fallback: requestIdleCallback for environments without Worker
async function processWithFallback(imageData) {
  if (typeof Worker === 'undefined') {
    return new Promise((resolve) => {
      requestIdleCallback(() => {
        resolve(applyGrayscale(imageData)); // runs on main thread during idle time
      });
    });
  }
  return worker.process('GRAYSCALE', { imageData }, [imageData.buffer]);
}

The this.worker.onerror handler is the most commonly forgotten piece. If a worker throws an unhandled exception, the onerror event fires on the main thread. Without this handler, all pending Promises from in-flight tasks would never resolve or reject, leaving the UI in a loading state forever.


Tradeoffs

Data transfer methodCostMain thread access after transferUse when
Structured clone (default)O(n) copyStill has originalSmall data or if you need to keep it
Transferable (ArrayBuffer)O(1) transferNeutered (byteLength === 0)Large images, video, audio data
SharedArrayBufferZero copy, shared memoryShared with workerRequires Atomics, COOP/COEP headers

What Interviewers Actually Check

  • Whether you can design a typed message protocol with IDs for matching requests to responses
  • Whether you know Transferable objects and why they are better than structured clone for large buffers
  • Whether you know to implement worker.onerror to handle worker crashes
  • Whether you can implement a worker pool rather than spawning a new worker per task
  • Whether you know a fallback strategy when Worker is not available

Follow-Up Questions

  1. How would you add a task timeout so that if a worker does not respond within N seconds, the task is cancelled and the worker is terminated?
  2. How does OffscreenCanvas work with Web Workers, and how would you use it for canvas-based rendering off the main thread?
  3. What is a ServiceWorker and how does it differ from a DedicatedWorker in terms of scope, lifecycle, and use case?
  4. How would you implement a progress reporting mechanism where the worker posts percentage updates back to the main thread during a long computation?
  5. If a worker pool has 4 workers and all 4 are busy, how does the pool handle a new task request?

Common Candidate Mistakes

  • Not using Transferable objects for ArrayBuffer and paying a full copy cost for every large image transfer in both directions
  • Creating a new Worker for each image file rather than reusing a pool, incurring startup cost on every upload
  • Not implementing worker.onerror, leaving pending Promises unresolved when the worker crashes
  • Not providing a fallback for environments where Worker is undefined, causing the feature to silently fail
  • Trying to send a DOM node, a function, or a Symbol via postMessage, which throws because those types are not cloneable

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you design a worker message protocol with type, id, and payload fields?
  • Can you explain the difference between structured clone transfer and Transferable object transfer?
  • Can you implement a basic worker pool that dispatches tasks and reuses workers?
  • Can you implement error handling that propagates worker errors to the main thread caller?
  • Can you describe a fallback strategy using requestIdleCallback for environments without worker support?

Summary

Web Workers run JavaScript in a separate OS thread. Communication happens exclusively through postMessage and onmessage. A structured message protocol with a type and id field allows a single worker to handle multiple operation types and match responses back to their originating Promises.

Data transfer is the performance bottleneck for large binary payloads. Passing an ArrayBuffer via structured clone copies the entire buffer. Passing it as a Transferable object transfers ownership in O(1) time. After transfer, the original buffer on the sending thread is neutered. The receiving thread owns the memory and can transfer it back the same way when done.

For applications that perform many worker tasks (image processing on upload, document parsing), a worker pool eliminates the per-task startup cost. The pool maintains N pre-created workers and a queue of pending tasks, dispatching each task to the first available worker and routing its response back to the caller via a pending Promise map.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

Can Web Workers access the DOM?

No. Workers run in a separate global scope and cannot access window or document directly. Use postMessage to send results back to the main thread.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement