Web Workers for Performance: Offload the Main Thread and Fix Your INP

Move heavy JavaScript off the main thread with web workers and watch your INP score drop. Practical examples with Comlink, Partytown, transferable objects, and SharedArrayBuffer — plus how to measure the real impact on Core Web Vitals.

JavaScript is single-threaded. Every line of code you write — from rendering the DOM to handling click events to crunching data — competes for time on the same main thread. When a heavy computation blocks that thread for 200 milliseconds, users feel it: buttons stop responding, animations stutter, and your Interaction to Next Paint (INP) score tanks.

Web Workers are the browser's built-in answer to this problem, and honestly, they're one of the most underused tools in the web performance toolkit. They let you spawn background threads that run JavaScript in parallel without touching the main thread. Every modern browser has supported them since 2012. So why aren't more developers using them?

In this guide, we'll walk through how to identify main-thread bottlenecks, offload them to web workers, and measure the real impact on your Core Web Vitals — especially INP.

Why the Main Thread Is Your Performance Bottleneck

The browser's main thread handles a truly absurd amount of work: parsing HTML, calculating styles, running layout, painting pixels, executing JavaScript, and processing user input. All of it shares a single execution context. When JavaScript runs for too long, it creates what Chrome DevTools calls a long task — any task exceeding 50 milliseconds.

Long tasks are the primary cause of poor INP scores. INP measures the latency of every user interaction throughout the page lifecycle and reports the worst one (at the 98th percentile). If a user clicks a button while a long task is running, the browser literally can't process that interaction until the task completes. The result? Sluggish, unresponsive interfaces that hurt both user experience and search rankings.

Common sources of main-thread congestion include:

  • Data processing — sorting, filtering, or transforming large arrays
  • Serialization — JSON parsing or stringifying large payloads
  • Client-side search — fuzzy matching or full-text indexing
  • Image manipulation — resizing, cropping, or extracting metadata
  • Cryptographic operations — hashing, encryption, or token validation
  • Third-party scripts — analytics, ads, and chat widgets all fighting for CPU time

Any of these can push your INP past the 200ms "good" threshold. Web workers let you move this work off the main thread entirely.

What Are Web Workers?

A web worker is a JavaScript file that runs in a separate background thread, completely independent of the main thread. Workers have their own global scope (self instead of window), their own event loop, and — this is the key part — no access to the DOM. Communication between the main thread and a worker happens through a message-passing API using postMessage() and onmessage.

Types of Web Workers

There are three types of workers, each suited to different use cases:

  • Dedicated Workers — owned by a single script. Best for offloading computation from a specific page or component. This is the one you'll reach for most often.
  • Shared Workers — accessible from multiple scripts, tabs, or iframes within the same origin. Useful for centralizing state or connections across browser tabs (though browser support has been rocky).
  • Service Workers — act as a programmable network proxy between the browser and the network. Used for caching strategies, offline support, and push notifications. These deserve their own deep dive, which we cover in our HTTP caching guide.

This article focuses on dedicated workers, the most directly useful type for improving runtime performance and INP.

Your First Web Worker: A Step-by-Step Tutorial

Let's start with a practical example. Say you have a page that needs to sort a large dataset when the user clicks a button. Without a worker, this blocks the main thread:

// Without a worker — blocks the main thread
button.addEventListener('click', () => {
  const sorted = largeArray.sort((a, b) => a.score - b.score);
  renderResults(sorted);
});

If largeArray contains 100,000 items, this sort can easily take 100–300ms, creating a long task that wrecks your INP score. Here's how to move it to a worker:

Step 1: Create the Worker File

// sort-worker.js
self.onmessage = function (event) {
  const data = event.data;
  const sorted = data.sort((a, b) => a.score - b.score);
  self.postMessage(sorted);
};

Step 2: Use the Worker from the Main Thread

// main.js
const sortWorker = new Worker('sort-worker.js');

button.addEventListener('click', () => {
  // Send data to the worker — main thread stays free
  sortWorker.postMessage(largeArray);
});

sortWorker.onmessage = function (event) {
  // Runs when the worker finishes — update the DOM here
  renderResults(event.data);
};

sortWorker.onerror = function (error) {
  console.error('Worker error:', error.message);
};

The sort now runs in a background thread. The main thread sends the data, immediately goes back to handling user input, and picks up the result when the worker finishes. The button click responds instantly, your INP stays under 200ms, and the user sees results as soon as sorting completes.

That's really all there is to it at the basic level.

Step 3: Clean Up When Done

// Terminate the worker when no longer needed
sortWorker.terminate();

Always terminate workers you no longer need. Each one consumes memory and a thread, and unused workers quietly accumulate overhead over time.

Inline Workers: No Extra File Required

