Home / Articles / Offloading CPU-Heavy Work in Node.js with worker_threads and Pools

This article is published in English.

Offloading CPU-Heavy Work in Node.js with worker_threads and Pools

Learn how Node.js worker_threads keep the event loop responsive: spawning workers, request-response messaging, transferable buffers, pools, and their pitfalls.

4814 words

Node.js handles thousands of concurrent connections on a single-threaded event loop because almost all of that work is waiting on the network or the disk. The model breaks down the moment a request needs real computation: hashing a large payload, resizing an image, or crunching a dataset keeps the one JavaScript thread busy, and every other request waits behind it. The worker_threads module is the built-in answer. After working through this guide you will be able to move CPU-bound code into workers, exchange data with them efficiently, run them as a pool, and recognize the cases where a worker is the wrong tool.

Why worker threads exist

worker_threads first appeared in Node.js 10.5.0 behind an experimental flag and has been treated as stable since the Node.js 12 line. It lets a single Node.js process run JavaScript on several threads at once.

It differs from child_process in an important way. A child process is an entirely separate Node.js process with its own memory and its own copy of the runtime. A worker lives inside the same process, but it is not simply "another thread sharing everything". Each worker gets its own V8 isolate, its own heap and its own event loop. Nothing is shared implicitly; workers talk by passing messages, and they share memory only when you explicitly hand them a SharedArrayBuffer. (A frequently repeated claim is that workers share the main thread's V8 instance and memory. That is not accurate, and the difference explains most of the API design below.) Compared with processes, workers are cheaper to start and communicate with, which makes them the natural way to use several cores from one application instance.

What a blocked event loop costs you

The central job of a worker is to keep the event loop free. While a synchronous computation runs on the main thread, the server cannot accept new requests, cannot make progress on open connections, and cannot even answer a health check. The practical fallout looks like this:

  • Degraded user experience: API responses arrive late, and real-time features such as live updates stutter.
  • Lower throughput: one long task occupies the only JavaScript thread, so requests per second drop.
  • Instability: extended blocking can exceed load balancer or client timeouts, and those failures can cascade to other services.

Moving the heavy computation to a worker leaves the main loop free to keep servicing sockets and timers, so the application stays responsive even while serious computation is underway.

For a deeper look at how the event loop, libuv and its thread pool fit together, see how Node.js concurrency works under the hood.

Using every core

Servers usually have many CPU cores. Without workers, or without running several processes through child_process or a process manager such as PM2, a single Node.js process can use roughly one core for CPU-bound JavaScript. The rest sit idle. Workers let one application run several heavy tasks in parallel across the available cores and raise total computational throughput accordingly.

Workloads that become practical

Parallel computation opens up domains that were traditionally left to languages with first-class threading:

  • Data processing: real-time analytics, image and video manipulation, and transformations of large datasets.
  • Machine learning inference: executing trained models while the rest of the application stays interactive.
  • Cryptography: hashing, encryption and decryption.
  • Simulation and scientific computing: parallel simulations and heavy mathematical models.

The key point is that none of this costs you the asynchronous I/O model. Workers add parallel computation on top of it.

The building blocks of the module

worker_threads exposes a small set of primitives:

  • Worker: the class the main thread uses to start a new worker from a script.
  • isMainThread: a boolean that tells you whether the current code is running on the main thread, handy when one file can play both roles.
  • parentPort: available inside a worker, it is the channel back to the thread that created it. It is null on the main thread.
  • workerData: available inside a worker, it holds a clone of whatever data the parent supplied when creating the worker.
  • MessagePort and MessageChannel: used to create additional independent two-way channels between threads.
  • SharedArrayBuffer and Atomics: genuine shared memory plus the synchronization primitives needed to use it safely. They are the most efficient option and also the most complex, because they reintroduce race conditions.

A first worker: finding prime numbers off the main thread

A good first exercise is a deliberately slow task: computing every prime up to a large limit. The example uses two files that live in the same folder.

The main thread script

The main script creates the worker, gives it its input and waits for the answer. Before reading it, note three things. The worker is created from a separate file path. The input travels in the workerData option. And the parent subscribes to three events: message for results, error for exceptions the worker did not catch, and exit for when the thread stops.

// main.js
const { Worker, isMainThread, workerData } = require('worker_threads');
const path = require('path');

if (isMainThread) {
    console.log(`Main thread started. PID: ${process.pid}`);

    const largeNumber = 20_000_000; // A large number for prime calculation

    console.log(`Starting CPU-intensive task (finding primes up to ${largeNumber})...`);
    const startTime = Date.now();

    const worker = new Worker(path.join(__dirname, 'prime-worker.js'), {
        workerData: { limit: largeNumber }
    });

    worker.on('message', (result) => {
        const endTime = Date.now();
        console.log(`Worker finished. Found ${result.length} primes.`);
        console.log(`Time taken by worker: ${endTime - startTime}ms`);
        // console.log('Primes found:', result.slice(0, 10), '...'); // Log first 10 for brevity

        // Demonstrate a simple non-blocking task on the main thread
        console.log('Main thread continuing with other tasks...');
        setTimeout(() => {
            console.log('Main thread completed a separate non-blocking task.');
        }, 100);
    });

    worker.on('error', (err) => {
        console.error('Worker error:', err);
    });

    worker.on('exit', (code) => {
        if (code !== 0)
            console.error(`Worker stopped with exit code ${code}`);
        else
            console.log('Worker thread exited normally.');
    });

} else {
    // This part will not be executed in our scenario as worker.js runs separately.
    // It's here for illustrative purposes if main.js itself was to be imported as a worker.
    console.log('This code runs inside a worker thread if main.js was spawned as one.');
    console.log('WorkerData:', workerData);
}

Walking through the important lines:

  • The isMainThread check guards the spawning logic so it only runs on the main thread. The else branch exists purely to illustrate what would happen if this same file were loaded as a worker; in this setup it never executes.
  • largeNumber is set high enough (20 million) that the computation takes noticeable time.
  • new Worker(path.join(__dirname, 'prime-worker.js'), { workerData: { limit: largeNumber } }) is the core call. The first argument points to the script the new thread will run, which must be its own file. The second is an options object whose workerData becomes the worker's initial input.
  • workerData is copied using the HTML structured clone algorithm. The worker receives its own copy, not a reference to the parent's object.
  • The message listener receives whatever the worker posts, here the array of primes. The error listener fires for uncaught exceptions inside the worker, and the exit listener receives an exit code, where 0 means a normal finish.

The worker script

The worker contains nothing but the expensive computation and the code that reports its result. The findPrimes function is a straightforward trial-division loop, intentionally unoptimized so that it burns CPU time.

// prime-worker.js
const { parentPort, workerData } = require('worker_threads');

// A simple (not highly optimized) function to find prime numbers
function findPrimes(limit) {
    const primes = [];
    for (let i = 2; i <= limit; i++) {
        let isPrime = true;
        for (let j = 2; j <= Math.sqrt(i); j++) {
            if (i % j === 0) {
                isPrime = false;
                break;
            }
        }
        if (isPrime) {
            primes.push(i);
        }
    }
    return primes;
}

// Ensure this code only runs if it's indeed a worker thread being executed
if (parentPort) {
    const { limit } = workerData;
    console.log(`Worker thread started to find primes up to ${limit}.`);

    // Simulate an error for demonstration purposes sometimes
    // if (Math.random() < 0.2) {
    //     throw new Error('Simulated worker error!');
    // }

    try {
        const result = findPrimes(limit);
        parentPort.postMessage(result); // Send the result back to the main thread
    } catch (error) {
        console.error('Error during prime calculation in worker:', error);
        parentPort.postMessage({ error: error.message }); // Send error back
    }
} else {
    console.log("This script is intended to be run as a worker thread.");
}

What to notice:

  • It pulls parentPort and workerData from the module.
  • The if (parentPort) guard ensures the logic only runs when the file is loaded as a worker; if you execute it directly with node, parentPort is null and it prints a message instead.
  • const { limit } = workerData; reads the input the parent sent.
  • parentPort.postMessage(result) delivers the primes to the main thread, where the message handler picks them up.
  • The try...catch block logs any failure during the calculation and reports it to the parent instead of letting the thread die.

One subtlety: on failure the worker posts { error: ... } on the same message channel used for results, but the main script treats every message as an array of primes. In real code, give messages an explicit shape (for example a status or type field) so the parent can tell a result from an error.

Running it and reading the output

Save both files next to each other and start the program with node main.js. You will see the main thread announce itself, the worker report that it has started, and some time later the worker's result followed by the elapsed time.

Pay attention to the ordering of the line "Main thread continuing with other tasks...". In this script it is printed inside the message handler, so it only appears after the worker finishes. It demonstrates that the main thread was able to receive and process the result, not that it was doing other work in the meantime. To actually see the main thread stay responsive during the calculation, start something on the main thread before the result arrives, such as a setInterval that logs a heartbeat every few hundred milliseconds. The heartbeat keeps ticking while the worker computes, which is exactly what would not happen if findPrimes ran on the main thread.

Two-way messaging with a request-response protocol

The first example sends one input and gets one result. Most real uses need a long-lived worker that handles many requests. Communication is fully bidirectional: a Worker object on the main thread and parentPort inside the worker both provide .postMessage(...) and .on('message', ...).

The worker below stays alive and waits for messages. When it receives a calculateFibonacci request, it computes the value with a naive recursive function (slow on purpose) and replies with either a fibonacciResult or a fibonacciError message, echoing the request's identifier so the caller can match the reply.

// request-response-worker.js
const { parentPort } = require('worker_threads');

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

if (parentPort) {
    parentPort.on('message', (message) => {
        if (message.type === 'calculateFibonacci') {
            const { n, requestId } = message.payload;
            console.log(`Worker: Calculating Fibonacci(${n}) for request ID ${requestId}`);
            try {
                const result = fibonacci(n);
                parentPort.postMessage({ type: 'fibonacciResult', requestId, payload: result });
            } catch (error) {
                console.error(`Worker: Error calculating Fibonacci(${n}):`, error);
                parentPort.postMessage({ type: 'fibonacciError', requestId, payload: error.message });
            }
        }
    });
    console.log('Worker ready to receive Fibonacci requests.');
}

On the main side, the trick is turning message passing into promises. Each call generates a unique requestId, stores the promise's resolve and reject in a Map under that ID, and posts the request. When a reply arrives, the message handler looks up the ID, removes the entry and settles the matching promise. The example uses the uuid package for IDs; on current Node.js versions crypto.randomUUID() from the built-in node:crypto module does the same job without a dependency.

// main-request-response.js
const { Worker, isMainThread } = require('worker_threads');
const path = require('path');
const { v4: uuidv4 } = require('uuid'); // npm install uuid

if (isMainThread) {
    const worker = new Worker(path.join(__dirname, 'request-response-worker.js'));
    const pendingRequests = new Map();

    worker.on('message', (message) => {
        const { type, requestId, payload } = message;
        if (pendingRequests.has(requestId)) {
            const { resolve, reject } = pendingRequests.get(requestId);
            pendingRequests.delete(requestId);

            if (type === 'fibonacciResult') {
                resolve(payload);
            } else if (type === 'fibonacciError') {
                reject(new Error(payload));
            }
        }
    });

    worker.on('error', (err) => {
        console.error('Worker error:', err);
    });

    worker.on('exit', (code) => {
        if (code !== 0) console.error(`Worker stopped with exit code ${code}`);
    });

    async function calculateFibonacciInWorker(n) {
        const requestId = uuidv4();
        return new Promise((resolve, reject) => {
            pendingRequests.set(requestId, { resolve, reject });
            worker.postMessage({ type: 'calculateFibonacci', payload: { n }, requestId });
        });
    }

    (async () => {
        console.log('Main: Sending Fibonacci requests to worker...');
        try {
            const fib40 = await calculateFibonacciInWorker(40);
            console.log(`Main: Fibonacci(40) = ${fib40}`);

            const fib35 = await calculateFibonacciInWorker(35);
            console.log(`Main: Fibonacci(35) = ${fib35}`);

            // This will block the main thread if done directly, but not here
            // console.log(`Main: Local Fibonacci(40) = ${fibonacci(40)}`);

        } catch (error) {
            console.error('Main: Failed to get Fibonacci result from worker:', error);
        } finally {
            worker.terminate(); // Terminate worker when done
        }
    })();
}

The pattern in summary:

  • The main thread creates a requestId for every request so replies can be matched to their promises even if they arrive out of order.
  • The pendingRequests map holds the resolve and reject functions until the worker answers.
  • The worker handles calculateFibonacci messages and replies with fibonacciResult or fibonacciError, carrying the original requestId.

Watch the message shape carefully if you adapt this code. The main thread puts requestId at the top level of the message, next to payload, while the worker destructures it from message.payload. As written, the worker therefore sees requestId as undefined, the reply cannot be matched, and the awaited promise never settles. Keep the ID in one agreed place on both sides (for example payload: { n, requestId }). A timeout on each pending request is also worth adding so a lost reply turns into an error rather than a hang. Finally, note the finally block: once the work is done, worker.terminate() stops the thread so the process can exit.

Moving large data without copying it

Everything sent through postMessage is structured-cloned by default, which means the receiver gets a copy. For small messages that is fine. For large binary data the copy itself can become the bottleneck. The fix is to transfer ownership of the underlying memory instead of copying it: the object becomes unusable for the sender and immediately usable for the receiver, with no duplication.

ArrayBuffer and MessagePort are the most commonly transferred objects. SharedArrayBuffer is a separate case: it is not transferred at all, because both threads can access the same memory simultaneously.

The example below contains two scripts in one listing. The worker (buffer-worker.js) receives a buffer, doubles every byte in place and sends the buffer back as a transferable. The main script (main-buffer.js) fills a 1 MB buffer, transfers it to the worker and reads the processed data when it returns.

// buffer-worker.js
const { parentPort } = require('worker_threads');

if (parentPort) {
    parentPort.on('message', (message) => {
        if (message.type === 'processBuffer') {
            const { buffer } = message.payload; // This is now the ArrayBuffer
            const uint8 = new Uint8Array(buffer);

            // Modify the buffer in the worker
            for (let i = 0; i < uint8.length; i++) {
                uint8[i] = uint8[i] * 2;
            }
            console.log('Worker: Buffer processed. First 5 elements:', uint8.slice(0, 5));

            // Send it back as a transferable
            parentPort.postMessage({ type: 'bufferProcessed', payload: buffer }, [buffer]);
        }
    });
}
// main-buffer.js
const { Worker, isMainThread } = require('worker_threads');
const path = require('path');

if (isMainThread) {
    const worker = new Worker(path.join(__dirname, 'buffer-worker.js'));
    const bufferSize = 1024 * 1024; // 1MB
    let myBuffer = new ArrayBuffer(bufferSize);
    let uint8 = new Uint8Array(myBuffer);

    // Initialize buffer
    for (let i = 0; i < uint8.length; i++) {
        uint8[i] = i % 256;
    }
    console.log('Main: Original buffer (first 5 elements):', uint8.slice(0, 5));

    worker.postMessage({ type: 'processBuffer', payload: { buffer: myBuffer } }, [myBuffer]);

    // After postMessage with transfer, myBuffer becomes detached/empty in main thread
    // Attempting to access it will result in an error or empty view.
    // console.log('Main: Buffer after transfer (should be detached):', uint8.slice(0, 5)); // This would likely show zeros or error.

    worker.on('message', (message) => {
        if (message.type === 'bufferProcessed') {
            const receivedBuffer = message.payload;
            const receivedUint8 = new Uint8Array(receivedBuffer);
            console.log('Main: Received processed buffer (first 5 elements):', receivedUint8.slice(0, 5));
            worker.terminate();
        }
    });
}

The key details:

  • The second argument to postMessage, here [myBuffer] on one side and [buffer] on the other, is the transfer list.
  • When myBuffer is sent, its ownership moves to the worker. In the main thread the buffer becomes detached: its byteLength drops to 0 and any typed-array view over it, such as the earlier uint8, has length 0.
  • The worker modifies the data and transfers it back, so the main thread receives a buffer again.
  • The 1 MB of data is never copied in either direction.

The trade-off is that you must stop using the original reference after the transfer. If both threads genuinely need the data at the same time, you need a copy or a SharedArrayBuffer.

Handling errors and cleaning up

Workers fail like any other code, and they hold memory and a thread while they live. The tools available:

  • worker.on('error', handler) on the main thread: receives exceptions that were thrown in the worker and never caught.
  • worker.on('exit', handler) on the main thread: fires whenever the worker stops, whether it finished normally or crashed. The exit code distinguishes the two, with 0 for success.
  • process.on('uncaughtException', handler) inside the worker: gives you a place to log or report details from within the worker before it goes down, in addition to what the parent sees.
  • worker.terminate(): stops the worker as soon as possible and returns a promise that resolves with the exit code. It does not wait for in-flight work to finish, so if you need a graceful shutdown, send the worker a "stop" message and let it exit on its own. Always make sure workers that are no longer needed are stopped one way or the other.

Running many tasks through a worker pool

Creating a new worker for every task is wasteful, and running more CPU-bound workers than you have cores only adds contention. A worker pool solves both problems: it starts a fixed number of workers, puts incoming tasks in a queue and hands each task to the next idle worker.

The simplified pool below keeps an array of worker records, a list of free worker IDs and a queue of pending tasks. runTask returns a promise and enqueues the task; processQueue pairs the oldest task with a free worker; when a worker reports back, its promise is resolved and it rejoins the free list. If a worker errors or exits abnormally, terminateWorker replaces it with a fresh one to keep the pool at full size.

// workerPool.js
const { Worker } = require('worker_threads');
const path = require('path');

class WorkerPool {
    constructor(workerPath, numWorkers) {
        this.workerPath = workerPath;
        this.numWorkers = numWorkers;
        this.workers = [];
        this.freeWorkers = [];
        this.queue = [];

        this.initWorkers();
    }

    initWorkers() {
        for (let i = 0; i < this.numWorkers; i++) {
            const worker = new Worker(this.workerPath);
            worker.id = i; // Assign an ID for easier debugging
            worker.on('message', (message) => {
                // Resolve the promise associated with this worker's task
                this.workers[worker.id].resolve(message.payload);
                this.returnWorkerToPool(worker.id);
                this.processQueue();
            });
            worker.on('error', (err) => {
                this.workers[worker.id].reject(err);
                console.error(`Worker ${worker.id} error:`, err);
                this.terminateWorker(worker.id); // Re-initialize or handle as appropriate
            });
            worker.on('exit', (code) => {
                if (code !== 0) console.error(`Worker ${worker.id} exited with code ${code}`);
                this.terminateWorker(worker.id); // Handle crashed worker
            });
            this.freeWorkers.push(worker.id);
            this.workers.push({ instance: worker, busy: false, resolve: null, reject: null });
        }
        console.log(`Worker Pool initialized with ${this.numWorkers} workers.`);
    }

    runTask(taskData) {
        return new Promise((resolve, reject) => {
            this.queue.push({ taskData, resolve, reject });
            this.processQueue();
        });
    }

    processQueue() {
        if (this.queue.length === 0 || this.freeWorkers.length === 0) {
            return;
        }

        const workerId = this.freeWorkers.shift();
        const workerInfo = this.workers[workerId];
        workerInfo.busy = true;

        const { taskData, resolve, reject } = this.queue.shift();
        workerInfo.resolve = resolve;
        workerInfo.reject = reject;

        workerInfo.instance.postMessage(taskData);
    }

    returnWorkerToPool(workerId) {
        const workerInfo = this.workers[workerId];
        workerInfo.busy = false;
        workerInfo.resolve = null;
        workerInfo.reject = null;
        this.freeWorkers.push(workerId);
    }

    terminateWorker(workerId) {
        const workerInfo = this.workers[workerId];
        if (workerInfo.instance) {
            workerInfo.instance.terminate();
        }
        // Remove from current workers list and potentially replace
        this.workers[workerId] = { instance: null, busy: false, resolve: null, reject: null };
        // Optionally, re-create the worker to maintain pool size
        console.log(`Worker ${workerId} terminated. Re-initializing...`);
        const newWorker = new Worker(this.workerPath);
        newWorker.id = workerId;
        newWorker.on('message', (message) => {
            this.workers[newWorker.id].resolve(message.payload);
            this.returnWorkerToPool(newWorker.id);
            this.processQueue();
        });
        newWorker.on('error', (err) => {
            this.workers[newWorker.id].reject(err);
            console.error(`Worker ${newWorker.id} error:`, err);
            this.terminateWorker(newWorker.id);
        });
        newWorker.on('exit', (code) => {
            if (code !== 0) console.error(`Worker ${newWorker.id} exited with code ${code}`);
            this.terminateWorker(newWorker.id);
        });
        this.workers[workerId] = { instance: newWorker, busy: false, resolve: null, reject: null };
        this.freeWorkers.push(workerId); // Add the new worker to the pool
    }

    close() {
        for (const workerInfo of this.workers) {
            if (workerInfo.instance) {
                workerInfo.instance.terminate();
            }
        }
        console.log('Worker Pool closed.');
    }
}

module.exports = WorkerPool;

The generic worker used by the pool runs a CPU-bound function for every message it receives and replies with a status and a payload. The summing loop stands in for any real workload such as image processing or encryption.

// pool-worker.js
const { parentPort } = require('worker_threads');

function performHeavyCalculation(data) {
    // Example heavy calculation: sum of numbers up to 'limit'
    // This could be anything CPU-bound: image processing, encryption, etc.
    let sum = 0;
    for (let i = 0; i <= data.limit; i++) {
        sum += i;
    }
    return sum;
}

if (parentPort) {
    parentPort.on('message', (taskData) => {
        try {
            const result = performHeavyCalculation(taskData);
            parentPort.postMessage({ status: 'success', payload: result });
        } catch (error) {
            parentPort.postMessage({ status: 'error', payload: error.message });
        }
    });
}

Finally, the main script sizes the pool to the number of CPU cores reported by os.cpus(), submits six tasks, and waits for all of them with Promise.allSettled. Each task's promise is mapped to a readable success or failure string.

// main-pool.js
const WorkerPool = require('./workerPool');
const path = require('path');

const numCores = require('os').cpus().length;
const pool = new WorkerPool(path.join(__dirname, 'pool-worker.js'), numCores);

async function runExample() {
    const tasks = [
        { limit: 1_000_000_000 },
        { limit: 500_000_000 },
        { limit: 1_500_000_000 },
        { limit: 750_000_000 },
        { limit: 2_000_000_000 },
        { limit: 250_000_000 }
    ];

    console.log('Main: Submitting tasks to the worker pool...');
    const results = await Promise.allSettled(tasks.map((task, index) =>
        pool.runTask(task)
            .then(res => `Task ${index} completed with result: ${res}`)
            .catch(err => `Task ${index} failed: ${err.message}`)
    ));

    results.forEach(res => console.log(res.value));

    // Demonstrate main thread responsiveness
    console.log('Main: Tasks submitted. Doing other stuff...');
    await new Promise(resolve => setTimeout(resolve, 100)); // Simulate async work
    console.log('Main: Other stuff done.');

    pool.close();
    console.log('Main: Worker pool closed.');
}

runExample();

Treat this pool strictly as a teaching sketch; it has several flaws that matter before anything like it reaches production:

  • The pool's message handler resolves the promise with payload regardless of status, so a task that failed inside the worker still counts as a success. Check status and reject on 'error'.
  • worker.terminate() makes a worker exit with a non-zero code. Because the exit handler calls terminateWorker, which spawns a replacement, calling close() triggers a cycle of new workers instead of shutting the pool down, and an error followed by its exit event can replace the same slot twice and push its ID onto the free list more than once. A real pool needs a "closing" flag and should only replace workers that crashed unexpectedly.
  • A task that was in progress when its worker crashed is rejected via the error path only; an abnormal exit without an error event leaves its promise pending.
  • In the demo, the sums up to two billion exceed Number.MAX_SAFE_INTEGER, so the printed results lose precision. Use BigInt if exact values matter.
  • The "Doing other stuff" log runs after all tasks have been awaited, so, as in the first example, it does not show concurrency with the tasks.

More complete pools also add idle timeouts, dynamic scaling and more careful recovery. Well-maintained libraries such as Piscina implement these details, and they are usually a better choice than a hand-rolled pool.

When worker threads help and when they do not

Good fits

  • CPU-bound tasks: anything that consumes significant CPU time, such as heavy calculations, compression, encryption or image processing.
  • Protecting the event loop: any operation that would otherwise block the main thread for longer than you can tolerate.

Poor fits

  • I/O-bound work: network calls, database queries and file system operations are already non-blocking in Node.js. Wrapping them in a worker adds overhead and no benefit.
  • Tiny tasks: starting a worker and passing messages both cost time. For quick computations the overhead can outweigh the gain, which is another reason to keep long-lived workers in a pool.
  • Shared mutable state: SharedArrayBuffer makes it possible, but coordinating writes with Atomics is notoriously hard to get right. Unless you truly need it and understand the consequences, prefer message passing.

Practices that keep workers healthy

  1. Keep worker scripts small. Load only the logic and dependencies the task needs. Importing your whole application framework into every worker inflates startup time and memory.
  2. Choose the cheapest data path. Structured cloning through postMessage is fine for small messages, large ArrayBuffers should go through the transfer list, and deep or complex object graphs are worth avoiding because cloning them is slow.
  3. Shut workers down. Terminate them, or let them exit, when their work is done. Idle workers still hold memory.
  4. Handle errors on both sides. Listen for error and exit on the main thread, and wrap work inside the worker in try...catch so failures come back as explicit error messages.
  5. Watch resource usage. Too many workers cause context switching and higher memory use, which can make things slower rather than faster. os.cpus().length is a reasonable starting size for a pool; on newer Node.js versions, os.availableParallelism() is the recommended way to get that number.
  6. Do not block the worker's own loop. A worker has an event loop too. If its message handler runs a long synchronous job, further messages queue up behind it. A worker that must juggle many requests should keep each unit of work short or, in rare cases, delegate to its own sub-workers.

Mistakes that commonly trip people up

  • Expecting shared globals. Data given to new Worker() reaches the worker only through workerData (or later messages). Module-level variables in the main thread are not visible inside the worker, because it runs in its own isolate. The only true sharing is through SharedArrayBuffer.
  • Misreading isMainThread. It describes the execution context of the code that is currently running. A single file can use it to act as either the main script or the worker, but separate main and worker files are usually clearer.
  • Assuming debugging works as usual. The Node.js inspector can attach to worker threads, but you start the process with inspector flags such as --inspect-brk and need to switch between thread contexts in the debugger. Editors like VS Code handle much of this, yet it is still less direct than debugging the main thread.
  • Leaving workers running. Forgotten workers accumulate memory and threads over time and can exhaust resources in long-running services.

Key takeaways

  • Workers run JavaScript in parallel in separate isolates within one process; they communicate by message passing and share memory only through SharedArrayBuffer.
  • Use them for CPU-bound work that would otherwise stall the event loop, not for network or disk access that Node.js already handles asynchronously.
  • Give messages an explicit, consistent shape so results, errors and request IDs are unambiguous on both sides.
  • Transfer large ArrayBuffers instead of cloning them, and remember the sender loses access afterwards.
  • Reuse workers through a pool sized to the machine's cores, and make sure shutdown and crash recovery are clearly separated, or use a proven pool library.