Skip to main content

Command Palette

Search for a command to run...

Deep Dive — Node.js Execution Model, Concurrency, and Performance

Published
•9 min read•View as Markdown

1 — High-level overview: single-threaded JS, multi-threaded runtime

  • JavaScript execution in Node.js runs on a single main thread (V8). That’s the thread people mean when they say “Node is single-threaded.”

  • Node.js runtime is multi-threaded under the hood: it uses libuv (a C library) that provides:

    • The event loop (scheduling async callbacks),

    • A thread pool (default 4 worker threads) for running certain native, blocking operations in background threads.

  • Additionally, Node exposes a Worker Threads API so you can create actual JS threads (each with its own V8 instance) for CPU-bound tasks.

Think: single-threaded JavaScript on top of a multi-threaded engine.


2 — Event loop basics (call stack, queues, microtasks)

Components

  • Call Stack — where synchronous JS runs (function calls pushed/popped).

  • Web/Node APIs — timers, sockets, file system, setTimeout, etc. (these are not part of JS engine — they are provided by the environment/runtime).

  • Callback Queue (Macrotask queue) — callbacks from timers, I/O, setTimeout, setImmediate, etc.

  • Microtask Queue — Promise .then, async/await resumption, process.nextTick.

  • Event Loop — the loop that coordinates moving tasks to the call stack.

Execution order (simplified)

  1. Execute all synchronous code on the call stack.

  2. Process process.nextTick queue.

  3. Process microtasks (Promise .then, await continuations).

  4. Proceed through event loop phases (timers, pending callbacks, poll/IO, check/setImmediate, close callbacks) and at the end of each phase re-run microtasks.

Example: microtask vs macrotask

console.log('Start');

setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));

console.log('End');

Typical output:

Start
End
nextTick
Promise
setTimeout
setImmediate

Key point: microtasks (Promises, process.nextTick) run before macrotasks even if macrotasks are ready.


3 — How async/await and Promises work internally

async functions return Promises. await pauses the async function and yields control back to the event loop. Internally:

  • axios.get(...) returns a Promise immediately.

  • await suspends the function; the remainder is scheduled as a microtask (Promise continuation) after the awaited Promise resolves.

  • While the HTTP call is in flight, the main thread is free to run other JS.

  • Once the network response arrives, libuv/OS notify Node, the Promise resolves, the microtask gets queued and the async function resumes.

Example:

async function fetchUserData() {
  console.log("data calling");
  const data = await axios.get("/users");
  console.log("data", data);
}
fetchUserData();

Flow: console.log → start network I/O (non-blocking) → function suspended → later resume and console.log(data).


4 — libuv and the thread pool

libuv responsibilities

  • Event loop implementation.

  • Cross-platform async I/O abstraction (epoll, kqueue, IOCP).

  • A thread pool for running tasks that cannot be done using non-blocking OS I/O APIs.

Thread pool details

  • Default size: 4 threads (UV_THREADPOOL_SIZE=4).

  • Used by certain native operations that are implemented to run in workers:

    • fs async functions (on some platforms),

    • Some DNS operations,

    • crypto functions (e.g., pbkdf2, scrypt),

    • zlib compression,

    • Other native addons that schedule work on the libuv pool.

  • You can increase the pool size via environment variable:

UV_THREADPOOL_SIZE=16 node app.js
  • There’s an upper practical limit (documentation often recommends ≤128), and increasing it indefinitely has diminishing returns and higher overhead.

When thread pool helps

  • If you call an async library function that is implemented to offload to the libuv pool (e.g., crypto.pbkdf2), the work runs in a background thread and the main thread is not blocked.

When it doesn’t help

  • If you use the synchronous API (e.g., pbkdf2Sync, fs.readFileSync) — that runs on main thread and blocks.

  • If you write CPU-intensive pure JS (e.g., tight loops), libuv cannot magically parallelize it — it runs on the main V8 thread unless you explicitly use Worker Threads.


