Node.js November 29, 2024 Aditya Rawas

Is Node.js Single-Threaded or Multi-Threaded? A Complete Guide

Is Node.js Single-Threaded or Multi-Threaded? A Complete Guide

If you’ve ever wondered whether Node.js is single-threaded or multi-threaded, you’re not alone. It’s one of the most common questions for developers diving into Node.js. The answer? Node.js is single-threaded for JavaScript execution, but uses multiple threads under the hood for certain tasks. Let’s unpack what that actually means — and what to do when single-threading isn’t enough.


The Architecture at a Glance

Your JavaScript Code

   V8 Engine (single thread)

   Event Loop

┌──────────────────────────────────────┐
│         libuv Thread Pool            │
│  (file I/O, DNS, crypto, zlib)       │
│  Default: 4 threads (UV_THREADPOOL)  │
└──────────────────────────────────────┘

   OS-level async I/O
   (networking — epoll/kqueue/IOCP)

What Does Single-Threaded Mean in Node.js?

Node.js runs JavaScript on a single thread using the V8 engine. This means your JavaScript code executes one operation at a time — there’s no parallel execution of JS code without Worker Threads.

The Event Loop

The event loop is what makes single-threaded Node.js feel concurrent. Instead of blocking while waiting for I/O, Node.js registers a callback and moves on. When the I/O completes, the callback is added to the event queue and executed on the main thread.

The event loop processes these phases in order on every tick:

timers          → setTimeout / setInterval callbacks
pending I/O     → I/O callbacks deferred from previous tick
idle/prepare    → internal use
poll            → retrieve new I/O events
check           → setImmediate callbacks
close callbacks → e.g., socket.on('close', ...)

This non-blocking model is why a single Node.js process can handle thousands of concurrent HTTP connections — it’s never sitting idle waiting for a database query to finish.


How Multi-Threading Works in Node.js

While your JavaScript is single-threaded, Node.js uses threads internally for expensive operations.

libuv Thread Pool

libuv is a C library that powers Node.js’s async I/O. It maintains a thread pool (default: 4 threads) that handles:

  • File system operations (fs.readFile, fs.writeFile) — see the Node.js fs module guide for practical examples
  • DNS lookups (dns.lookup)
  • Cryptography (crypto.pbkdf2, crypto.scrypt)
  • Compression (zlib)

When you call fs.readFile, Node.js hands the work to a libuv thread, frees the main thread immediately, and calls your callback when the read completes.

You can increase the thread pool size for I/O-heavy workloads:

UV_THREADPOOL_SIZE=16 node server.js

OS-Level Async Networking

For networking (HTTP, TCP, UDP), Node.js doesn’t use the libuv thread pool at all — it uses the operating system’s native async mechanisms:

  • Linux: epoll
  • macOS: kqueue
  • Windows: IOCP

These handle thousands of simultaneous connections with zero extra threads, which is why Node.js excels at network-heavy workloads.


The Event Loop Blocking Problem

The single-threaded model has one critical weakness: CPU-intensive JavaScript blocks the entire event loop.

// This blocks the event loop for ~5 seconds
// No other requests can be handled during this time!
function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

app.get('/fib', (req, res) => {
    const result = fibonacci(45); // Blocks for ~5 seconds
    res.json({ result });
});

While fibonacci(45) is running:

  • No other HTTP requests are processed
  • No timers fire
  • No I/O callbacks execute

This is why Node.js is a poor choice for CPU-intensive work without offloading to threads.


Worker Threads: Parallel JavaScript

Worker Threads (stable since Node.js 12) allow you to run JavaScript in parallel on separate threads. Each worker has its own V8 instance and event loop.

Basic Worker Thread

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

if (isMainThread) {
    // Main thread: spawn a worker
    const worker = new Worker(__filename, {
        workerData: { n: 45 }
    });

    worker.on('message', (result) => {
        console.log('Fibonacci result:', result);
    });

    worker.on('error', (err) => console.error(err));
    worker.on('exit', (code) => console.log('Worker exited with code', code));

} else {
    // Worker thread: compute and send result back
    function fibonacci(n) {
        if (n <= 1) return n;
        return fibonacci(n - 1) + fibonacci(n - 2);
    }

    parentPort.postMessage(fibonacci(workerData.n));
}

Worker Thread Pool Pattern

For repeated CPU tasks, creating a new worker per request is expensive. Use a pool:

const { Worker } = require('worker_threads');

class WorkerPool {
    constructor(workerScript, size = 4) {
        this.workers = Array.from({ length: size }, () => ({
            worker: new Worker(workerScript),
            busy: false,
        }));
    }

