The Single-Thread Problem

JavaScript executes code sequentially, one turn at a time. Once a function starts running, everything else queues behind it. That design is fine for quick operations, but when a computation takes seconds, the browser tab freezes and the UI becomes unresponsive. Users can't click, type, or scroll until the work completes.

Web Workers address this by introducing multi-threaded behavior to JavaScript. A worker runs a script in a separate background thread, communicating with the main thread only through messages. The Worker constructor creates a dedicated worker from a named JavaScript file:

const worker = new Worker('worker-file.js')

Workers have constraints. There is no access to the document API inside a worker, so you cannot manipulate the DOM from it. The global scope inside a worker is self, not window. Workers and the spawning thread exchange data using postMessage(), and each side listens for incoming messages with the onmessage event handler. The received message lives in the event's data property. For shared access across multiple scripts, the SharedWorker constructor is available:

const sWorker = new SharedWorker('shared-worker-file.js')

Calculating Fibonacci Numbers

The classic example of a long-running task is computing the nth Fibonacci number. The sequence follows a recursive pattern where each number is the sum of the two preceding ones:

Starting from F1 = 1, the sequence begins: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 .... You can implement a recursive function to compute the nth term:

const fib = n => {
  if (n < 2) {
    return n // or 1
  } else {
    return fib(n - 1) + fib(n - 2)
  }
}

This implementation has exponential time complexity, 0(2n), meaning the runtime grows with each increment of n. For large inputs, this becomes a blocking operation.

To demonstrate the problem, build a simple page with an input field, a calculate button, and a container for results:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="heading-container">
    <h1>Computing the nth Fibonnaci number</h1>
  </div>
  <div class="body-container">
    <p id='error' class="error"></p>
    <div class="input-div">
      <input id='number-input' class="number-input" type='number' placeholder="Enter a number" />
      <button id='submit-btn' class="btn-submit">Calculate</button>
    </div>
    <div id='results-container' class="results"></div>
  </div>
  <script src="https://www.smashingmagazine.com/src/index.js"></script>
</body>
</html>

Add some base styling to keep the page readable:


body {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
  
  .body-container,
  .heading-container {
    padding: 0 20px;
  }
  
  .heading-container {
    padding: 20px;
    color: white;
    background: #7a84dd;
  }
  
  .heading-container > h1 {
    margin: 0;
  }
  
  .body-container {
    width: 50%
  }
  
  .input-div {
    margin-top: 15px;
    margin-bottom: 15px;
    display: flex;
    align-items: center;
  }
  
  .results {
    width: 50vw;
  }
  
  .results>p {
    font-size: 24px;
  }
  
  .result-div {
    padding: 5px 10px;
    border-radius: 5px;
    margin: 10px 0;
    background-color: #e09bb7;
  }
  
  .result-div p {
    margin: 5px;
  }
  
  span.bold {
    font-weight: bold;
  }
  
  input {
    font-size: 25px;
  }
  
  p.error {
    color: red;
  }
  
  .number-input {
    padding: 7.5px 10px;
  }
  
  .btn-submit {
    padding: 10px;
    border-radius: 5px;
    border: none;
    background: #07f;
    font-size: 24px;
    color: white;
    cursor: pointer;
    margin: 0 10px;
  }

In the main script, define the Fibonacci function alongside utility helpers that format output markup:

const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));

const ordinal_suffix = (num) => {
  // 1st, 2nd, 3rd, 4th, etc.
  const j = num % 10;
  const k = num % 100;
  switch (true) {
    case j === 1 && k !== 11:
      return num + "st";
    case j === 2 && k !== 12:
      return num + "nd";
    case j === 3 && k !== 13:
      return num + "rd";
    default:
      return num + "th";
  }
};
const textCont = (n, fibNum, time) => {
  const nth = ordinal_suffix(n);
  return `
  <p id='timer'>Time: <span class='bold'>${time} ms</span></p>
  <p><span class="bold" id='nth'>${nth}</span> fibonnaci number: <span class="bold" id='sum'>${fibNum}</span></p>
  `;
};

The event handler reads the input, logs the start time, computes the Fibonacci number, and measures elapsed milliseconds. Each result renders into its own div with the computation duration:

Showing computed Fibonacci numbers up to 43
Some Fibonacci numbers. (Large preview)

As the input grows, the results clearly show the exponential trend. At 30 the computation takes about 13ms. At 35 it jumps to 130ms. By 40 you're already crossing one second. On a typical machine, somewhere around this point the page becomes unresponsive — the single thread is busy and cannot handle clicks or focus changes until the calculation ends. Computing 44 can take almost ten seconds of frozen UI.

Handing Work to a Worker

Moving the expensive calculation into a Web Worker lets the main thread stay responsive. The worker receives the input number, computes the result, and posts the result back without blocking the UI.

