JavaScript
min read
Last update on

Unblocking the UI with web workers: a guide for faster web apps

Unblocking the UI with web workers: a guide for faster web apps
Table of contents

Ever clicked a button in a web app, and the UI just froze? Maybe a timer stopped, animations stuttered, or clickable elements stopped responding. This happens because JavaScript is single-threaded, meaning it can only do one thing at a time. 

When a heavy computation runs on the main thread, it blocks the UI, creating a poor user experience.

In this blog, we’ll explore Web Workers, a simple and effective way to offload heavy computations to a background thread in a React app, keeping your UI smooth and responsive.

Understanding the problem: single-threaded JavaScript

JavaScript runs on a single thread that handles both:

  • UI rendering
  • JavaScript execution

This means if you run a heavy task, everything else stops until it finishes. Here’s a classic example:

// Blocks UI for several seconds
    function fibonacci(n) {
      if (n <= 1) return n;
      return fibonacci(n - 1) + fibonacci(n - 2);
    }
    const result = fibonacci(44);
    console.log(result)

If this runs on the main thread, timers, animations, and clicks stop responding.

Introducing web workers

Web Workers allow us to run JavaScript code in a separate background thread. This way:

  • Heavy tasks don’t freeze the UI
  • You can continue interacting with the page
  • Communication is done via postMessage() and onmessage()

Note: Workers cannot access the DOM directly; they are only for computations.

Web worker syntax

Worker File (public/worker.js):

self.onmessage = function (e) {

    function fibonacci(n) {
      if (n <= 1) return n;
      return fibonacci(n - 1) + fibonacci(n - 2);
    }

    const result = fibonacci(e.data);
    self.postMessage(result);
};

Main Thread (React Component):

  const workerRef = useRef(null);

  useEffect(() => {  
    workerRef.current = new Worker("/worker.js"); 

    workerRef.current.onmessage = (e) => { 
      alert('worker finished! Result : ',e.data);
    };

    return () => {
      workerRef.current.terminate(); 
    };
  }, []);

  const runWorkerTask = () => {
    workerRef.current.postMessage(44); 
  };

React demo: without vs with web worker

Setting up a Next.js Project with Web Workers

1. Create Next.js App

React demo

2. Project Folder Structure

Here’s a minimal structure we’ll use:

Project Folder Structure

Without worker (UI freezes)

Create WithoutWorker Component File (app/WithoutWorker.js)

'use client';
import { useEffect, useState } from 'react';

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

export default function WithoutWorker() {
  const [timer, setTimer] = useState(0);
  const [color, setColor] = useState('lightblue');
  const [size, setSize] = useState(100);

  useEffect(() => {
    const interval = setInterval(() => setTimer((t) => t + 1), 1000);
    return () => clearInterval(interval);
  }, []);

  const handleHeavyTask = async () => {
    console.log('handleHeavyTask started...');
    const result = fibonacci(44); // Heavy enough to lag most browsers
    console.log('handleHeavyTask ended... : ', result);
  };

  const animateBox = () => {
    // Try clicking this during a heavy task, no response
    setColor((prev) => (prev === 'lightblue' ? 'lightcoral': 'lightblue'));
    setSize((prev) => (prev === 100 ? 550 : 100));
  };

  return (
    <div>
      <h2>Without Web Worker</h2>
      <p>Timer: {timer}</p>
      <button
        onClick={handleHeavyTask}
        className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
      >
        Run Heavy Task
      </button>
      <div
        onClick={animateBox}
        style={{
          marginTop: '20px',
          width: `${size}px`,
          height: `${size}px`,
          backgroundColor: colour,
          cursor: 'pointer',
          transition: 'all 0.3s ease',
        }}
      >
        Click Me
      </div>
    </div>
  );
}

Observation: Click the box while running the heavy task , nothing happens. The timer also freezes.

localhost

With worker (UI smooth)

Create Worker File (public/worker.js)

self.onmessage = function (e) {

    function fibonacci(n) {
      if (n <= 1) return n;
      return fibonacci(n - 1) + fibonacci(n - 2);
    }

    const result = fibonacci(44);
    
    self.postMessage(result);
};

