Node.js Diagnostics and Event Loop Blocking
A Node.js service runs all JavaScript on one thread. When that thread is busy — parsing a 50 MB JSON payload, running a catastrophic regular expression, hashing passwords synchronously — every request waits, health checks time out, and the orchestrator restarts a process that was not actually broken. Other slowdowns look similar but have different causes: the libuv thread pool can be saturated by file system, DNS, compression, or crypto work while the event loop is idle, and a memory leak causes growing garbage collection pauses before the process finally crashes.
Fixing the right problem starts with measuring which one you have. Node.js ships the tools to do it: event loop metrics, CPU profiles, diagnostic reports, and heap snapshots.
TL;DR
- Three distinct problems: event loop blocking, thread pool saturation, and memory leaks or GC pressure.
- Measure event loop delay (
monitorEventLoopDelay) and event loop utilization continuously in production. - A CPU profile (
--cpu-prof, the inspector, or flame graphs) shows which JavaScript blocks the loop. - Diagnostic reports capture stacks, libuv handles, and resource usage on demand or on crash.
- Heap snapshots compared over time reveal what keeps memory alive.
- Fix by removing synchronous calls, chunking work, streaming large data, and moving CPU work to worker threads.
Quick Example
Export event loop health as metrics:
Capture evidence from a misbehaving process:
Open .cpuprofile and .heapsnapshot files in Chrome DevTools (Performance and Memory panels).
Core Concepts
Telling the Problems Apart
Event Loop Metrics
- Event loop delay measures how late timers fire — a direct proxy for how long requests wait to start running. Track p99 and max.
- Event loop utilization (ELU) is the fraction of time the loop spends doing work rather than waiting for I/O. ELU near 1 means the process is CPU-bound; scaling out or optimizing code helps, but adding concurrency does not.
The libuv Thread Pool
Some operations run on a small thread pool — four threads by default: most file system calls, dns.lookup, zlib, and several crypto functions. When many run at once, the rest queue, and those operations slow down even though the event loop is free. UV_THREADPOOL_SIZE can be raised (set it before startup), but reducing the work — caching DNS, using asynchronous resolvers, streaming compression at a proxy — is often better.
Diagnostic Tools
Finding a Memory Leak
- Confirm growth: chart heap used and RSS over hours under normal traffic.
- Take a heap snapshot after warm-up, generate load, and take another (and a third).
- In DevTools, compare snapshots and sort by size delta; inspect the retainers of growing object types.
- Common culprits: module-level maps used as caches without eviction, event listeners added per request, timers never cleared, and closures capturing request objects.
- Fix, then verify with the same snapshot comparison.
Heap snapshots pause the process and can use as much memory again as the heap; take them on a replica removed from load balancing.
Fixing Event Loop Blocking
- Replace synchronous APIs in request paths with their asynchronous versions.
- Stream large payloads instead of buffering and parsing them whole. See Node.js Streams and Backpressure.
- Chunk and yield in long loops with
setImmediatebetween batches. See JavaScript Event Loop and Scheduling. - Move CPU-heavy work to
worker_threads(with a pool such as Piscina) or a separate service. - Guard against ReDoS: review regular expressions applied to user input, and bound input sizes.
- Limit payload sizes at the HTTP layer so one request cannot ask for minutes of parsing.
Best Practices
Instrument Before You Need It
Export event loop delay, ELU, heap usage, GC pauses, and active handles as metrics from day one, with alerts on sustained increases.
Make Diagnostics Available in Production
Enable --report-on-signal and reports on fatal errors, and document how to capture profiles and snapshots safely during incidents.
Separate Liveness From Busy
A blocked loop fails liveness probes and gets restarted, destroying the evidence. Capture a report or profile before restarts where possible, and avoid probe timeouts so short that brief stalls cause restarts.
Load Test With Realistic Payloads
Many blocking bugs only appear with production-sized payloads. Include large and pathological inputs in performance tests.
Common Mistakes
Synchronous Hashing in a Request Handler
Unbounded In-Memory Caches
Scaling Out a CPU-Bound Bug
FAQ
What does it mean to block the event loop?
JavaScript code runs for a long time without returning control, so Node.js cannot process other callbacks — new requests, timers, or I/O results — until it finishes. Every concurrent request is delayed.
How do I measure event loop lag in Node.js?
Use perf_hooks.monitorEventLoopDelay() for a histogram of delays and performance.eventLoopUtilization() for busy time, and export both as metrics.
How do I find a memory leak in Node.js?
Take heap snapshots at intervals under load, compare them in Chrome DevTools to find object types that keep growing, and follow their retainer paths to the code that holds them.
What is UV_THREADPOOL_SIZE?
The number of threads in libuv's pool, which handles file system operations, dns.lookup, compression, and some crypto. The default is 4; increasing it helps when those operations queue, but reducing the work is often better.
When should I use worker threads?
For CPU-intensive JavaScript — parsing, image or data processing, heavy computation — that would otherwise block the event loop. For I/O-bound work, the event loop is already efficient.
Related Topics
- Node.js — Runtime fundamentals
- JavaScript Event Loop and Scheduling — Tasks, microtasks, and phases
- Node.js Streams and Backpressure — Handling large data without buffering
- Profiling — Profiling techniques in general
- Garbage Collection — How memory is reclaimed
- Performance Engineering — Systematic performance work
- JIT Deoptimization and Profiling — Tracing V8 optimizations and deopts