Creating a separate file for every worker can feel like overkill, especially in bundled applications. You can create an inline worker using a Blob:

const workerCode = `
  self.onmessage = function(event) {
    const result = event.data.reduce((sum, n) => sum + n, 0);
    self.postMessage(result);
  };
`;

const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));

worker.postMessage([1, 2, 3, 4, 5]);
worker.onmessage = (e) => console.log('Sum:', e.data); // Sum: 15

Modern bundlers like Vite and webpack also support importing workers directly with special syntax, which is arguably the cleaner approach:

// Vite — import with ?worker suffix
import SortWorker from './sort-worker.js?worker';
const worker = new SortWorker();

// Webpack 5 — use new URL pattern
const worker = new Worker(
  new URL('./sort-worker.js', import.meta.url)
);

Real-World Use Cases That Improve INP

Let's look at some patterns I've seen make a real difference in production applications.

1. Client-Side Search and Filtering

Filtering a product catalog or searching through thousands of records is one of the most common causes of jank. Moving the filtering logic to a worker makes a huge difference:

// search-worker.js
import Fuse from 'fuse.js';

let fuse;

self.onmessage = function (event) {
  const { type, payload } = event.data;

  if (type === 'init') {
    // Build the search index off the main thread
    fuse = new Fuse(payload.items, {
      keys: ['name', 'description'],
      threshold: 0.3,
    });
    self.postMessage({ type: 'ready' });
  }

  if (type === 'search') {
    const results = fuse.search(payload.query);
    self.postMessage({ type: 'results', data: results });
  }
};

This pattern offloads both index construction (which can take hundreds of milliseconds for large datasets) and each search query. The main thread stays free to handle typing input without any delay — exactly what your users expect.

2. Image Metadata Extraction

If your app processes user-uploaded images — extracting EXIF data, generating thumbnails, or reading dimensions — do it in a worker:

// image-worker.js
import ExifReader from 'exifreader';

self.onmessage = async function (event) {
  const arrayBuffer = event.data;
  const tags = ExifReader.load(arrayBuffer);

  self.postMessage({
    width: tags['Image Width']?.value,
    height: tags['Image Height']?.value,
    camera: tags['Model']?.description,
    date: tags['DateTimeOriginal']?.description,
  });
};

The main thread sends the image as an ArrayBuffer and receives structured metadata back. No jank during parsing.

3. CSV and JSON Data Processing

Parsing a 5MB CSV file or transforming a large JSON API response can easily block the main thread for several hundred milliseconds. I've seen this trip up teams that handle data dashboards or admin panels:

// data-worker.js
import Papa from 'papaparse';

self.onmessage = function (event) {
  const csvString = event.data;

  const parsed = Papa.parse(csvString, {
    header: true,
    dynamicTyping: true,
    skipEmptyLines: true,
  });

  // Aggregate data off the main thread
  const summary = {
    totalRows: parsed.data.length,
    columns: parsed.meta.fields,
    aggregates: computeAggregates(parsed.data),
  };

  self.postMessage(summary);
};

function computeAggregates(data) {
  // Heavy computation happens here, off the main thread
  return data.reduce((acc, row) => {
    acc.total += row.amount || 0;
    acc.count += 1;
    return acc;
  }, { total: 0, count: 0 });
}

Transferable Objects: Zero-Copy Data Transfer

Here's something that catches a lot of people off guard. By default, postMessage() creates a structured clone (deep copy) of the data you send. For small payloads this is fine, but copying a 10MB ArrayBuffer is expensive — and it happens on the main thread.

Transferable objects solve this by moving ownership of the memory to the other thread instead of copying it:

// Main thread — transfer the ArrayBuffer (zero-copy)
const buffer = new ArrayBuffer(1024 * 1024 * 10); // 10MB
worker.postMessage(buffer, [buffer]);

// After transfer, buffer.byteLength === 0 on the main thread
// The worker now owns the memory

Transferable types include ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas, and VideoFrame.

Use transferable objects whenever you're sending large binary data to or from a worker. The performance difference is dramatic — transferring a 10MB buffer takes microseconds instead of the 20–50ms that cloning requires.

SharedArrayBuffer and Atomics: Shared Memory Between Threads

For advanced use cases where multiple threads need to read and write the same data simultaneously, SharedArrayBuffer provides true shared memory — no copying, no transferring:

// Main thread
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);

// Send the shared buffer to the worker
worker.postMessage(sharedBuffer);

// Both threads can now read/write the same memory
sharedArray[0] = 42; // Visible to the worker immediately

Because multiple threads can write to the same memory simultaneously, you need Atomics to prevent race conditions:

// Worker thread
self.onmessage = function (event) {
  const sharedArray = new Int32Array(event.data);

  // Atomic operations prevent data races
  Atomics.add(sharedArray, 0, 10);      // Atomically add 10
  Atomics.store(sharedArray, 1, 100);   // Atomically set index 1

  // Wait until main thread signals
  Atomics.wait(sharedArray, 2, 0);      // Block until index 2 !== 0
};

// Main thread — signal the worker
Atomics.store(sharedArray, 2, 1);
Atomics.notify(sharedArray, 2, 1);

Security requirement: SharedArrayBuffer requires your page to be cross-origin isolated. You need to set these HTTP headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

This is a bit of a hassle, honestly, because cross-origin isolation can break third-party embeds and iframes that don't support CORP/COEP headers. Test carefully before deploying.

Use SharedArrayBuffer for performance-critical scenarios like real-time audio processing, physics simulations, or large-scale data analysis where the overhead of message passing becomes the bottleneck itself.

Comlink: Make Web Workers Feel Like Regular Functions

Comlink, a tiny library (1.1kB gzipped) from Google Chrome Labs, eliminates the verbose postMessage/onmessage boilerplate entirely. It turns worker communication into simple async function calls, and it's kind of magical the first time you use it:

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

const mathService = {
  fibonacci(n) {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  },

  sortArray(arr) {
    return arr.sort((a, b) => a - b);
  },

  async processData(data) {
    // Heavy computation here
    return data.map(item => ({
      ...item,
      score: calculateScore(item),
    }));
  },
};

Comlink.expose(mathService);
// main.js
import * as Comlink from 'comlink';

const mathService = Comlink.wrap(
  new Worker(new URL('./math-worker.js', import.meta.url))
);

// Call worker functions like they're local — just async
const result = await mathService.fibonacci(40);
const sorted = await mathService.sortArray([3, 1, 4, 1, 5]);

Comlink handles all the serialization, message routing, and error propagation under the hood. You write normal functions in the worker, expose them, and call them as async methods from the main thread. TypeScript support is built in — Comlink.wrap() returns the correct Remote<T> type automatically.

If you take one thing away from this article, it might be to try Comlink. It lowers the friction of using workers so much that you'll actually reach for them when you should.

Partytown: Offload Third-Party Scripts Automatically

Third-party scripts — analytics trackers, ad networks, chat widgets, A/B testing tools — are often the biggest offenders when it comes to main-thread congestion. Partytown takes a clever approach: instead of you manually creating workers, it automatically relocates third-party scripts into a web worker.

<!-- Load the Partytown snippet in the document head -->
<script src="/~partytown/partytown.js"></script>

<!-- Add type="text/partytown" to move any script to a worker -->
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"></script>
<script type="text/partytown">
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXX');
</script>

Partytown intercepts DOM API calls from the worker and proxies them back to the main thread synchronously using Atomics.wait() and a service worker. The result: third-party scripts execute entirely off the main thread while still being able to read and write the DOM.

Real-world results are pretty striking. Teams report Lighthouse performance scores jumping from the 70s to the high 90s after moving analytics and ad scripts to Partytown, with "Minimize main-thread work" warnings disappearing entirely. Worth noting, though, that Partytown can be finicky with scripts that rely on synchronous DOM measurements — test your specific third-party stack carefully.

Measuring the Impact on Core Web Vitals

Moving work to web workers is only valuable if you can prove it actually helped. Here's how to measure the before and after.

Using the Performance API

// Measure how long a task takes on the main thread
const start = performance.now();
const result = heavyComputation(data);
const duration = performance.now() - start;
console.log(`Main thread blocked for ${duration.toFixed(1)}ms`);

// Compare with the worker version
const workerStart = performance.now();
worker.postMessage(data);
worker.onmessage = () => {
  const workerDuration = performance.now() - workerStart;
  console.log(`Worker round-trip: ${workerDuration.toFixed(1)}ms`);
  // Main thread was free during this time
};

Using the Long Animation Frames (LoAF) API

The LoAF API lets you detect exactly when long tasks are blocking the main thread. This is incredibly useful for debugging INP issues:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn('Long animation frame detected:', {
        duration: entry.duration,
        blockingDuration: entry.blockingDuration,
        scripts: entry.scripts.map(s => ({
          source: s.sourceURL,
          duration: s.duration,
        })),
      });
    }
  }
});

observer.observe({ type: 'long-animation-frame', buffered: true });

Run this before and after moving work to web workers. You should see fewer long animation frames and lower blocking durations, which directly correlates with improved INP.

Chrome DevTools Performance Panel

Record a performance trace in Chrome DevTools, then look for:

  • Before workers: Long yellow (scripting) blocks on the Main thread track, with interaction events queued behind them
  • After workers: A clean Main thread with computation visible in a separate Worker thread track. Interaction events process immediately.

