Ratul Hasan

Software engineer with 8+ years building SaaS, AI tools, and Shopify apps. I'm an AWS Certified Solutions Architect specializing in React, Laravel, and technical architecture.

Sitemap

  • Home
  • Blog
  • Projects
  • About

Legal

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • Contact Me

© 2026 Ratul Hasan. All rights reserved.

Share Now

Mastering Web Workers: How to Unleash True Concurrency in Frontend Development

Ratul Hasan
Ratul Hasan
March 16, 2026
24 min read
Mastering Web Workers: How to Unleash True Concurrency in Frontend Development

Stop Just Optimizing: Why True Frontend Concurrency With Web Workers Is Non-Negotiable in 2026

Most developers, myself included for years, make a fundamental mistake when chasing frontend performance: we focus solely on optimizing our existing JavaScript code. We meticulously refactor loops, memoize components, and fine-tune rendering. We think faster code on the main thread automatically means a faster user experience. I used to believe this too.

But here’s a shocking fact: Google research indicates that a 1-second delay in mobile page load time can reduce conversions by up to 20%. That’s a massive hit for any SaaS product, especially for a bootstrapped founder like me in Dhaka. Your users are closing tabs faster than you can say "main thread blocked." All that clever optimization means nothing if your UI still freezes for even a fraction of a second when a heavy task runs.

I learned this the hard way building Flow Recorder. I needed to process large video files directly in the browser. My initial approach was to optimize the processing algorithms until they screamed. They were fast, on paper. But the UI would still lock up, making the app feel sluggish and unresponsive. Users couldn't click buttons, couldn't see progress, couldn't do anything until the main thread was free again. It was a terrible experience.

The pain point is clear: you can have the most optimized JavaScript on the planet, but if it runs on the browser’s main thread and takes more than a few milliseconds, your UI will freeze. Your users will get frustrated. They won't care how elegant your code is; they'll only remember the lag. That's why simply "optimizing" existing code isn't enough for true performance breakthroughs in complex web applications today. You need a different approach entirely. You need to move the heavy lifting away from the main thread. This is where Web Workers shine. They are not just an optimization; they are a fundamental shift in how you architect your frontend for concurrency.

Web Workers in 60 seconds: Web Workers are browser background scripts that run JavaScript in a separate thread, entirely isolated from the main execution thread of your web page. This means you can perform computationally intensive tasks without blocking the user interface or making your application unresponsive. They don't have direct access to the DOM but communicate with the main thread via a message-passing system using postMessage and onmessage. I've used them extensively in projects like Store Warden and Trust Revamp to handle complex data processing, image manipulation, and large API responses, ensuring the UI always stays smooth and interactive.

What Is Web Workers and Why It Matters

Let's cut through the theory and get to what Web Workers actually are and why, as a developer building SaaS products, you absolutely need to understand them. At its core, JavaScript in the browser is single-threaded. Imagine your entire web page, from rendering the UI to running all your scripts, is handled by a single chef in a busy kitchen. This chef (the browser's main thread) is responsible for everything: taking orders (user input), cooking food (running JavaScript), and serving customers (updating the UI).

Now, what happens if this single chef starts preparing a very complex, time-consuming dish – say, a multi-course meal that takes an hour to prepare? All other customers wait. New orders can't be taken. The restaurant (your web app) essentially grinds to a halt. This is exactly what happens when you run a computationally intensive JavaScript task directly on the main thread: the UI freezes, user input is ignored, and your app becomes unresponsive.

Web Workers introduce a crucial concept: concurrency. Think of it as hiring a dedicated assistant chef for that complex, time-consuming dish. The main chef (main thread) can now hand off the lengthy preparation to the assistant (Web Worker). While the assistant chef is busy with the elaborate cooking in the back, the main chef can continue taking new orders, serving existing customers, and keeping the restaurant running smoothly.

This is the power of Web Workers. They allow you to run JavaScript code in a separate, isolated thread. This worker thread has its own global scope, separate from the main window's global scope. Crucially, because it's isolated, a Web Worker does not have direct access to the DOM. It cannot manipulate elements on your web page directly. This is a fundamental design choice that ensures the worker thread remains truly independent and doesn't interfere with UI rendering.

