AI Infrastructure

AI Infrastructure

How to Prevent JavaScript Lag Using Web Workers

Aashka Doshi

11 min read

AI Infrastructure

How to Prevent JavaScript Lag Using Web Workers

Aashka Doshi

11 min read

Web Workers for Faster, Responsive JavaScript

Learn how Web Workers prevent JavaScript lag by offloading heavy tasks, improving performance, and keeping your web apps smooth and responsive.

Share this blog on

I recently ran into a frustrating issue while building a data-heavy dashboard. Everything looked smooth until I triggered a task that involved processing a large amount of data in the browser. Suddenly, the UI just froze. No button clicks, no scroll, no visual feedback, just a locked interface. It made me realize how fragile the frontend can be when you're doing heavy work directly in the browser, especially on the main thread. JavaScript is single-threaded. It can only execute one task at a time on the main thread. When that task is computationally expensive, it blocks everything else, including user interactions. In this blog, I’ll walk you through how I solved that problem using Web Workers. We’ll look at the real cause of UI freezes, when to reach for Web Workers, and how to implement them in practice.


Understanding the Problem: Why JavaScript Blocks the UI

Most web apps eventually run into this problem: they have to do something that takes a bit of computing power, like parsing data, drawing charts, or filtering lists. These tasks are CPU-heavy, meaning they need a lot of processing.

Unlike things like fetch() (which loads data in the background), CPU-heavy tasks run directly on the main thread, the same one that handles clicks, scrolls, and animations. So when the browser is busy with that work, everything else has to wait.

What gets blocked:

  • User clicks and interactions

  • Scroll and resize events

  • DOM updates and repaints

  • Even seemingly unrelated animations

Once, my browser even threw a "Page Unresponsive" warning all because my script took too long in a loop. That’s when I realized: if your code takes more than a few milliseconds to run, it's probably slowing down the whole app.


The Solution: Offloading Work with Web Workers

After hitting that performance wall during CSV parsing, I started looking for ways to keep my app responsive while still handling the data processing I needed. That’s when I explored Web Workers  - a native browser feature that allows true multithreading for heavy JavaScript tasks. It’s one of the most effective tools for making architecture decisions in performance-sensitive frontends.

Think of it like this: instead of asking one person (your browser) to do everything from drawing the UI to doing math, you hire a helper (a Web Worker) to do the heavy lifting in the background. This way, the main thread stays free to handle things like clicks, scrolls, and rendering.

Once I moved the logic to a worker, the difference was instant: the UI stayed smooth, buttons stayed clickable, and everything just worked better.

Here’s where Web Workers shine:

  • Processing large datasets: Useful for parsing CSV, JSON, and log files client-side.

  • Running CPU-intensive calculations: Ideal for math-heavy loops, simulations, or scientific tasks.

  • Transforming or compressing media: Great for image filters, audio waveform extraction, or pre-upload compression.

  • Executing AI models: Helps run lightweight ML models (e.g., TensorFlow.js, ONNX) without freezing the UI.


Example 1: CPU-Heavy Task on Main Thread (UI Blocking)

To show you how the problem happens, let’s take a simple example. I wrote a function that calculates the first 50,000 prime numbers - a task that needs a lot of processing.

I ran that function directly on the main thread (which also handles your UI), and here’s what happened:

<!-- index.html -->

<button id="btn">Find 50 000 primes</button>

<p id="status">Idle</p>

<script type="module">

  const btn = document.getElementById('btn');

  const status = document.getElementById('status');

  function findPrimes(limit) {

    const primes = [];

    let num = 2;

    while (primes.length < limit) {

      if (primes.every(p => num % p !== 0)) primes.push(num);

      num++;

    }

    return primes;

  }

  btn.addEventListener('click', () => {

    status.textContent = 'Working — UI will freeze…';

    const primes = findPrimes(50_000);

    status.textContent = `Done! Largest prime: ${primes.at(-1)}`;

  });

</script>
Result:
  • UI froze completely while the primes were being calculated

  • The browser flagged it as a "long task."

  • Buttons stopped working, scroll didn’t respond - it just felt broken

Even though the math worked, the user experience was terrible. This is a textbook case of why running heavy tasks on the main thread is a bad idea.


Example 2: Using a Web Worker (Non-Blocking UI)

To fix the freezing issue, I moved the same logic into a Web Worker. This way, the main thread (which handles clicks, scrolling, and layout) could stay free, while the worker handled all the heavy lifting in the background.

Here’s how I organized it:

Folder Layout
public/

└─ primes-worker.ts   Worker code

   index.html          Main thread code
Worker file (primes-worker.ts)

This code runs separately from the UI. It waits for a message, finds the prime numbers, and sends the result back.

// primes-worker.ts

self.onmessage = (event) => {

  const limit = event.data;

  const primes = [];

  let num = 2;

  while (primes.length < limit) {

    if (primes.every(p => num % p !== 0)) primes.push(num);

    num++;

  }

  postMessage(primes.at(-1));

};
Main thread (index.html) 

