← Back
Fundamentals 6 min read

JavaScript is single-threaded. Workers are the way out.

When a heavy operation blocks the event loop, the UI freezes or the server stops responding. Web Workers and Worker Threads are JavaScript's mechanism for running code in parallel — and they are not the same thing.

JavaScript runs on a single thread. It always has, and it makes sense: it simplifies the concurrency model, eliminates the most common race conditions, and makes writing asynchronous code with async/await reasonably predictable.

The problem shows up when an operation is CPU-intensive. Processing a large image, parsing a heavy file, running an encryption algorithm — any task that takes more than a few milliseconds blocks that single thread. In the browser, the UI freezes. In Node.js, the server stops serving requests until it finishes.

Workers are the solution: separate threads of execution that run in parallel without blocking the main thread. But there are two kinds — and picking the wrong one simply does not work.

Web Workers — concurrency in the browser

A Web Worker runs on a separate thread inside the browser. It has no access to the DOM, it cannot touch window, and it communicates with the main thread only through messages.

The basic pattern involves two files: the main thread that creates the worker and sends it work, and the worker file that receives that work and returns the result.

// main.js — main thread
const worker = new Worker('worker.js')

worker.postMessage({ data: arrayGrande })

worker.onmessage = (event) => {
  console.log('Resultado:', event.data.result)
}
// worker.js — separate thread
self.onmessage = (event) => {
  const resultado = procesarDatos(event.data.data) // heavy operation
  self.postMessage({ result: resultado })
}

The main thread never blocks while the worker crunches. The UI keeps responding.

Important limitations:

  • No access to the DOM or to APIs like localStorage — the worker lives in its own isolated context
  • Communication is asynchronous and by copy — the objects you send with postMessage get serialized, not shared by reference (except SharedArrayBuffer and transferable ArrayBuffers)
  • It only works same-origin — you cannot load a worker from another domain

When to use it: image or audio processing in the browser, heavy computations triggered by the user (simulations, filters, compression), parsing large files without freezing the UI.

Worker Threads — concurrency in Node.js

The Node.js equivalent is worker_threads, available since Node 12. The model is similar — separate threads communicating through messages — but the API uses parentPort instead of self.

// server.js — main thread
const { Worker } = require('worker_threads')

const worker = new Worker('./worker.js', {
  workerData: { input: datosHeavy }
})

worker.on('message', (result) => {
  console.log('Resultado del worker:', result)
})
// worker.js — separate thread
const { workerData, parentPort } = require('worker_threads')

const resultado = operacionPesada(workerData.input)
parentPort.postMessage(resultado)

Unlike child_process.fork(), Worker Threads share memory with the parent process — they can use SharedArrayBuffer to hand off data without copying it. That makes them more efficient for tasks that juggle large buffers.

When to use it: encryption or hashing on the server, batch data processing, server-side PDF or image generation, any CPU-bound task that would otherwise run in the same process and block Node's event loop.

The difference that matters

Comparison diagram: Web Workers in the browser vs Worker Threads in Node.js

Web WorkersWorker Threads
EnvironmentBrowserNode.js
CommunicationpostMessage / onmessageparentPort.postMessage / .on('message')
DOM accessNoNot applicable
Shared memoryOnly with SharedArrayBufferSharedArrayBuffer natively
OverheadMediumLow

The most common confusion is trying to use Web Workers in Node.js or worker_threads in the browser. They are not interchangeable — they are distinct mechanisms for the same problem in distinct environments.

What they do not solve

Workers are not the answer to everything. For I/O operations — reading a file, making an HTTP request, querying a database — JavaScript's asynchronous event loop already handles concurrency well. An await fetch(...) does not block the thread; the result arrives when it is ready while the thread carries on with other work.

Workers are for CPU. If what you have is an operation that burns processor time — not time spent waiting on network or disk — that is where the single-threaded model breaks down and Workers are the right answer.

Good practices

Keep workers stateless. A worker that takes inputs, runs a computation, and returns a result is predictable and easy to reason about. A worker with internal state that piles up between messages is a source of nasty bugs.

Terminate workers once you are done with them. A worker left alive consumes resources. Call worker.terminate() when you have finished using it.

Use transferables for large data. Instead of copying an ArrayBuffer with postMessage, transfer it — the buffer moves from one context to the other with no copy. Faster, less memory.

const buffer = new ArrayBuffer(1024 * 1024 * 10) // 10MB
worker.postMessage(buffer, [buffer]) // second arg = list of transferables
// buffer is no longer accessible on the main thread

JavaScript's single-threaded model is not a limitation to overcome — it is a design decision with clear trade-offs. Workers do not replace that model; they extend it for the cases where it is not enough.

Next · Fundamentals · 6 min Why your variable exists before you declare it Read next →