The visual difference is pretty satisfying, not going to lie.

Common Pitfalls and Best Practices

Pitfalls to Avoid

  • Sending too much data via postMessage. Structured cloning is synchronous on the sending side. If you send a 50MB object, the main thread blocks during serialization. Use transferable objects or SharedArrayBuffer for large payloads.
  • Creating too many workers. Each worker spawns a real OS thread and consumes memory. A good rule of thumb: navigator.hardwareConcurrency - 1 workers maximum. On a 4-core mobile device, that means 3 workers.
  • Expecting DOM access. Workers can't touch the DOM. If your computation needs to update the UI, do the heavy lifting in the worker and send the results back to the main thread for rendering.
  • Ignoring worker startup cost. Creating a worker involves fetching and parsing the script. For short-lived tasks, the overhead may exceed the benefit. Create workers early (at page load) and reuse them.
  • Not handling errors. Always attach an onerror handler. Unhandled worker errors fail silently, and they're a nightmare to debug.

Best Practices

  • Profile first. Use Chrome DevTools to identify actual long tasks before adding worker complexity. Not every slow operation belongs in a worker.
  • Create workers at startup, reuse them. Initialize workers during page load when the main thread has idle time, then send work to them on demand.
  • Use Comlink for complex APIs. If your worker exposes more than one or two operations, Comlink eliminates boilerplate and reduces bugs significantly.
  • Transfer, don't clone. For ArrayBuffers and binary data, always use the transfer list in postMessage().
  • Batch small operations. Sending many small messages has overhead. Batch operations into a single message when possible.
  • Terminate unused workers. Call worker.terminate() when the worker is no longer needed to free memory and thread resources.

When Not to Use Web Workers

Web workers aren't a silver bullet. Skip them when:

  • The computation takes less than 50ms — the message-passing overhead may negate the benefit
  • The work requires direct DOM manipulation — use requestAnimationFrame or requestIdleCallback instead
  • You're already yielding to the main thread effectively using scheduler.yield() or breaking work into small chunks with setTimeout
  • The data transfer cost exceeds the computation cost — profiling will reveal this quickly

Web workers complement other main-thread optimization techniques. Use scheduler.yield() for breaking up sequential DOM work, requestIdleCallback for deferring non-essential tasks, and web workers for genuinely heavy computation that doesn't need the DOM.

Browser Support and Compatibility

Dedicated web workers are supported in all modern browsers — Chrome, Firefox, Safari, Edge — and have been available since Internet Explorer 10. There's really no reason to avoid them due to compatibility concerns in 2026.

SharedArrayBuffer requires cross-origin isolation headers and is supported in Chrome 68+, Firefox 79+, Safari 15.2+, and Edge 79+. Check crossOriginIsolated before using it:

if (crossOriginIsolated) {
  const sab = new SharedArrayBuffer(1024);
  worker.postMessage(sab);
} else {
  // Fall back to transferable ArrayBuffer
  const ab = new ArrayBuffer(1024);
  worker.postMessage(ab, [ab]);
}

Frequently Asked Questions

Do web workers improve Largest Contentful Paint (LCP)?

Indirectly, yes. When the main thread has less work during page startup, the browser can prioritize rendering the LCP element sooner. If heavy JavaScript initialization is blocking the main thread during load, moving it to a worker frees the main thread to paint content faster. That said, web workers have the most direct impact on INP, not LCP.

What's the difference between web workers and service workers?

Dedicated web workers run alongside a specific page and are designed for offloading computation. They're created and destroyed with the page. Service workers, on the other hand, act as a network proxy that lives independently of any page — they intercept network requests, manage caches, and enable offline functionality. Use dedicated workers for CPU-intensive tasks and service workers for caching and network strategies.

Can web workers access localStorage or cookies?

No. Web workers can't access localStorage, sessionStorage, document.cookie, or any DOM API. They can use IndexedDB, Cache API, fetch(), and WebSocket. If you need data from localStorage, read it on the main thread and send it to the worker via postMessage().

How many web workers should I create?

There's no hard limit, but each worker spawns a real OS thread. Creating more workers than CPU cores provides no parallelism benefit and just increases memory usage. Use navigator.hardwareConcurrency to check available cores, and aim for hardwareConcurrency - 1 workers at most — leaving one core for the main thread. For most web apps, one to three dedicated workers is plenty.

Is Comlink production-ready?

Absolutely. Comlink is maintained by Google Chrome Labs, weighs just 1.1kB gzipped, and has been used in production by major applications. It adds negligible overhead on top of the native postMessage API while dramatically improving developer experience with its RPC-style abstraction and full TypeScript support.

About the Author Editorial Team

Our team of expert writers and editors.