This file creates the worker, sends it the data, and updates the UI when the worker is done.

<button id="btn">Find 50 000 primes</button>

<p id="status">Idle</p>

<script type="module">

  const btn = document.getElementById('btn');

  const status = document.getElementById('status');

  const worker = new Worker(new URL('./primes-worker.ts', import.meta.url), {

    type: 'module',

  });

  worker.onmessage = (ev) => {

    status.textContent = `Done! Largest prime: ${ev.data}`;

    btn.disabled = false;

  };

  btn.addEventListener('click', () => {

    status.textContent = 'Crunching in the background…';

    btn.disabled = true;

    worker.postMessage(50_000);

  });

<

Result:
  • UI stayed responsive the entire time

  • Computation happened in the background

  • Clean, professional UX and a happy user


How Web Workers Communicate with the Main Thread

Web Workers don’t magically know what to do—you have to send them messages from your main JavaScript code. And when they’re done, they send messages back.

This back-and-forth is done using two simple methods:

  • postMessage(data) → sends a message

  • onmessage → listens for messages

Both the main thread and the worker use these to talk to each other, kind of like walkie-talkies.

For example:

ts

CopyEdit

// Main thread

worker.postMessage(50000); // Tell the worker what to do

worker.onmessage = (event) => {

  console.log("Worker finished:", event.data);

};
ts

CopyEdit

// Worker thread

self.onmessage = (event) => {

  // Do some work

  self.postMessage(result); // Send it back

};

A few things to remember:

  • Communication is async

  • No shared memory or references

  • Use postMessage(data) and handle it via onmessage

For large data payloads (like typed arrays or buffers), you can improve performance further using transferable objects, which transfer ownership of memory rather than copying it.

For complex apps, tools like Comlink help simplify communication using proxies.


Debugging Web Workers: What to Know

Web Workers run separately from your main app code which is great for performance, but it also means debugging them works a little differently.

Here are a few beginner-friendly tips to help you debug them effectively:

console.log() works, but in a different place
If you try logging inside a worker using console.log(), it won't show up in the main browser console right away.

In Chrome DevTools, look for a small dropdown near the top-left of the Console or Sources tab, you’ll see your worker thread listed there. Switch to it to see those logs.

Worker errors don’t crash your app
If there’s a mistake (like a typo or bug) in your worker code, it won’t break the main thread—but the worker may silently fail.

To catch this, listen for errors like this:

js

CopyEdit

worker.onerror = (e) => {

  console.error("Worker error:", e.message);

};

Paths matter
If you’re using bundlers like Vite or Webpack, referencing your worker script correctly is important.

Use this format to avoid issues:

js

CopyEdit

new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });

If the worker isn’t doing anything:

  • Make sure you’re calling the worker.postMessage()

  • Double-check that you’ve set up onmessage handlers in both places

  • Look inside the worker for any infinite loops or unhandled errors

Debugging can feel tricky at first because workers are isolated. But once you get used to switching DevTools contexts and watching message flow, it becomes much more manageable.


Performance Profiling: Measuring the Impact

To verify whether moving logic to a worker actually helps, it's important to go beyond gut feeling and test with real metrics. I use Chrome DevTools Performance tab to visually inspect and validate the changes.

What I check:
  • Main thread frame budget: For a smooth 60fps experience, each frame should render in under ~16.6ms. If any function blocks the thread longer than this, frames get dropped. After introducing Web Workers, I confirm that main-thread activity is comfortably within this budget.

  • Long tasks (>50ms): These show up in red blocks and typically cause jank. You want to eliminate or move these to workers. I make sure that any computation-heavy logic no longer appears in the main thread timeline.

  • Timeline flame chart: This gives a visual representation of what’s running and where. I verify that my worker logic is no longer part of the main-thread stack and appears separately under the 'Worker Thread' section.

I also pair this with Web Vitals metrics to monitor end-user experience:

  • Total Blocking Time (TBT): Measures how much time long tasks block the main thread, which is often reduced significantly when using workers.

  • First Input Delay (FID): Tells you how fast your app responds to the first user interaction, indirectly affected by whether your main thread is blocked or not.

Workers don’t just feel better, they leave measurable improvements. By profiling before and after, I gain clarity on whether the solution is worth the complexity in a given scenario.


Real-World Use Cases for Web Workers

