Is Node.js Really Slow Because It's Single-Threaded?

A few months ago, our team decided to rewrite a Python Telegram webhook server in Kotlin. The team lead’s reasoning was simple: “Kotlin is faster than Python.”

There was no concrete benchmark or performance measurement data. It was just a decision based on the general assumption that compiled languages are faster than interpreted ones. But after completing the migration, there was no noticeable performance difference.

It was actually an expected result. In small-scale webhook servers, bottlenecks mostly occur in network I/O. The time spent waiting for external API responses or database query results is much longer than the time the CPU spends performing complex calculations.

This experience made me curious about Node.js. I often heard in the developer community that “Node.js is slow because it’s single-threaded,” but is that really true? There must be reasons why large-scale services like Netflix, PayPal, and LinkedIn adopted Node.js, right?

Driven by curiosity, I decided to dive deep into how Node.js works internally.

Discovering Worker Threads

// Multi-threading example using worker_threads module
const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  // Main thread
  const worker = new Worker(__filename);
  
  worker.on('message', (result) => {
    console.log('Calculation result:', result);
  });
  
  worker.postMessage({ num: 1000000 });
} else {
  // Worker thread
  parentPort.on('message', (data) => {
    const result = calculatePrimes(data.num);
    parentPort.postMessage(result);
  });
}

function calculatePrimes(max) {
  // CPU-intensive task
  const primes = [];
  for (let i = 2; i <= max; i++) {
    if (isPrime(i)) primes.push(i);
  }
  return primes.length;
}

The first thing I discovered was that Node.js is not completely single-threaded. Starting from Node.js 10.5.0, a feature called Worker Threads was added, allowing CPU-intensive tasks to be processed in separate threads.

This is particularly useful for CPU-bound tasks like image processing, large data parsing, and encryption. The main thread doesn’t get blocked and can continue processing other requests.

Event Loop: Scheduling, Not Context Switching

Initially, I thought Node.js performed OS-level context switching when handling multiple tasks. But it actually uses a much more efficient approach.

// Example showing how the event loop works
console.log('1: Start');

setTimeout(() => {
  console.log('2: setTimeout callback');
}, 0);

Promise.resolve().then(() => {
  console.log('3: Promise callback');
});

process.nextTick(() => {
  console.log('4: nextTick callback');
});

console.log('5: End');

// Output order:
// 1: Start
// 5: End
// 4: nextTick callback
// 3: Promise callback
// 2: setTimeout callback

The event loop is a mechanism that efficiently schedules tasks in a single thread. Each task is processed with the following priority:

  1. Synchronous code execution: Complete currently running code
  2. process.nextTick queue: Highest priority asynchronous tasks
  3. Microtask queue: Promise callbacks, etc.
  4. Timer phase: setTimeout, setInterval callbacks
  5. I/O callbacks: File system, network operation completion callbacks
  6. setImmediate: Execute immediately after I/O events
  7. close callbacks: When sockets or handles are closed

The key point is that all this happens in a single thread. Since the OS thread scheduler doesn’t intervene, there’s no context switching overhead. This is why Node.js can efficiently handle high concurrency.

libuv and the Hidden Thread Pool

A more interesting discovery was that Node.js internally uses multi-threading.

const crypto = require('crypto');
const fs = require('fs');

// File I/O - uses libuv's thread pool
console.time('file');
for (let i = 0; i < 4; i++) {
  fs.readFile(__filename, () => {
    console.timeEnd('file');
  });
}

// Cryptography - uses libuv's thread pool
console.time('crypto');
for (let i = 0; i < 4; i++) {
  crypto.pbkdf2('password', 'salt', 100000, 512, 'sha512', () => {
    console.timeEnd('crypto');
  });
}

// Network I/O - uses OS async interfaces (no thread pool)
const https = require('https');
console.time('https');
for (let i = 0; i < 4; i++) {
  https.get('https://www.google.com', (res) => {
    res.on('data', () => {});
    res.on('end', () => {
      console.timeEnd('https');
    });
  });
}

libuv is a C library responsible for Node.js’s asynchronous I/O, operating a thread pool with 4 worker threads by default. The following operations are processed in this thread pool:

  • File system operations (fs module)
  • DNS lookups (dns.lookup)
  • Some cryptographic operations (crypto module)
  • Compression operations (zlib module)
// Thread pool size can be adjusted with environment variables (max 1024)
process.env.UV_THREADPOOL_SIZE = 8;

It was impressive that network I/O doesn’t use the thread pool but directly uses the OS’s asynchronous interfaces (epoll, kqueue, IOCP, etc.). This allows web servers to efficiently handle thousands of concurrent connections.

Utilizing Multi-core with Cluster Module

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`Master process ${process.pid} running`);
  
  // Create workers equal to CPU count
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died`);
    cluster.fork(); // Create new worker when one dies
  });
} else {
  // Workers share the port
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end(`Handled by worker ${process.pid}\n`);
  }).listen(8000);
  
  console.log(`Worker ${process.pid} started`);
}

Another way to overcome single-thread limitations is the cluster module. This allows creating multiple Node.js processes and having each process share the same port.

Using process managers like PM2 makes clustering even easier:

# Create processes equal to CPU core count
pm2 start app.js -i max

Performance Differences by Task Type

// I/O bound tasks - Node.js strength
async function fetchMultipleAPIs() {
  const urls = [
    'https://api1.example.com',
    'https://api2.example.com',
    'https://api3.example.com'
  ];
  
  // Process in parallel - very efficient
  const results = await Promise.all(
    urls.map(url => fetch(url).then(r => r.json()))
  );
  
  return results;
}

// CPU bound tasks - utilizing Worker Threads
const { Worker } = require('worker_threads');

function runCPUIntensiveTask(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./cpu-intensive-task.js');
    
    worker.postMessage(data);
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

I learned that Node.js performance is heavily dependent on the nature of the tasks.

Strengths in I/O Bound Tasks

Node.js shows excellent performance in I/O-centric tasks like:

  • Web API servers
  • Real-time chat applications
  • Data streaming
  • Microservice architectures

Thanks to asynchronous I/O and event-driven architecture, it can handle thousands of concurrent connections with minimal memory usage.

Handling CPU Bound Tasks

For CPU-intensive tasks:

  • Use Worker Threads for parallel processing
  • Utilize multi-core with clustering
  • Use C++ addons or WebAssembly when needed
  • Separate really heavy computations into separate services

From Misconception to Understanding

The statement “Node.js is slow because it’s single-threaded” was a half-truth. While JavaScript execution happens in a single thread, the Node.js platform itself utilizes multi-threading when necessary. And in many web applications, the bottleneck is in I/O, not CPU.

There were reasons why companies like Netflix, PayPal, and LinkedIn chose Node.js. When used appropriately in the right situations, Node.js is a sufficiently powerful and efficient platform.

Next time, I’d like to perform benchmarks in various scenarios. It would be interesting to verify with actual data beyond theory.