DEV Community

Cover image for Web Workers — Stop Freezing the UI With This Practical Guide
YT Low
YT Low

Posted on Originally published at onlywebpro.com

Web Workers — Stop Freezing the UI With This Practical Guide

Full guide with a live frozen vs smooth UI demo, Comlink setup, transferable objects, and React hook on
onlyWebPro.

JavaScript on the main thread shares one thread with rendering, event handling, and layout. A heavy computation — sorting 200k rows, parsing a 10MB CSV — blocks everything until it finishes. The UI freezes.

Web Workers move that computation to a separate OS thread. The main thread stays completely free.


Why async/await doesn't solve this

Promises and async/await are still single-threaded. They let you defer work so other tasks can run in between — but if the computation itself is CPU-bound, the thread is still blocked while it runs. Web Workers are a separate thread. That's a completely different thing.


The basic pattern

// worker.js — runs in its own thread
self.onmessage = function(event) {
  const { n } = event.data;
  const result = findNthPrime(n);   // heavy computation here
  self.postMessage({ result });
};

// main.js — creates and communicates with the worker
const worker = new Worker('worker.js');
worker.onmessage = ({ data }) => console.log(data.result);
worker.postMessage({ n: 8000 });
Enter fullscreen mode Exit fullscreen mode

Comlink — the right way in 2026

Raw postMessage gets messy fast: manual message routing, matching requests to responses, propagating errors. Comlink (1.1kB, Google) wraps all of that and lets you call worker functions as regular async functions.

// heavy.worker.js
import * as Comlink from 'comlink';

Comlink.expose({
  async findNthPrime(n) { /* ... */ },
  async sortLargeArray(arr) { return [...arr].sort((a, b) => a - b); },
  async parseCSV(text) { return text.trim().split('\n').map(r => r.split(',')); }
});

// main.js
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./heavy.worker.js', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);

// Feels like a local async call — no postMessage in sight
const prime  = await api.findNthPrime(8000);
const sorted = await api.sortLargeArray(hugeArray);
Enter fullscreen mode Exit fullscreen mode

Transferable objects — zero-copy data transfer

By default, postMessage deep-copies data. For a 50MB ArrayBuffer that's ~180ms of copy time — defeating the purpose of the worker. Transferable objects transfer ownership in ~0.1ms regardless of size.

const buffer = new ArrayBuffer(50 * 1024 * 1024); // 50 MB

// ❌ Copy — ~180ms for 50MB
worker.postMessage({ buffer });

// ✅ Transfer — ~0.1ms, buffer unusable on main thread afterward
worker.postMessage({ buffer }, [buffer]);
Enter fullscreen mode Exit fullscreen mode

Use transfer for: ArrayBuffer, ImageBitmap, OffscreenCanvas, streams.


React hook — clean lifecycle management

export function useWorker(workerUrl) {
  const workerRef = useRef(null);
  const apiRef    = useRef(null);

  useEffect(() => {
    workerRef.current = new Worker(workerUrl, { type: 'module' });
    apiRef.current    = Comlink.wrap(workerRef.current);
    return () => workerRef.current?.terminate(); // always clean up
  }, []);

  return apiRef;
}
Enter fullscreen mode Exit fullscreen mode

When to use a worker (and when not to)

✅ Use for: sorting/filtering 100k+ rows, parsing large files, image processing, running a search index, encryption/hashing

❌ Don't use for: fetch calls (already async), DOM updates (workers have no DOM access), computations under ~5ms (message overhead isn't worth it)


The full guide has a live demo where you can see a bouncing animation freeze on the main thread and keep running with a worker — same computation, completely different experience.

👉 Read the full guide + live demo


Part 4 — the final post — of the Frontend Performance Series on onlyWebPro.

Have you shipped Web Workers in a production app? What was the use case?

Top comments (0)