Web Workers are surprisingly versatile. They help wherever heavy computation could block your UI. These are some real-world scenarios where they shine:

  • Large file parsing: When users upload CSV, JSON, or XML files, parsing them on the main thread can cause the UI to freeze. Web Workers handle parsing asynchronously, giving your app time to update progress indicators or remain responsive.

  • Image manipulation: Resizing, compressing, or applying filters to images before upload is a perfect task for workers. This avoids long delays in the UI thread and improves user perception of performance.

  • Video processing: Workers are useful for extracting metadata, creating video thumbnails, or segmenting streams before playback. Even basic frame sampling becomes viable without sacrificing UI responsiveness.

  • Data-heavy charts: When you’re visualizing large datasets (e.g., 10k+ points), preprocessing or aggregating that data in a worker avoids layout thrashing or reflow problems.

  • ML model inference: Lightweight machine learning tasks (like sentiment analysis, face detection, etc.) can run in the browser. Web Workers ensure this doesn’t interrupt scroll, paint, or animation flows.

  • Crypto operations: Anything involving hashing, encryption, or signature verification should run in a worker to avoid main thread lock-ups, especially during authentication or file verification flows.

  • Sensor data processing: IoT dashboards or web-based real-time systems often process thousands of sensor updates per second. Filtering, smoothing, or alert evaluation can be offloaded to workers.

If your task is CPU-bound, lots of math, iterations, or memory transformation, it’s a great candidate for a worker. Always prioritize UI responsiveness and move the bottlenecks away from the render path.


Reusability and Architecture Tips

To avoid repeating worker setup across projects, I created a reusable abstraction that simplifies how I create and communicate with Web Workers.

Here’s how you can structure it for maintainability:

  • Create a generic worker wrapper: A utility function that accepts a worker file path and returns a Promise-based interface to interact with it. This decouples the calling logic from boilerplate messaging code.

  • Use Promises over message handlers: By wrapping postMessage and onmessage into a Promise, you get clean async/await support and avoid callback hell.

  • Consider libraries like Comlink: It provides a proxy-based API to make worker functions feel like local function calls. This simplifies communication for complex apps without reinventing message protocols.

  • Use pools for repeated tasks: For one-off computations, spin up a new worker and terminate after use. For repeated workloads (e.g., decoding or inference), keep a persistent worker pool alive to reduce overhead.

Here’s a simplified example:

export function runWorker<T>(path: string, payload: any): Promise<T> {

  return new Promise((resolve, reject) => {

    const worker = new Worker(new URL(path, import.meta.url), { type

This pattern has helped me reduce friction and make worker-based concurrency a first-class citizen in my architecture. It also makes it easier to test, mock, or extend workers without scattering implementation details across the codebase.


Why Use Web Workers Over Lazy Loading or requestIdleCallback()

I tried other techniques before committing to Web Workers. Here’s why they didn’t work:

  • Lazy Loading helps delay when code runs, not how long it runs. My app still froze once the computation started.

  • requestIdleCallback() schedules light tasks during browser idle time. But it still runs on the main thread, and anything CPU-heavy still causes jank.

Only Web Workers truly move the computation off the main thread. That’s real concurrency and the only viable solution for tasks that involve serious number crunching.


When Not to Use Web Workers

Web Workers aren’t always necessary. Here’s when I avoid them:

  • Tasks < 2–3 ms: The overhead of setting up a worker isn’t worth it

  • UI/DOM manipulation: Workers can’t access the document, styles, or layout

  • Network-bound tasks (fetch, IndexedDB): These are already async and efficient

  • Low-end mobile or embedded devices: Spawning a worker adds memory and CPU pressure

  • SEO, tracking, analytics: Workers can’t access documents, cookies, or many browser APIs

  • Early debugging or prototyping: Debugging async code in workers can be a hassle

  • Battery-sensitive environments: Workers can wake up the CPU more often

  • Strict bundle-size constraints: Workers may split into extra chunks in your final build


Conclusion

JavaScript’s single-threaded model is often sufficient until it’s not. When your app starts parsing large datasets, generating visualizations, or running computations that impact responsiveness, it’s not just a performance issue; it becomes a usability problem.

Web Workers offer a production-grade way to reclaim your main thread and maintain a fluid user experience. Offloading CPU-heavy tasks isn’t about optimization for the sake of performance charts; it's about ensuring your app doesn’t freeze when users need it the most.

If you’re a developer working on data-intensive features, consider Web Workers not as an edge case, but as a core part of your frontend architecture toolkit.

And if your team needs help designing performant web apps that scale across devices and workloads, we can help. From architecture guidance to implementation and testing, we specialize in building interfaces that don’t compromise on speed, UX, or engineering quality.

If you found this post valuable, I’d love to hear your thoughts. Let’s connect and continue the conversation on LinkedIn.

Curious what Webworkers can do for you?

Our team is just a message away.

Curious what Webworkers can do for you?

Our team is just a message away.

Other blogs you might like

Procedure is an AI-native design & development studio. We help ambitious teams ship faster, scale smarter, and solve real-world problems with clarity and precision.

© 2025 Procedure Technologies. All rights reserved.

Procedure is an AI-native design & development studio. We help ambitious teams ship faster, scale smarter, and solve real-world problems with clarity and precision.

© 2025 Procedure Technologies. All rights reserved.

Procedure is an AI-native design & development studio. We help ambitious teams ship faster, scale smarter, and solve real-world problems with clarity and precision.

© 2025 Procedure Technologies. All rights reserved.