5 — Worker Threads (the way to run JS in parallel)

  • Introduced to run JavaScript on multiple threads (similar to Web Workers).

  • Each Worker gets its own V8 instance and own event loop.

  • Communication is via message passing (postMessage, on('message')) or SharedArrayBuffer (careful!).

  • Use worker threads for CPU-bound JS work that would otherwise block the event loop.

Example:
main.js

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

const worker = new Worker('./heavyTask.js');

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

heavyTask.js

// heavy JS computation
const { parentPort } = require('worker_threads');
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i;
parentPort.postMessage(sum);

Main thread remains responsive while the worker does heavy computation.


6 — Concurrency for network requests (axios/HTTP) — how many at once?

  • Network I/O is non-blocking and handled by the OS kernel using scalable mechanisms (epoll, kqueue, IOCP). Node registers sockets and waits for events — no thread per socket.

  • You can initiate hundreds or thousands of concurrent outbound HTTP requests. Node will track sockets, and the OS will notify when data arrives.

  • Practical limits:

    • File descriptor limit (ulimit -n on Unix). Default often ~1024; raise it for high concurrency.

    • Network bandwidth, latency, and remote server rate limits.

    • Memory: each socket + pending Promise uses memory (but relatively small per connection).

    • Target server: it may throttle or reject many simultaneous requests.

  • If you need to limit concurrency, use libraries like p-limit, promise-pool, or write batching logic:

import pLimit from 'p-limit';
const limit = pLimit(10);
await Promise.all(urls.map(url => limit(() => axios.get(url))));

7 — Why main thread can still be blocked even with libuv thread pool

  • libuv helps only when you call asynchronous native functions that are implemented to offload to the pool (e.g., crypto.pbkdf2, fs async on some operations).

  • If you call:

    • A synchronous API (fs.readFileSync, pbkdf2Sync) → main thread blocks.

    • Pure JS CPU-heavy code (big loops, recursive Fibonacci) → main thread blocks.

  • Simply having a thread pool doesn’t make your JS automatically parallel. You must use appropriate async/native APIs or spawn Worker Threads.


8 — Detailed execution timeline for the axios example

Given:

async function fetchUserData() {
  console.log("data calling");
  const data = await axios.get("/users");
  console.log("data", data);
}
fetchUserData();

Step-by-step:

  1. fetchUserData() pushed to call stack; console.log("data calling") prints.

  2. axios.get() is called; it creates a Promise and triggers non-blocking network I/O (via Node HTTP client, libuv + OS).

  3. await suspends function; its continuation is scheduled as a microtask once the Promise resolves.

  4. While waiting, event loop runs other tasks (main thread free).

  5. When response arrives, OS signals libuv which queues the Promise resolution.

  6. Promise resolution is executed as a microtask; the async function resumes and console.log("data", data) runs.


9 — Synchronous vs asynchronous APIs (practical examples)

Async (non-blocking, good):

fs.readFile('file.txt', (err, data) => { /* callback */ });
// OR
await fs.promises.readFile('file.txt');

Sync (blocking, avoid on server):

const data = fs.readFileSync('file.txt'); // blocks main thread

Async crypto (uses thread pool):

crypto.pbkdf2('pass', 'salt', 100000, 64, 'sha512', (err, key) => {});

Sync crypto (blocks):

const key = crypto.pbkdf2Sync('pass', 'salt', 100000, 64, 'sha512'); // blocks main thread

10 — Tuning and operational tips

  1. Avoid synchronous APIs in production — they block the event loop, hurting throughput and latency.

  2. Use async/native functions to let libuv do the work when available.

  3. Use Worker Threads for CPU-heavy JS logic.

  4. Tune UV_THREADPOOL_SIZE if you perform many libuv-threaded tasks (ex: lots of pbkdf2, compression). Example:

     UV_THREADPOOL_SIZE=16 node server.js
    

    Be careful — increasing the pool too much increases thread scheduling overhead and memory usage.

  5. Raise file descriptor limits if you need many concurrent sockets:

     ulimit -n 65536
    
  6. Control outbound concurrency when calling external services to avoid overwhelming them or your network:

    • Use concurrency limiters (p-limit, promise-pool).

    • Batch requests.

  7. Measure & observe: CPU, event-loop latency (use perf_hooks’ monitorEventLoopDelay()), memory, FD usage, and external service latencies.

  8. Design for graceful backpressure — use retries with exponential backoff and circuit breakers (e.g., opossum).