Create WithWorker Component File (app/WithWorker.js)

"use client"
import { useEffect, useRef, useState } from "react";

export default function WithWorker() {
  const [timer, setTimer] = useState(0);
  const [color, setColor] = useState("lightgreen");
  const [size, setSize] = useState(100);
  const workerRef = useRef(null);

  useEffect(() => {
    const interval = setInterval(() => setTimer(t => t + 1), 1000);
    return () => clearInterval(interval);
  }, []);

  useEffect(() => {  // Create the worker on mount
    workerRef.current = new Worker("/worker.js"); // Creates a new background thread by loading our worker script.
    workerRef.current.onmessage = (e) => { // Listens for the result from the worker.
      console.log('handleHeavyTask ended... : ',e.data)
    };
    return () => {
      workerRef.current.terminate(); // Cleans up the worker on component unmount.
    };
  }, []);

  const runWorkerTask = () => {
    console.log('handleHeavyTask started...')  
    workerRef.current.postMessage(44); // Sends a message to the worker to begin processing.
  };

  const animateBox = () => {  // This should stay responsive even during heavy task
    setColor((prev) => (prev === "lightgreen" ? "orange" : "lightgreen"));
    setSize((prev) => (prev === 100 ? 550 : 100));
  };

  return (
    <div>
      <h2>With Web Worker</h2>
      <p>Timer: {timer}</p>
      <button 
        onClick={runWorkerTask}
        className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
      >Run Heavy Task</button>
      <div
        onClick={animateBox}
        style={{
          marginTop: "20px",
          width: `${size}px`,
          height: `${size}px`,
          backgroundColor: colour,
          cursor: "pointer",
          transition: "all 0.3s ease"
        }}
      >
        Click Me
      </div>
    </div>
  );
}

localhost web worker

Observation: Click the box while the worker runs, the timer keeps going, and the box responds instantly! 

Error handling 

Handle Worker Errors in React

You can attach an onerror listener when creating the worker:

  useEffect(() => {  // Create the worker on mount
    workerRef.current = new Worker("/worker.js"); // Creates a new background thread by loading our worker script.
    workerRef.current.onmessage = (e) => { // Listens for the result from the worker.
      console.log('handleHeavyTask ended... : ',e.data)
    };
    workerRef.current.onerror = (err) => { // Error handler
      console.error("Worker error:", err.message);
      alert("Something went wrong in the worker: " + err.message);
    };
    return () => {
      workerRef.current.terminate(); // Cleans up the worker on component unmount.
    };
  }, []);

Catch Errors Inside Worker File

In your worker.js, wrap computation in a try/catch:

self.onmessage = function (e) {
  try {
    const n = e.data;

    if (typeof n !== "number" || n < 0) {
      throw new Error("Invalid input for Fibonacci");
    }

    function fibonacci(x) {
      if (x <= 1) return x;
      return fibonacci(x - 1) + fibonacci(x - 2);
    }

    const result = fibonacci(n);
    self.postMessage(result);
  } catch (err) {
    // Send error back to main thread
    self.postMessage({ error: err.message });
  }
};

Key takeaways

  • Web Workers allow true multithreading in the browser.
  • Use them for CPU-intensive tasks like data processing, parsing large files, or complex calculations.
  • Async/await only helps with I/O operations, not CPU-heavy work.
  • Always clean up workers using .terminate() to avoid memory leaks.

Conclusion

Web Workers keep React apps responsive by moving heavy computations off the main thread, ensuring smooth user experiences even under load. They are already valuable in finance, simulations, and real-time communication, and their role will expand as WebAssembly, progressive web apps, and edge computing mature. 

Countries like Singapore, South Korea, and Estonia are leading in digital infrastructure, while global research explores energy-efficient and privacy-focused applications. Future directions include integration with in-browser AI, distributed multi-worker systems, and secure collaboration protocols. 

In my view, Web Workers are still underused in React, but they are on track to become a standard tool for performance-driven development.

Written by
Editor
Ananya Rakhecha
Tech Advocate