Module A-3·33 min read

The math behind event loop lag, microtask queue starvation, and UV_THREADPOOL_SIZE tuning for cryptographic validation at scale.

Module 2 — Event Loop Saturation & Thread Pool Offloading

What this module covers: The event loop from Module 0 was presented as a single loop. That was a simplification. The event loop has six distinct phases, each with its own queue, processed in strict order. When you have thousands of incoming transactions per second, microtask queues competing with I/O callbacks, timers firing at sub-millisecond intervals, and cryptographic operations queuing for a thread pool that defaults to 4 threads — the phase structure determines everything about your latency profile. This module covers the event loop precisely, shows you how to measure saturation, and gives you the tools to offload work correctly.


The Six Phases of the Event Loop

The Node.js event loop is not a simple loop over a single queue. It is a phased loop — in each iteration, it processes up to six distinct phases, and the order is non-negotiable.

text

Between every phase and between every callback within a phase, Node.js drains two special queues:

  1. process.nextTick queue — highest priority micro-queue
  2. Promise microtask queue — second priority

These micro-queues drain completely before the next phase or callback runs. This ordering has critical implications for high-throughput ingestion.

Phase 1: Timers

Executes callbacks scheduled by setTimeout and setInterval whose thresholds have elapsed. The "threshold" is a minimum — the callback won't run before the specified time, but it may run later if other phases are busy.

Production implication: If your ingestion pipeline uses setTimeout(fn, 100) as a flush trigger for a batch write, that timer will not fire at exactly 100ms if the poll phase is busy processing incoming transaction data. At 50K events/sec, the poll phase can be continuously occupied, delaying timers by hundreds of milliseconds.

Phase 2: Pending Callbacks

Executes I/O callbacks that were deferred to the next loop iteration by the OS (typically some TCP errors). Rarely populated in normal operation.

Phase 3: Idle, Prepare

Internal to libuv. Not accessible from JavaScript.

Phase 4: Poll

The most important phase for I/O-intensive applications.

The poll phase does two things:

  1. Calculates how long to block waiting for new I/O events (0ms if there are pending timers or setImmediate callbacks, otherwise up to some calculated maximum)
  2. Processes I/O callbacks in the poll queue

For a blockchain indexer receiving a continuous stream of transactions: incoming socket data triggers epoll notifications → libuv adds callbacks to the poll queue → the poll phase drains the poll queue. As long as data keeps arriving, the poll phase stays busy.

The blocking calculation is critical: if the poll queue keeps filling faster than it drains, the event loop never moves past the poll phase. Timers don't fire. setImmediate callbacks don't run. This is event loop starvation.

Phase 5: Check

Executes setImmediate callbacks. This phase runs after the poll phase, not before. If you want code to run "soon" but after current I/O has been processed, setImmediate is correct. If you want code to run "immediately" (before any I/O callbacks), process.nextTick is correct.

javascript

Phase 6: Close Callbacks

Executes close event callbacks (socket.on('close', ...)). Cleanup only.


Microtask Queues: The Invisible Priority System

Before every phase transition and between every callback, Node.js drains microtask queues in priority order:

Priority 1: process.nextTick queue Priority 2: Promise resolution queue (queueMicrotask, .then, await)

Both queues drain completely before the event loop moves forward. This has a dangerous implication: if you continuously add items to these queues, the event loop never advances.

The Microtask Starvation Pattern

javascript
javascript

Promise Chain Depth and Starvation

javascript

Event Loop Utilization (ELU): The Critical Production Metric

Event Loop Utilization (ELU) measures the ratio of time the event loop spends actively executing JavaScript vs idling in the poll phase waiting for I/O.

text
javascript
javascript

Alert thresholds for a UPI payment gateway:

ELUStatusAction
< 0.70HealthyNo action
0.70–0.85WatchProfile for CPU hotspots
0.85–0.95WarningOffload CPU work, scale
> 0.95CriticalImmediate intervention, add instances

Event Loop Lag: Measuring Actual Delay

ELU tells you how busy the loop is. Event loop lag tells you how delayed callbacks are relative to when they were scheduled.

javascript

A setImmediate callback should run in < 0.1ms under no load. Under saturation:

  • At ELU 0.80: lag typically 2–10ms
  • At ELU 0.90: lag typically 10–50ms
  • At ELU 0.95: lag typically 50–200ms

For a blockchain indexer, 50ms event loop lag means WebSocket subscribers receive transaction confirmations 50ms late — and that 50ms compounds across every downstream consumer.


The libuv Thread Pool

libuv maintains a thread pool for operations that cannot be made truly non-blocking at the OS level. This pool is separate from the event loop thread and runs in parallel.

What Uses the Thread Pool

javascript

Critical for blockchain applications: crypto.pbkdf2, crypto.scrypt, and crypto.createSign all use the thread pool. Every transaction signature verification is a thread pool job.

The Default 4-Thread Limit

The thread pool defaults to 4 threads. If you submit more than 4 concurrent blocking operations, they queue. This queue has no limit.

javascript

Sizing UV_THREADPOOL_SIZE for Cryptographic Workloads