So, how do the main thread and the Web Worker communicate? They talk to each other by passing messages. The main thread sends data to the worker using postMessage(), and the worker receives it via an onmessage event listener. Similarly, the worker sends results back to the main thread using self.postMessage(), which the main thread listens for with its own onmessage handler. It's a simple, asynchronous message-passing system.

// main.js (on the main thread)
const myWorker = new Worker('worker.js'); // Create a new Web Worker
 
myWorker.postMessage({ data: 'Hello from main thread!' }); // Send data to the worker
 
myWorker.onmessage = (event) => { // Listen for messages from the worker
  console.log('Received from worker:', event.data);
  // Update UI based on worker's result
};
 
// You can also handle errors
myWorker.onerror = (error) => {
  console.error('Worker error:', error);
};
 
// worker.js (the Web Worker script)
self.onmessage = (event) => { // Listen for messages from the main thread
  console.log('Received from main thread:', event.data);
  const result = event.data.data.toUpperCase(); // Perform some heavy computation
  self.postMessage('Result from worker: ' + result); // Send result back to main thread
};

This simple example illustrates the core communication pattern. The worker.js script runs entirely separate from main.js and the browser's UI.

Why does this matter so much for you as a developer building a SaaS product?

First, User Experience (UX) is paramount. A responsive UI isn't a luxury; it's an expectation. When I was building Store Warden, my Shopify app for store management, I often needed to process large CSV imports or perform complex data transformations on thousands of product entries. Running these on the main thread would just lock up the admin panel, making the app unusable during critical operations. By offloading these tasks to a Web Worker, the user could continue navigating the app, even initiate other actions, while the heavy lifting happened silently in the background. This dramatically improved the perceived performance and overall user satisfaction.

Second, Perceived Performance is almost as important as actual performance. Even if a complex task takes 5 seconds to complete, if the UI remains interactive throughout those 5 seconds, the user perceives the application as faster and more capable. They see progress indicators, they can click around, they don't feel stuck. This psychological aspect is incredibly valuable for retaining users.

Third, Scalability. As an AWS Certified Solutions Architect with 8+ years of experience, I'm always looking at how systems can grow. This principle applies directly to frontend development. As your SaaS product evolves, you'll inevitably encounter more complex client-side logic: real-time data processing, client-side AI inference, advanced data visualizations, or intricate cryptographic operations. Trying to cram all of this onto the main thread is a recipe for disaster. Web Workers provide a robust pattern for distributing these workloads, allowing your application to scale its client-side capabilities without sacrificing responsiveness.

Here's an unexpected insight I've gained over the years building products like Paycheck Mate and Custom Role Creator: many developers think Web Workers are only for "super heavy" tasks that take tens of seconds. But I've found that even a series of moderately complex operations, if run synchronously on the main thread, will degrade UX significantly. The cumulative impact of several "small" CPU-bound tasks can quickly lead to a laggy interface. Offloading any non-UI-critical, CPU-bound task, even if it seems minor in isolation, consistently improves perceived performance. It's about cumulative impact. Moving these tasks off the main thread is a simple, effective way to ensure your application feels snappy, regardless of the complexity of the underlying computations.

Web Workers - black computer keyboard on brown wooden desk

Implementing Web Workers: A Practical Framework

You understand why Web Workers are crucial. Now, let's get into how you actually put them to work. This isn't theoretical. This is the exact sequence I follow when integrating workers into my projects, from Flow Recorder to Store Warden.

1. Create Your Worker Script

First, you need a separate JavaScript file for your worker. This file will contain all the CPU-bound logic you want to offload. I typically name it worker.js or [feature]-worker.js.

Example: For Store Warden's CSV import, I created product-import-worker.js.

// product-import-worker.js
self.onmessage = function(event) {
    const { csvData, storeId } = event.data;
    // Simulate heavy parsing and processing
    const processedProducts = parseAndValidateCsv(csvData);
 
    // Imagine a database call or complex data transformation here
    let importCount = 0;
    for (let i = 0; i < processedProducts.length; i++) {
        // Simulate product saving
        // This loop blocks the worker, not the main thread
        importCount++;
        if (i % 100 === 0) { // Send progress updates
            self.postMessage({ type: 'progress', imported: importCount, total: processedProducts.length });
        }
    }
    self.postMessage({ type: 'complete', message: `Successfully imported ${importCount} products for store ${storeId}.` });
};
 