    run(data) {
        return new Promise((resolve, reject) => {
            const available = this.workers.find(w => !w.busy);
            if (!available) return reject(new Error('No available workers'));

            available.busy = true;
            available.worker.postMessage(data);
            available.worker.once('message', (result) => {
                available.busy = false;
                resolve(result);
            });
        });
    }
}

Sharing Memory with SharedArrayBuffer

Workers communicate via message passing (copying data). For performance-critical cases, use SharedArrayBuffer to share memory directly — no copying needed:

const { Worker, isMainThread, workerData } = require('worker_threads');

if (isMainThread) {
    const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes
    const sharedArray  = new Int32Array(sharedBuffer);
    sharedArray[0] = 0;

    const worker = new Worker(__filename, { workerData: { sharedBuffer } });

    worker.on('exit', () => {
        console.log('Counter value:', sharedArray[0]); // Modified by worker
    });
} else {
    const sharedArray = new Int32Array(workerData.sharedBuffer);
    Atomics.add(sharedArray, 0, 1); // Thread-safe increment
}

Use Atomics for thread-safe operations on shared memory — raw reads/writes without Atomics can produce race conditions.


The Cluster Module: Multi-Process Scaling

Worker Threads run inside a single Node.js process. The Cluster module takes a different approach — it forks multiple Node.js processes, each with their own memory and V8 instance.

const cluster = require('cluster');
const http    = require('http');
const os      = require('os');

if (cluster.isPrimary) {
    const numCPUs = os.cpus().length;
    console.log(`Primary process ${process.pid} running`);
    console.log(`Forking ${numCPUs} workers...`);

    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }

    cluster.on('exit', (worker, code) => {
        console.log(`Worker ${worker.process.pid} died — restarting`);
        cluster.fork(); // Auto-restart on crash
    });

} else {
    // Each worker runs an HTTP server
    http.createServer((req, res) => {
        res.writeHead(200);
        res.end(`Handled by worker ${process.pid}`);
    }).listen(3000);

    console.log(`Worker ${process.pid} started`);
}

The OS kernel distributes incoming connections across workers automatically.


Worker Threads vs Cluster: When to Use Which

Worker ThreadsCluster
IsolationShared process memorySeparate processes
Best forCPU-intensive JS tasksHTTP server scaling
CommunicationMessage passing / SharedArrayBufferIPC messages
Crash impactCrash kills entire processOnly the worker process dies
Startup costLowerHigher (full process fork)
Memory sharingYes (SharedArrayBuffer)No

Use Worker Threads when you have CPU-heavy computations (image processing, crypto, ML inference, large data transforms) that would block the event loop.

Use Cluster when you want to take full advantage of all CPU cores for HTTP request handling — each worker handles requests independently.


FAQs

Q: Is Node.js truly single-threaded? A: For JavaScript execution, yes. But it uses multiple OS threads via libuv for file I/O, DNS, crypto, and compression — transparently.

Q: What are Worker Threads in Node.js? A: Stable since Node.js 12, Worker Threads allow parallel JavaScript execution for CPU-intensive tasks. Each worker has its own V8 instance and event loop.

Q: When should I use Cluster instead of Worker Threads? A: Use Cluster for HTTP server scaling across CPU cores. Use Worker Threads for CPU-heavy computations within a single server process.

Q: Can Worker Threads share memory? A: Yes, via SharedArrayBuffer. Use Atomics for thread-safe operations on shared memory.

Q: When should I avoid Node.js? A: Node.js isn’t ideal for purely CPU-intensive workloads (video encoding, complex ML training) without Worker Threads. For those, consider Go, Rust, or a dedicated microservice in a CPU-optimized language.


Key Takeaways

  • Node.js runs JavaScript on a single thread — CPU-blocking code halts all other processing.
  • The event loop enables concurrency without threads by deferring I/O callbacks.
  • libuv uses a thread pool for file I/O, DNS, and crypto — transparently behind the scenes.
  • Worker Threads enable parallel JavaScript for CPU-intensive tasks within a single process.
  • Use SharedArrayBuffer + Atomics when Workers need to share large data without copying.
  • The Cluster module forks multiple processes to utilize all CPU cores for HTTP workloads.
  • For I/O-bound apps (APIs, real-time, microservices), Node.js’s single-threaded model is a feature, not a limitation.
  • To understand how modules are loaded in Node.js, see how require() works in CommonJS.
Aditya Rawas

Written by

Aditya Rawas

Full-stack engineer writing deep-dives on JavaScript, TypeScript, React, AWS, Docker, and Kubernetes. Passionate about making complex engineering concepts accessible to developers at every level.