Step 1: Map Workloads to Event Loop Phases
Node.js processes each tick in this order: timers β pending callbacks β idle/prepare β poll β check β close. I/O callbacks resolve in poll. setImmediate fires in check. setTimeout resolves in timers. process.nextTick and Promise.then execute as microtasks after each phase, before the next tick.
Schedule deferred I/O or callback-heavy logic in check or poll. Reserve timers for periodic tasks. Never queue CPU work in poll without offloading.
Step 2: Implement Microtask-Aware Scheduling
Microtasks run to completion before the event loop advances. Unbounded chains block I/O. Use setImmediate to yield control back to the loop when processing large datasets.
// TypeScript: Batching with explicit loop yield
function processLargeArray(items: string[], batchSize: number = 500): Promise<void> {
return new Promise((resolve, reject) => {
let index = 0;
function batch() {
const end = Math.min(index + batchSize, items.length);
for (let i = index; i < end; i++) {
// CPU-bound transform
items[i] = items[i].toUpperCase();
}
index = end;
if (index < items.length) {
setImmediate(batch); // Yield to event loop
} else {
resolve();
}
}
setImmediate(batch);
});
}
Step 3: Offload CPU-Bound Operations with Worker Threads
The worker_threads module runs JavaScript in parallel V8 isolates. Use it for cryptographic operations, image processing, or heavy data transformations.
// TypeScript: Worker thread pool manager
import { Worker, isMainThread, parentPort, WorkerOptions } from 'worker_threads';
import { cpus } from 'os';
const THREAD_COUNT = Math.max(1, cpus().length - 1);
const pool: Worker[] = [];
if (isMainThread) {
for (let i = 0; i < THREAD_COUNT; i++) {
pool.push(new Worker(__filename));
}
export function runTask(data: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
const worker = pool[Math.floor(Math.random() * pool.length)];
worker.once('message', resolve);
worker.once('error', reject);
worker.postMessage(data);
});
}
} else {
parentPort!.on('message', (data: Buffer) => {
const result = Buffer.from(data.toString().toUpperCase()); // Placeholder CPU work
parentPort!.postMessage(result);
});
}
Step 4: Enforce Stream Backpressure
Piping streams without respecting drain events causes memory bloat and event loop saturation. Implement explicit backpressure handling for file/network I/O.
// TypeScript: Backpressure-aware stream processor
import { Readable, Writable, Transform } from 'stream';
const processor = new Transform({
transform(chunk: Buffer, encoding, callback) {
// Simulate async CPU work
setImmediate(() => {
this.push(Buffer.from(chunk.toString().toUpperCase()));
callback();
});
}
});
// Pipe with backpressure awareness
readableStream.pipe(processor).pipe(writableStream);
Step 5: Monitor Event Loop Health
Instrument lag, GC duration, and libuv handle counts. Use perf_hooks and async_hooks for production telemetry.
// TypeScript: Event loop lag monitor
import { performance } from 'perf_hooks';
let lastCheck = performance.now();
setImmediate(() => {
const now = performance.now();
const lag = now - lastCheck - 0; // Approximate loop delay
lastCheck = now;
if (lag > 10) {
console.warn(`Event loop lag: ${lag.toFixed(2)}ms`);
}
});
Architecture Decisions:
- Use single-threaded async for I/O-bound services (APIs, proxies, message consumers).
- Partition CPU work into worker threads to preserve poll phase responsiveness.
- Use streams for data pipelines to enforce backpressure natively.
- Avoid cluster mode unless horizontal scaling or process isolation is required; it complicates state management and increases memory footprint.
Pitfall Guide
-
Blocking the poll phase with synchronous I/O or heavy computation
The poll phase handles I/O callbacks. Running sync operations here stalls all pending network events. Always offload or use setImmediate to defer.
-
process.nextTick starvation
process.nextTick queues execute before the next event loop phase. Recursive or unbounded chains prevent poll, timers, and check from running. Use it only for critical state synchronization, not iteration.
-
Confusing setImmediate with setTimeout(0)
setTimeout(0) schedules in the timers phase. setImmediate schedules in the check phase. Under load, setTimeout can be delayed by timer precision and OS scheduling. setImmediate is more predictable for yielding control.
-
Assuming Promise.all parallelizes CPU work
Promise.all waits for concurrent async operations but does not spawn threads. CPU-bound promises still execute on the main thread sequentially. Use worker_threads or child_process for true parallelism.
-
Ignoring stream backpressure
Piping fast producers to slow consumers fills internal buffers until memory exhaustion. Always check writable.write() return value and listen for drain events.
-
Misconfiguring UV_THREADPOOL_SIZE
Libuv uses a thread pool for DNS resolution, file I/O, and crypto operations. Default size is 4. Under high concurrent file/DNS load, this becomes a bottleneck. Set via UV_THREADPOOL_SIZE environment variable or process.env.
-
Mixing sync and async error handling
Synchronous errors thrown inside async callbacks bypass promise rejection chains. Wrap sync operations in try/catch and forward errors to reject().
Best Practices from Production:
- Explicitly mark CPU-bound functions with type annotations and runtime guards.
- Use
setImmediate for batch processing instead of recursive setTimeout.
- Monitor event loop lag, GC pause time, and libuv active handles in dashboards.
- Isolate worker threads per feature domain to prevent cross-contamination.
- Validate stream backpressure in integration tests with simulated slow consumers.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| I/O-heavy API gateway | Single-threaded async + connection pooling | Maximizes non-blocking I/O, minimal overhead | Low (baseline infrastructure) |
| CPU-bound data processing | Worker Threads pool | Isolates V8 isolates, preserves main loop responsiveness | Medium (memory per worker) |
| Mixed workload with streaming | Stream pipeline + backpressure + selective workers | Prevents buffer overflow, scales with data volume | Low-Medium (stream overhead) |
| Multi-tenant stateful services | Cluster mode + process isolation | Fault isolation, predictable resource allocation | High (process duplication, LB overhead) |
Configuration Template
// event-loop-config.ts
import { Worker } from 'worker_threads';
import { cpus } from 'os';
import { performance } from 'perf_hooks';
export const WORKER_COUNT = Math.max(1, cpus().length - 1);
export const EVENT_LOOP_LAG_THRESHOLD_MS = 10;
export const BATCH_SIZE = 500;
export function createWorkerPool(scriptPath: string): Worker[] {
const pool: Worker[] = [];
for (let i = 0; i < WORKER_COUNT; i++) {
pool.push(new Worker(scriptPath, { workerData: { id: i } }));
}
return pool;
}
export function monitorEventLoop(intervalMs: number = 1000): void {
let last = performance.now();
setInterval(() => {
const now = performance.now();
const lag = now - last - intervalMs;
if (lag > EVENT_LOOP_LAG_THRESHOLD_MS) {
console.error(`[EVENT_LOOP] Lag detected: ${lag.toFixed(2)}ms`);
}
last = now;
}, intervalMs);
}
Quick Start Guide
- Install dependencies:
npm install @types/node
- Create
worker.ts with CPU-bound logic and parentPort message handling
- Initialize worker pool in your entry point using
createWorkerPool(__dirname + '/worker.js')
- Add
monitorEventLoop() to your application bootstrap sequence
- Run load test with
autocannon -c 100 -d 30 http://localhost:3000 and observe lag metrics in console