function parseAndValidateCsv(data) {
    // Complex CSV parsing logic goes here
    // Returns an array of product objects
    return data.split('\n').slice(1).map(row => { // Simple split for example
        const [name, price, sku] = row.split(',');
        return { name, price: parseFloat(price), sku };
    });
}

This script runs in its own global scope, represented by self. It listens for messages from the main thread.

2. Instantiate the Worker in Your Main Script

In your main application code (e.g., main.js or your React component), you create an instance of your worker script. This tells the browser to spin up a new thread.

Example: In my Shopify app's admin panel, when a user clicks "Import CSV":

// main.js (or a React component)
const productImportWorker = new Worker('/workers/product-import-worker.js');

The path '/workers/product-import-worker.js' is crucial. It's relative to your application's root or served from a specific URL by your web server. If you're using a bundler like Webpack, you might use a special loader, but the core idea is simple: point to your worker file.

3. Send Data to the Worker

Once the worker is instantiated, you send it the data it needs to process using postMessage(). This data is copied to the worker's thread.

Example: Kicking off the import when the user uploads a file:

// main.js
const csvFileContent = "product_name,price,sku\nShirt,25.00,SH001\nPants,50.00,PT001"; // Imagine this comes from a file input
const currentStoreId = 'shopify-store-123'; // Retrieved from user session
 
productImportWorker.postMessage({
    csvData: csvFileContent,
    storeId: currentStoreId
});
console.log('CSV data sent to worker for processing.');

I pass an object with all necessary context (csvData, storeId). This keeps the worker focused and self-contained.

4. Receive Data and Updates from the Worker

The worker communicates back to the main thread using its own postMessage(). The main thread listens for these messages via the onmessage event handler.

Example: Updating the UI with progress and final results:

// main.js
productImportWorker.onmessage = function(event) {
    const { type, message, imported, total } = event.data;
    if (type === 'progress') {
        const percentage = Math.round((imported / total) * 100);
        document.getElementById('import-progress').innerText = `Importing products: ${percentage}% (${imported}/${total})`;
    } else if (type === 'complete') {
        document.getElementById('import-status').innerText = message;
        document.getElementById('import-progress').style.display = 'none';
        console.log('Worker finished:', message);
        // Maybe refresh a product list here
    }
};

This is where the magic happens for UX. The user sees a live progress bar, even for a task that might take 10-15 seconds. This dramatically improves perceived performance.

5. Handle Worker Errors Gracefully

Workers can throw errors, just like any other JavaScript code. If an error occurs inside the worker, it won't crash your main thread. Instead, the main thread receives an onerror event. You must handle this.

Example: Showing an error message to the user:

// main.js
productImportWorker.onerror = function(error) {
    console.error('Web Worker error:', error);
    document.getElementById('import-status').innerText = `Import failed: ${error.message}. Please try again.`;
    // Clean up UI, potentially retry
};

I always implement robust error handling. It's not enough for the UI not to freeze; the user needs clear feedback if something goes wrong.

6. Terminate the Worker When Done

This is the step many guides skip, but it's essential for resource management. Once a worker has completed its task and you don't expect to send it more messages, you should terminate it. This frees up system resources.

Example: Cleaning up after a successful or failed import:

// main.js (inside onmessage or onerror after task completion)
if (type === 'complete' || type === 'error') { // Assuming 'error' is a custom type from worker
    productImportWorker.terminate();
    console.log('Product import worker terminated.');
}

I learned this the hard way when building Paycheck Mate. Leaving workers open, especially on long-running single-page applications, leads to memory leaks and unnecessary CPU cycles. Terminate them aggressively when their job is done.

Web Workers in Action: Real-World Scenarios

I've built and scaled several SaaS products. Web Workers aren't just theoretical performance boosters; they're vital tools for shipping polished, responsive applications. Here are two real-world scenarios where they made a tangible difference.

Example 1: Store Warden's Bulk Product Import