In React, you can instantiate a worker from within a component or a React hook. The worker script handles the same recursive calculation, and hooks like useEffect manage the worker's lifecycle — creating it, listening for messages, and terminating it when the component unmounts.

Key differences to keep in mind when working with workers:

  • Message passing: Use postMessage() to send data to a worker; handle incoming responses by attaching an onmessage handler to the worker instance.
  • No DOM access: Any DOM updates must be performed in the main thread after receiving the result from the worker.
  • File scope: The worker needs its own separate JavaScript file containing the code to execute in the background thread.

With this setup, long computations no longer stall your interface. Users can keep interacting with the page while the worker crunches numbers in the background — a noticeable difference in perceived quality for any application that does heavy data processing.

A Worker-Based Fibonacci Calculator

To put the theory into practice, we’ll offload the Fibonacci computation to a worker. Create a new file src/fib-worker.js:

const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));

onmessage = (e) => {
  const { num } = e.data;
  const startTime = new Date().getTime();
  const fibNum = fib(num);
  postMessage({
    fibNum,
    time: new Date().getTime() - startTime,
  });
};

The fib() function now lives inside the worker file. Communication between the worker and its parent uses the onmessage event handler and the postMessage() method. In the worker, onmessage receives the number from the parent script, starts a timer, performs the computation, and posts the result back with postMessage().

Next, update src/index.js to create the worker and wire up the button:

...

const worker = new window.Worker("src/fib-worker.js");

btn.addEventListener("click", (e) => {
  errPar.textContent = "";
  const num = window.Number(input.value);
  if (num < 2) {
    errPar.textContent = "Please enter a number greater than 2";
    return;
  }

  worker.postMessage({ num });
  worker.onerror = (err) => err;
  worker.onmessage = (e) => {
    const { time, fibNum } = e.data;
    const resultDiv = document.createElement("div");
    resultDiv.innerHTML = textCont(num, fibNum, time);
    resultDiv.className = "result-div";
    resultsContainer.appendChild(resultDiv);
  };
});

The Worker constructor spawns the worker. Inside the button’s event listener, we send the number via worker.postMessage({ num }). We also attach an error handler and a message listener. The message handler destructures time and fibNum from the response and displays them in the DOM. Note that inside the worker, onmessage and postMessage() refer to the worker’s global scope (equivalently self.onmessage), but in the parent script they must be attached to the worker instance.

View of an active web worker file
A running web worker file. (Large preview)

The UI remains responsive regardless of the number entered—that’s the core benefit of using a worker.

Spawning One Worker Per Request

The current implementation uses a single worker for every computation. If a new request arrives while one is still running, the earlier one is discarded. To handle concurrent inputs properly, we can create a fresh worker for each calculation and terminate it when finished.

Move the worker creation inside the button’s click handler and call worker.terminate() after the result is received:

btn.addEventListener("click", (e) => {
  errPar.textContent = "";
  const num = window.Number(input.value);
  
  if (num < 2) {
    errPar.textContent = "Please enter a number greater than 2";
    return;
  }
  
  const worker = new window.Worker("src/fib-worker.js"); // this line has moved inside the event handler
  worker.postMessage({ num });
  worker.onerror = (err) => err;
  worker.onmessage = (e) => {
    const { time, fibNum } = e.data;
    const resultDiv = document.createElement("div");
    resultDiv.innerHTML = textCont(num, fibNum, time);
    resultDiv.className = "result-div";
    resultsContainer.appendChild(resultDiv);
    worker.terminate() // this line terminates the worker
  };
});

Two changes were made:

  1. The line const worker = new window.Worker("src/fib-worker.js") now executes on every click.
  2. The line worker.terminate() disposes of the worker once the computation completes.

With this pattern, each click spawns its own worker. Even if you change the input repeatedly, each result appears independently as soon as it’s ready. In the screenshot below, values for 20 and 30 render before 45 even though 45 was submitted first. Once all workers finish, none remain in the Sources tab.

showing Fibonacci numbers with terminated workers
Illustration of Multiple independent workers. (Large preview)

Bringing Workers Into a React App

To use this pattern in React, start by creating a new app with Create React App. Copy fib-worker.js into the public/ folder—React apps are single-page, so the worker file must be served from public/. Everything else is standard React.

Create src/helpers.js and export the ordinal_suffix() function:

// src/helpers.js

export const ordinal_suffix = (num) => {
  // 1st, 2nd, 3rd, 4th, etc.
  const j = num % 10;
  const k = num % 100;
  switch (true) {
    case j === 1 && k !== 11:
      return num + "st";
    case j === 2 && k !== 12:
      return num + "nd";
    case j === 3 && k !== 13:
      return num + "rd";
    default:
      return num + "th";
  }
};

State management comes next. Create src/reducer.js with the following reducer:

// src/reducers.js

