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
postMessageget serialized, not shared by reference (exceptSharedArrayBufferand transferableArrayBuffers) - 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
| Web Workers | Worker Threads | |
|---|---|---|
| Environment | Browser | Node.js |
| Communication | postMessage / onmessage | parentPort.postMessage / .on('message') |
| DOM access | No | Not applicable |
| Shared memory | Only with SharedArrayBuffer | SharedArrayBuffer natively |
| Overhead | Medium | Low |
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.