Skip to content

node_07 - Node Architecture

Three layers stacked together that somehow don't crash more often
V8 (JavaScript engine) at the top , libuv (async I/O library) at the bottom , and a C++ binding layer in between. Your JavaScript calls fs.readFile() which goes through Node bindings to libuv which uses the OS kernel to actually read the file. The result comes back through the same pipeline , landing as a callback on the event loop queue. Every operation you'll ever do in Node follows this path

the three layers

flowchart TD
    subgraph "Node.js Architecture"
        A["JavaScript Layer<br/>(net , fs , http)<br/>Your code , Node's standard library"]
        B["C++ Bindings<br/>(V8 + libuv wrappers)<br/>Translates JS calls to system calls"]
        C["libuv + V8<br/>Async I/O , event loop , thread pool"]
        D["OS Kernel<br/>Actual hardware interaction"]
    end
    A --> B --> C --> D

JavaScript Layer - your application code plus Node's standard library modules (http , fs , path , crypto). These are JavaScript files that call into the C++ bindings when they need OS access. Pure JS modules (like path) never touch C++ at all

C++ Bindings - this is where the V8 JavaScript engine meets native code. When fs.readFile() is called from JS , it invokes a C++ function that calls libuv's asynchronous file operations. The V8 engine provides the function templates that bridge JS to C++

libuv - the async I/O library that handles everything Node can't do synchronously. File system operations , DNS lookups , network I/O , thread pool management. Originally developed for Node but now used by other projects (Luvit , Julia)

libuv - the real engine

libuv is where the magic actually happens. V8 executes JavaScript. libuv makes JavaScript useful by giving it access to the operating system

What libuv provides: - Event loop - the core loop that processes callbacks in phases - File I/O - asynchronous file operations via the thread pool - TCP/UDP sockets - network communication - DNS resolution - getaddrinfo in a non-blocking way - Child processes - spawning and managing sub-processes - Thread pool - default 4 threads for operations that can't be done asynchronously at the OS level (file I/O , DNS , crypto.pbkdf2 , zlib)

// These operations use libuv's thread pool:
// - fs.readFile / fs.writeFile / fs.stat  (file operations)
// - crypto.pbkdf2 / crypto.randomBytes     (CPU-bound crypto)
// - dns.lookup                             (DNS resolution)
// - zlib.gzip / zlib.gunzip                (compression)

// These operations use the OS kernel's async I/O directly:
// - http.createServer / net.createServer   (network sockets)
// - process.nextTick / setImmediate        (event loop phases)

The thread pool (default 4 threads) queues operations that can't be handled by the OS kernel's async interface. File operations on most Linux systems use the thread pool because aio_read is not well-supported. Network operations use epoll/kqueue/IOCP directly - no threads needed

single-threaded JS , multi-threaded everything else

Here's the model that confuses everyone: JavaScript runs on ONE thread. libuv runs on multiple threads. Your JS code is single-threaded and never executes in parallel with other JS code. But the I/O operations your JS code triggers run in parallel on libuv's thread pool

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

// These run in PARALLEL on libuv's thread pool
// But their callbacks run SEQUENTIALLY on the main JS thread

console.time('parallel')

// Two CPU-bound operations that use the thread pool
// On default 4 threads - these execute simultaneously
crypto.pbkdf2('password' , 'salt' , 100000 , 64 , 'sha512' , (err , key) => {
  console.log('Key 1 done')
  console.timeLog('parallel')
})

crypto.pbkdf2('password' , 'salt' , 100000 , 64 , 'sha512' , (err , key) => {
  console.log('Key 2 done')
  console.timeLog('parallel')
})

// Output (approximate - both finish at almost the same time):
// Key 1 done: ~200ms
// Key 2 done: ~200ms
// Both completed in ~200ms total (not 400ms!) because they
// ran in parallel on the thread pool

This is why Node can handle thousands of concurrent connections on a single CPU core. While one request's callback is waiting for a database query (libuv thread pool), the event loop processes other callbacks. No threads needed for the JS layer

process model - one main thread

When you start node app.js , one process starts with:

  • One main thread - runs V8 , executes JavaScript , processes the event loop
  • One event loop - processes callbacks in phases
  • A thread pool - libuv manages 4 threads (configurable via UV_THREADPOOL_SIZE)
  • V8's heap and garbage collector - allocated memory for JavaScript objects
# Visual representation of a running Node process
# $ ps -T -p $(pgrep -f 'node app.js')
# PID  SPID  COMMAND
# 1234 1234  node app.js       <-- main thread (JS execution)
# 1234 1235  node app.js       <-- libuv thread pool worker
# 1234 1236  node app.js       <-- libuv thread pool worker
# 1234 1237  node app.js       <-- libuv thread pool worker
# 1234 1238  node app.js       <-- libuv thread pool worker

To see your own Node process threads:

node -e "setTimeout(() => {}, 10000)" &
ps -T -p $!  # Shows main thread + 4 thread pool workers

why this architecture matters for security

Understanding the process model helps identify attack vectors that would be invisible otherwise:

Event loop starvation - a single synchronous CPU-heavy operation blocks ALL requests because there's only one JS thread. An attacker who finds a slow endpoint can DoS the entire server with a single request

// This blocks the entire event loop for ALL users
app.post('/slow' , (req , res) => {
  // Synchronous hash of a large payload
  // While this runs: ZERO other requests get processed
  const hash = crypto.createHash('sha256').update(req.body).digest('hex')
  res.json({ hash })
})

Thread pool exhaustion - the thread pool has a fixed size (default 4). If all threads are busy with file operations , DNS lookups , or crypto , subsequent operations queue up. Attackers who trigger many concurrent file operations can starve other requests

# Increase thread pool size if profiling shows contention
# Trade-off: more threads = more memory , more context switching
export UV_THREADPOOL_SIZE=8
node app.js

Worker threads escape hatch - for CPU-bound operations you can't avoid (image processing , data transformation) , use worker_threads:

const { Worker } = require('worker_threads')

function runInWorker(file , data) {
  return new Promise((resolve , reject) => {
    const worker = new Worker(file , { workerData: data })
    worker.on('message' , resolve)
    worker.on('error' , reject)
    worker.on('exit' , code => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`))
    })
  })
}

// This doesn't block the event loop
app.post('/heavy' , async (req , res) => {
  const result = await runInWorker('./heavy.js' , req.body)
  res.json(result)
})

Worker threads have their OWN V8 instance , event loop , and thread pool. They don't share memory with the main thread (message passing only). This makes them safe from race conditions but requires serialization overhead for large data transfers

prerequisites

node_06 - The V8 Engine


next -> node_08_event_loop.md