11 — Troubleshooting: common pitfalls & how to spot them

  • Symptoms: high latency, CPU pegged, Node appears unresponsive.

    • Check if synchronous functions are used (*Sync() calls).

    • Use top/htop to check CPU usage.

    • Use Node’s --trace-sync-io (when available) or search code for readFileSync, pbkdf2Sync, etc.

    • Check Node’s event loop lag with small probes:

        const { monitorEventLoopDelay } = require('perf_hooks');
        const h = monitorEventLoopDelay({ resolution: 20 });
        h.enable();
        setInterval(() => {
          console.log('mean delay', h.mean / 1e6, 'ms');
        }, 1000);
      
  • If many crypto.pbkdf2 tasks are slow: increase UV_THREADPOOL_SIZE or offload to workers / specialized service.

  • If many outgoing HTTP requests fail: check FD limits, network bandwidth, remote server rate limits.


12 — Advanced internals notes (for interview depth)

  • The OS kernel’s async mechanisms (epoll/kqueue/IOCP) allow thousands of concurrent sockets because they don’t map 1:1 to threads.

  • libuv’s poll phase is where it waits for I/O events and dispatches callbacks.

  • After each event-loop phase, Node executes microtasks (Promises/process.nextTick) to ensure predictable ordering.

  • The libuv thread pool runs native C/C++ callbacks — these threads are managed by libuv and return results via the event loop back on the main thread.

  • Worker threads create separate V8 isolates (independent memory and GC) — communication cost is message passing / serialization; share memory carefully via SharedArrayBuffer only when necessary.


13 — FAQ (quick answers)

Q: Node is single-threaded — so how does it handle many clients?
A: Network I/O is non-blocking and handled by the OS kernel; Node registers sockets and processes events using the event loop — threads are not needed per connection.

Q: How many worker threads are there?
A: libuv thread pool: 4 by default (changeable via UV_THREADPOOL_SIZE). Worker Threads: you spawn as many as you create (subject to system limits).

Q: If I call 1000 axios.get calls, will OS handle them?
A: Yes — the OS can track many sockets. Limits: file descriptor limit, memory, network bandwidth, and the remote server’s capacity.

Q: Why is the main thread blocked even though libuv exists?
A: Because either you used a synchronous API or you’re running CPU-heavy pure JS code on the main thread. libuv only helps when you call async/native APIs that offload work to the pool.


14 — Practical examples you can run

A. Controlled concurrency with p-limit

import pLimit from 'p-limit';
import axios from 'axios';
const limit = pLimit(10);
const urls = [...]; // many urls

const results = await Promise.all(
  urls.map(url => limit(() => axios.get(url)))
);

B. Using Worker Threads for CPU tasks

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

function runTask(path) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path);
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

(async () => {
  const result = await runTask('./heavyTask.js');
  console.log(result);
})();

C. Offloading pbkdf2 to libuv pool

const crypto = require('crypto');
for (let i = 0; i < 20; i++) {
  crypto.pbkdf2('p', 's', 200000, 64, 'sha512', (err, derivedKey) => {
    if (err) throw err;
    console.log('done', i);
  });
}

If you see only 4 run concurrently, increase UV_THREADPOOL_SIZE.


15 — Final checklist / best practices

  • Prefer non-blocking APIs (avoid *Sync()).

  • Use Promise-based flows and async/await for readable concurrency.

  • Limit and batch external requests; avoid overwhelming networks or remote APIs.

  • Use Worker Threads for CPU-heavy JS.

  • If using libuv-threaded tasks heavily (crypto, compression), tune UV_THREADPOOL_SIZE.

  • Monitor: event-loop latency, CPU, memory, FDs, response time.

  • Test under load to discover bottlenecks (profiling beats guessing)