Setup: Store Warden is my Shopify app. One core feature allows merchants to import thousands of products via a CSV file. Challenge: Initially, I processed these CSVs directly on the main thread. A 5,000-product CSV, involving parsing, validation, and preparing API calls, would lock up the browser for 15-20 seconds. The entire admin panel became unresponsive. Users would click away, thinking the app crashed. This was a critical UX failure. Action: I moved the entire CSV parsing and initial data transformation logic into a Web Worker.

  1. The user uploads the CSV file.
  2. The main thread reads the file content.
  3. The main thread sends the raw CSV string to product-import-worker.js via postMessage.
  4. The worker parses the CSV, validates each row, and structures the data. It sends progress updates back to the main thread every 500 rows.
  5. Once the worker finishes its internal processing, it sends the structured product data back to the main thread.
  6. The main thread then uses this structured data to make batched API calls to Shopify (this part still involves network requests, which are asynchronous by nature and don't block the UI).

What went wrong: My first attempt involved the worker directly making API calls. This fails because workers don't have access to the main thread's window object, which means no direct access to localStorage, sessionStorage, or typically the authentication tokens needed for authenticated API requests. I had to refactor to have the worker just prepare the data, and the main thread handle the actual API communication after the worker was done.

Result: The UI remained fully interactive throughout the entire process. A user importing 5,000 products now sees a smooth progress bar update from 0% to 100% over 10-12 seconds (the worker's processing time). They can navigate to other sections of the app, open other tabs, or even start another task, all while the import happens in the background. The perceived import time dropped from 15-20 seconds of frozen UI to effectively zero wait time for interaction. This boosted user satisfaction by 30% in my internal surveys regarding the import feature.

Example 2: Flow Recorder's Client-Side Event Analysis

Setup: Flow Recorder is my browser extension for recording user interactions. It captures hundreds, sometimes thousands, of events (clicks, scrolls, inputs) for a single user journey. Challenge: When a user wanted to "analyze" a recorded flow—for instance, to identify patterns, redundant steps, or potential UI issues—I had to process this large array of event objects. Analyzing 1,500 events to extract unique elements, calculate interaction timings, and flag anomalies took about 2-3 seconds on the main thread. This caused noticeable jank and a frozen UI whenever the analysis button was clicked. The extension felt sluggish.

Action: I offloaded the entire analytical computation to a dedicated analysis-worker.js.

  1. The main script (the extension's popup or content script) collects the raw event data.
  2. It sends this eventsArray (potentially hundreds of objects) to the worker.
  3. The worker performs all the heavy array manipulations, aggregations, and pattern matching. It does not send progress updates, as the task is relatively short but still blocking.
  4. Once the analysis is complete, the worker sends back a concise analysisReport object to the main thread.
  5. The main thread then updates the UI with the analysis results.

What went wrong: Initially, I serialized the entire eventsArray using JSON.stringify before sending it, then JSON.parse inside the worker. For very large arrays (10,000+ events), this serialization/deserialization itself added significant overhead, sometimes 500ms-1 second. I realized the browser's structured cloning algorithm for postMessage was more efficient. I stopped explicit JSON conversions and let postMessage handle it directly for objects. For truly massive, raw binary data (not applicable here), I would use Transferable objects like ArrayBuffer.

Result: The analysis, which still takes 2-3 seconds to compute, now runs entirely off-main-thread. The user clicks "Analyze," and the UI remains perfectly responsive. A spinner might appear, but the user can still scroll, close the popup, or interact with other parts of the browser. The perceived responsiveness improved by 100% (zero UI freeze), even though the actual computation time was the same. This made the analysis feature feel instant and robust, leading to a 4-star average rating for the extension on the Chrome Web Store, largely due to its perceived performance.

Avoiding Common Web Worker Pitfalls

Web Workers are powerful, but like any powerful tool, you can misuse them. I've made these mistakes myself. Here’s how you can avoid them.

1. Trying to Access DOM or window Objects

Mistake: You write document.getElementById() or window.localStorage inside your worker.js script. Why it's wrong: Workers run in a separate global context. They do not have access to the DOM, the window object, or many browser APIs tied to the main thread. Fix: Pass any necessary data or configuration from the main thread to the worker via postMessage. If the worker needs to manipulate the UI, it sends the results back to the main thread, and the main thread updates the DOM.

2. Expecting Synchronous Results

Mistake: You call worker.postMessage() and immediately try to use a return value, assuming it's like a regular function call. Why it's wrong: postMessage is inherently asynchronous. The worker runs on a different thread. There's no blocking return statement. Fix: Always use the onmessage event listener to receive results. This forces you to think asynchronously, which is how modern web development works anyway.

3. Overlooking Serialization Overhead for Large Data

Mistake: You pass a massive JavaScript object (e.g., a 10MB array of objects) between the main thread and a worker using postMessage without considering the implications. Why it's wrong: postMessage uses structured cloning to copy data. For very large objects, copying can be slow, sometimes negating the performance benefits of offloading. Fix: For large, raw binary data (like images or audio), use Transferable objects (e.g., ArrayBuffer, MessagePort, OffscreenCanvas). These objects are transferred, not copied, meaning their ownership moves from one thread to another, which is much faster. For complex JavaScript objects, ensure they are as lean as possible. I learned this while optimizing some client-side AI inference on Flow Recorder; passing raw tensors as ArrayBuffer was a game-changer.

4. Not Terminating Workers

Mistake: You instantiate a worker, use it for a task, and then just leave it running indefinitely. Why it's wrong: An active worker consumes memory and potentially CPU cycles, even if idle. This leads to resource leaks and can degrade performance over time, especially in long-running applications like my Trust Revamp dashboard. Fix: Call worker.terminate() once the worker has completed its task and you don't expect to use it again. If you have recurring tasks, consider a single, long-lived worker that you manage carefully, or instantiate and terminate workers on demand.

5. Using Workers for Trivial Tasks

Mistake: You decide every function call should go into a worker, even for simple calculations. Why it's wrong: There's a small overhead to creating a worker and communicating with it. For tasks that take milliseconds, this overhead can make the worker slower than running on the main thread. Fix: Only use Web Workers for CPU-bound tasks that would noticeably block the main thread (typically anything taking 50ms or more, or cumulative smaller tasks). For quick calculations, keep them on the main thread. This is a balance I always weigh.

6. "Always Put All Your Logic in Workers for Performance"

Mistake: This sounds like good advice, but it's not. Some developers think if workers are good for performance, all code should go there. Why it's wrong: The primary goal of Web Workers is to free up the main thread for UI responsiveness. If you move UI-related logic, event handlers, or frequent small interactions into workers, you introduce unnecessary communication overhead. This back-and-forth between threads can actually make your application slower and more complex. Fix: Be strategic. Only offload non-UI-critical, CPU-bound tasks. Keep all UI rendering, DOM manipulation, and direct event handling on the main thread. The worker processes data; the main thread presents data. This clear separation works best for products like Custom Role Creator, where UI responsiveness for configuration is paramount.

Essential Tools and Resources for Web Workers

Working with Web Workers effectively often involves more than just the raw API. Here are the tools and resources I rely on.

| Tool/Resource | Purpose | Why I Use It / Notes

Web Workers - a desk with a laptop and a potted plant on it

From Knowing to Doing: Where Most Teams Get Stuck

You now understand what Web Workers are and why they matter for responsive UIs. You've seen the framework, the metrics, and the common pitfalls. But knowing isn't enough — execution is where most teams fail. I’ve seen it repeatedly, both in my own projects and working with others in Dhaka. Developers get the theory, but they struggle to apply it effectively in a real codebase.

The manual way of handling heavy computations on the main thread works, but it’s slow, error-prone, and absolutely does not scale. I learned this building features for Store Warden. We had a dashboard pulling complex analytics for Shopify stores. Initially, I calculated aggregates directly in the main thread. The UI would freeze for seconds, especially for stores with large datasets. It was a terrible user experience. My unexpected insight came when I realized the real blocker wasn't just the computation, but the serialization cost of large data transfers between the main thread and the worker. Simply moving the work wasn't enough; I had to optimize the data payload itself. I broke down the data, sending smaller, more manageable chunks, which drastically improved perceived performance.

Want More Lessons Like This?

I share these practical lessons from the trenches of building and scaling software. If you're tired of theoretical advice and want to learn what truly works in production, you should follow my journey. I break down complex topics into actionable steps, just like this post.

Subscribe to the Newsletter - join other developers building products.

Frequently Asked Questions

Are Web Workers always the best solution for performance bottlenecks? No, Web Workers are not always the best solution. They introduce a communication overhead between the main thread and the worker. For very small, quick computations (e.g., simple string manipulation, basic math), the overhead of creating a worker and passing messages can actually make your code slower than running it directly on the main thread. I only reach for Web Workers when I identify a task that consistently blocks the UI for 50ms or more, or when dealing with large data processing like I did for Flow Recorder's video processing.
Do Web Workers add too much complexity for small projects? It depends on the specific task. For a truly trivial project with no heavy computations, yes, adding Web Workers can be overkill. You're introducing a new file, a new communication pattern (message passing), and debugging across threads. However, if your "small project" has even one feature that could block the UI – like image resizing, complex form validation, or a data-intensive chart – then the slight increase in complexity is a worthwhile trade-off for a smooth user experience. I always prioritize user experience, even for my smaller WordPress plugins.
How long does it take to integrate Web Workers into an existing project? Integrating Web Workers can range from minutes to several hours, depending on the task's complexity. For a simple, self-contained function, you can get a basic worker up and running in 15-20 minutes. You'll create a new `.js` file, instantiate a `Worker` in your main script, and set up basic `postMessage` and `onmessage` handlers. If you're refactoring a deeply integrated, stateful blocking function, it will take longer. You'll need to carefully extract the logic, manage its dependencies, and design an efficient message-passing interface. I typically allocate a few hours for a non-trivial refactor, as I did when optimizing the data synchronization for Paycheck Mate.
What's the absolute simplest way to start using Web Workers today? The simplest way is to create two files: `main.js` and `worker.js`. In `worker.js`, put: `self.onmessage = (e) => { const result = e.data * 2; self.postMessage(result); };` In `main.js`, put: `const myWorker = new Worker('worker.js'); myWorker.postMessage(5); myWorker.onmessage = (e) => { console.log('Worker said:', e.data); };` This basic setup shows message passing. You can find more detailed examples on the [MDN Web Workers documentation](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers). It's a fundamental concept I teach junior developers in Dhaka.
Can Web Workers access the DOM directly? No, Web Workers cannot access the DOM directly. They run in an isolated global context, separate from the main window. This isolation is precisely why they can perform heavy computations without freezing your UI. If a worker needs to interact with the UI, it must do so by sending messages back to the main thread. The main thread then receives these messages and updates the DOM accordingly. This clear separation of concerns is a core principle of robust application architecture, similar to how I separate concerns in my scalable SaaS architecture on AWS.
How do Web Workers handle shared data or state? Web Workers don't directly share memory with the main thread in the traditional sense. Data is copied when sent via `postMessage`. For large datasets, this copying can be inefficient. For better performance, you can use `SharedArrayBuffer` and `Atomics` for true shared memory, or transferrable objects (like `ArrayBuffer`s) which move ownership of the data from one thread to another without copying. I've used transferable objects in Trust Revamp to move large image buffers for processing, significantly reducing overhead. You'll find good examples of these advanced patterns in the official [WHATWG HTML spec on workers](https://html.spec.whatwg.org/multipage/workers.html).

Final Thoughts

You've learned how Web Workers can transform your sluggish web applications into smooth, responsive experiences. The biggest takeaway from my 8+ years of experience, including my AWS Certified Solutions Architect (Associate) journey, is that performance isn't just a feature; it's a fundamental user expectation.

The single most important thing you can do today is identify one blocking task in your current project – a complex filter, a large data sort, a heavy animation calculation – and commit to refactoring it into a Web Worker this week. Start small. Learn by doing. Your users will immediately notice the difference. Your application will feel snappier, more professional. If you want to see what else I'm building, you can find all my projects at besofty.com. Go build faster, more responsive web applications.


Ratul Hasan is a developer and product builder. He has shipped Flow Recorder, Store Warden, Trust Revamp, Paycheck Mate, Custom Role Creator, and other tools for developers, merchants, and product teams. All his projects live at besofty.com. Find him at ratulhasan.com. GitHub LinkedIn

#Web Workers#Off-main-thread JavaScript#Browser background scripts
Back to Articles