export const reducer = (state = {}, action) => {
  switch (action.type) {
    case "SET_ERROR":
      return { ...state, err: action.err };
    case "SET_NUMBER":
      return { ...state, num: action.num };
    case "SET_FIBO":
      return {
        ...state,
        computedFibs: [
          ...state.computedFibs,
          { id: action.id, nth: action.nth, loading: action.loading },
        ],
      };
    case "UPDATE_FIBO": {
      const curr = state.computedFibs.filter((c) => c.id === action.id)[0];
      const idx = state.computedFibs.indexOf(curr);
      curr.loading = false;
      curr.time = action.time;
      curr.fibNum = action.fibNum;
      state.computedFibs[idx] = curr;
      return { ...state };
    }
    default:
      return state;
  }
};

The reducer handles four action types:

  1. SET_ERROR: stores an error state.
  2. SET_NUMBER: updates the input value in state.
  3. SET_FIBO: appends a new entry to the list of computed FNs.
  4. UPDATE_FIBO: replaces an existing entry with an updated object containing the computed FN and elapsed time.

Now create src/Results.js to display the computed values:

// src/Results.js

import React from "react";

export const Results = (props) => {
  const { results } = props;
  return (
    <div id="results-container" className="results-container">
      {results.map((fb) => {
        const { id, nth, time, fibNum, loading } = fb;
        return (
          <div key={id} className="result-div">
            {loading ? (
              <p>
                Calculating the{" "}
                <span className="bold" id="nth">
                  {nth}
                </span>{" "}
                Fibonacci number...
              </p>
            ) : (
              <>
                <p id="timer">
                  Time: <span className="bold">{time} ms</span>
                </p>
                <p>
                  <span className="bold" id="nth">
                    {nth}
                  </span>{" "}
                  fibonnaci number:{" "}
                  <span className="bold" id="sum">
                    {fibNum}
                  </span>
                </p>
              </>
            )}
          </div>
        );
      })}
    </div>
  );
};

This component receives an array of FN objects and renders them. A loading state is added so the user knows when a computation is in progress.

Finally, update src/App.js. Add the first block of logic:

import React from "react";
import "./App.css";
import { ordinal_suffix } from "./helpers";
import { reducer } from './reducer'
import { Results } from "./Results";
function App() {
  const [info, dispatch] = React.useReducer(reducer, {
    err: "",
    num: "",
    computedFibs: [],
  });
  const runWorker = (num, id) => {
    dispatch({ type: "SET_ERROR", err: "" });
    const worker = new window.Worker('./fib-worker.js')
    worker.postMessage({ num });
    worker.onerror = (err) => err;
    worker.onmessage = (e) => {
      const { time, fibNum } = e.data;
      dispatch({
        type: "UPDATE_FIBO",
        id,
        time,
        fibNum,
      });
      worker.terminate();
    };
  };
  return (
    <div>
      <div className="heading-container">
        <h1>Computing the nth Fibonnaci number</h1>
      </div>
      <div className="body-container">
        <p id="error" className="error">
          {info.err}
        </p>

        // ... next block of code goes here ... //

        <Results results={info.computedFibs} />
      </div>
    </div>
  );
}
export default App;

After imports, we initialize state with useReducer. The runWorker() function takes a number and an ID, creates a worker, and dispatches UPDATE_FIBO when the result arrives. The worker path is relative—at runtime, the app is served from public/index.html, so the worker file is found in the same directory. The worker is terminated after each message.

Add the return block:

        <div className="input-div">
          <input
            type="number"
            value={info.num}
            className="number-input"
            placeholder="Enter a number"
            onChange={(e) =>
              dispatch({
                type: "SET_NUMBER",
                num: window.Number(e.target.value),
              })
            }
          />
          <button
            id="submit-btn"
            className="btn-submit"
            onClick={() => {
              if (info.num < 2) {
                dispatch({
                  type: "SET_ERROR",
                  err: "Please enter a number greater than 2",
                });
                return;
              }
              const id = info.computedFibs.length;
              dispatch({
                type: "SET_FIBO",
                id,
                loading: true,
                nth: ordinal_suffix(info.num),
              });
              runWorker(info.num, id);
            }}
          >
            Calculate
          </button>
        </div>

The input’s onChange updates info.num. The button’s onClick checks that the number is greater than 2, dispatches SET_FIBO to reserve its position in the list, then calls runWorker(). Each entry keeps its place in the array, unlike the earlier implementation. Finally, replace the contents of App.css with the previous styles.css.

showing loading state while worker is active.
Showing loading state and active web worker. (Large preview)

Notice the loading state and the active worker in the Sources tab. Once the computation finishes, the worker is killed and the loading state is replaced with the final result.

Conclusion

Workers are a straightforward way to keep long-running tasks off the main thread, preserving UI responsiveness. We’ve covered what they are, how to use them in plain JavaScript, and how to integrate them into a React application. For further detail on additional worker APIs and patterns, the MDN documentation on web workers is a good starting point.