The correct formula depends on your workload:

UV_THREADPOOL_SIZE = min(128, number_of_cpu_cores × 2)

For a blockchain indexer on an 8-core server performing signature verification on every transaction:

bash

Why not just set it to 128? More threads = more memory (each thread has its own stack), more context switching, and eventually diminishing returns as CPU cores are shared. The formula above keeps thread count proportional to available parallelism.

javascript

worker_threads vs Thread Pool: Choosing the Right Tool

libuv's thread pool handles C-level blocking operations. worker_threads is for JavaScript-level CPU-bound work.

libuv Thread Poolworker_threads
LanguageC / native codeJavaScript
ControlIndirect (through APIs)Direct (you write the worker)
CommunicationCallback when doneMessage passing / SharedArrayBuffer
Use casecrypto, fs, dnsHeavy JS computation
Default limit4 (configurable)No system limit (you manage the pool)

When to Use worker_threads

Use worker_threads when your hot path has CPU-bound work in JavaScript — complex data transformation, in-process schema validation at scale, or Merkle tree construction in JS.

javascript

SharedArrayBuffer: Zero-Copy Communication

For high-frequency worker communication, message passing involves serialization (structuredClone or JSON). SharedArrayBuffer eliminates this:

javascript

Preventing Event Loop Blocking During JSON Parsing

Large JSON payloads are a common blocking source. JSON.parse is synchronous and runs entirely on the main thread.

The Problem: Multi-MB Blockchain Payloads

A full Ethereum block can be 1–10MB of JSON. JSON.parse on a 5MB payload takes 20–80ms on modern hardware. On the event loop thread, this means 20–80ms where nothing else runs.

javascript

Solution 1: Offload to a Worker Thread

javascript

Solution 2: Streaming JSON Parser

For very large payloads, a streaming parser processes JSON incrementally, yielding to the event loop between chunks:

javascript

The Phase Interaction: setImmediate vs process.nextTick vs Promise

Understanding the exact execution order is critical for scheduling work correctly in a high-throughput pipeline.

javascript

For a blockchain indexer, this means:

javascript

The Production Incident: Thread Pool Exhaustion During UPI Festival Spike

Context: A UPI payment gateway processing ~1,500 transactions/second normally. During a major festival sale, traffic spikes to 12,000 transactions/second.

The architecture:

javascript

What happened at 12,000 req/sec:

Default UV_THREADPOOL_SIZE = 4. Each crypto.pbkdf2 call takes ~8ms. At 12,000 req/sec, the thread pool needed to complete 12,000 operations/sec. Maximum throughput: 4 threads × (1000ms / 8ms) = 500 operations/sec.

The queue of pending thread pool operations grew to over 20,000 within 2 seconds. Each incoming payment request waited in the thread pool queue. The event loop remained responsive (ELU: 0.12 — barely busy), but every request timed out because verifyHmac took 30–120 seconds to resolve — not because of CPU, but because of thread pool starvation.

The fix:

javascript

After the fix: At 12,000 req/sec, createHmac verifications ran at 0.1ms each on the main thread. ELU increased to 0.42 (acceptable). No thread pool queue buildup. Response time: 4–12ms end-to-end.


Summary

ConceptKey Takeaway
Six event loop phasesTimers → Pending → Poll → Check → Close. Strict execution order.
Poll phaseWhere I/O callbacks run. Continuous data = loop stays in poll phase.
Microtask queuesprocess.nextTick then Promises, before every phase. Infinite recursion starves the loop.
ELURatio of active to idle event loop time. Alert at > 0.85.
Event loop lagActual delay between scheduling and execution. Measurable with setImmediate probe.
Thread poolDefault 4 threads for crypto, fs, dns. Size with UV_THREADPOOL_SIZE = cores × 2.
Thread pool exhaustionRequests don't fail — they just wait forever. ELU stays low while requests queue.
worker_threadsJavaScript CPU work off the main thread. Use for heavy JS computation, not I/O.
SharedArrayBufferZero-copy communication between threads. Use for high-frequency data transfer.
Streaming JSONParse multi-MB payloads with clarinet or in a worker thread. Never JSON.parse large payloads on the main thread.
setImmediate vs nextTicksetImmediate yields to the event loop (check phase). nextTick is immediate (before next phase).

The event loop determines when your code runs. The next layer down determines what happens when data actually arrives at the kernel. Module 3 goes into the OS-level I/O multiplexing that the event loop is built on — epoll, kqueue, and the precise journey a transaction takes from the NIC to your JavaScript callback.

Next: Module 3 — Kernel-Level I/O Multiplexing: epoll, kqueue, IOCP →


Knowledge Check

Which mechanism should be used to schedule code to run in the check phase of the current event loop iteration?


What is the most likely cause of a Node.js application experiencing severe latency when processing thousands of concurrent requests that require cryptographic signature verification using crypto.pbkdf2, despite having low CPU utilization and an Event Loop Utilization (ELU) of 0.15?


What does an Event Loop Utilization (ELU) of 0.95 indicate about the Node.js process?

Test your knowledge with more question sets

Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.