Node.js 77 🟢 Profiling Event Loop Lag, CPU Spikes, and Async Hooks
Node.js performance problems fall into two categories: the event loop is blocked by synchronous work, or the event loop is busy but not blocked—it is processing too many callbacks, or the callbacks themselves are slow. The distinction matters because the tools are different. perf_hooks.monitorEventLoopDelay measures the first: how long a scheduled timer has to wait before it executes, which is the direct symptom of a blocked event loop . CPU profiling measures the second: which function is consuming the most processor time when the loop is running . Async hooks are the diagnostic layer underneath both: they expose the lifecycle of every asynchronous resource, which is how you trace a slow request through the chain of callbacks it spawned.
The event loop delay histogram is the first measurement in any performance investigation because it answers a binary question: is the main thread blocked? A P99 delay of 500ms during an endpoint that should take 50ms is conclusive evidence of a synchronous bottleneck . CPU profiling then identifies the function responsible. Async hooks are the third step, used when the problem is not a single slow function but a pattern of resource creation—a leak, an unbounded listener list, or a promise chain that never resolves.
Key point: monitorEventLoopDelay measures how late timers run, which directly indicates event loop blocking; CPU profiling with the V8 profiler identifies the function consuming processor time; async hooks expose the lifecycle of every asynchronous resource for diagnosing patterns that neither measurement can explain.
Why event loop delay is the primary measurement
The blocking problem. Node.js runs JavaScript on a single thread. When a synchronous function executes—a large JSON.stringify, a CPU-bound loop, a synchronous file write—no other callback can run until it finishes . The event loop is blocked, and every pending timer, I/O callback, and HTTP request waits. monitorEventLoopDelay measures this wait by scheduling a timer at a fixed resolution and recording how late it fires. A timer scheduled for 20ms from now that fires 500ms late means the loop was blocked for 480ms .
The resolution choice. The resolution option sets the sampling interval in milliseconds. A lower resolution (10-20ms) captures short blocking events but produces more samples and more overhead. A higher resolution (50-100ms) is lighter but may miss brief spikes . For production monitoring, 20ms is a reasonable starting point. The histogram reports min, max, mean, stddev, and percentiles via percentile(p) or the percentiles map .
The two sampling modes. By default, the histogram is updated by a timer at the configured resolution. When samplePerIteration: true is set, samples are taken once per event loop iteration using uv_prepare_t and uv_check_t hooks. The two modes produce significantly different results and should not be compared directly . The iteration mode is more precise for detecting per-iteration blocking, but it does not keep the loop alive when the application is idle.
The reset pattern. A continuous histogram accumulates samples indefinitely. For monitoring, the pattern is to log the current percentiles and call h.reset() to start a fresh window. This produces a rolling measurement that reflects recent behavior rather than the entire process lifetime . A P99 that spikes during a specific operation and returns to baseline is more useful than a P99 that has averaged across hours.
a. Measuring event loop delay with perf_hooks
The histogram is created with monitorEventLoopDelay, enabled with .enable(), and read with the percentile methods. The values are in nanoseconds, so converting to milliseconds requires dividing by 1e6.
const { monitorEventLoopDelay } = require('node:perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
// After some workload:
const p99Ms = h.percentile(99) / 1e6;
const maxMs = h.max / 1e6;
console.log(`P99 delay: ${p99Ms.toFixed(1)}ms, max: ${maxMs.toFixed(1)}ms`);
h.reset(); // start a fresh window
A monitoring interval that logs the P99 every 30 seconds and resets the histogram provides a continuous view of blocking behavior without unbounded memory growth. A P99 above 100ms is a warning; above 500ms is a problem. The eventLoopUtilization function is a complementary measurement: it reports the fraction of time the event loop spent active versus idle, which helps distinguish a blocked loop (high delay, high utilization) from a loop that is simply idle (low delay, low utilization) .
b. CPU profiling with the V8 profiler
When the event loop delay confirms blocking, the next question is which function is responsible. The V8 CPU profiler records a sample of the call stack at regular intervals and produces a profile that Chrome DevTools can visualize as a flame graph . The widest bar in the flame graph is the function consuming the most CPU time.
The simplest method is the --cpu-prof flag:
node --cpu-prof --cpu-prof-dir=./profiles app.js
This writes a .cpuprofile file to the specified directory. The file can be loaded in Chrome DevTools under the Performance or Memory tab, or analyzed programmatically. The --inspect flag combined with DevTools’ Profiler tab provides the same data interactively .
For programmatic profiling of a specific operation, the inspector module exposes Profiler.start and Profiler.stop:
const { Session } = require('node:inspector');
const session = new Session();
session.connect();
session.post('Profiler.enable', () => {
session.post('Profiler.start', () => {
// Code to profile
runHeavyOperation();
session.post('Profiler.stop', (err, { profile }) => {
require('fs').writeFileSync('profile.cpuprofile', JSON.stringify(profile));
});
});
});
The flame graph’s “Bottom Up” view sorts by self-time, which immediately shows which function is consuming CPU in its own body rather than in the functions it calls. A function with high self-time is the optimization target .
c. Async hooks and the context tracking distinction
The async_hooks module provides callbacks for the lifecycle of every asynchronous resource: init when a resource is created, before before its callback runs, after after it completes, and destroy when it is freed . The type parameter identifies the resource kind (Timeout, TCPWRAP, PROMISE, FSREQCALLBACK), and triggerAsyncId identifies the resource that caused this one to be created .
The practical use of async hooks is not writing callbacks for every resource. It is context tracking: associating data with a request and retrieving it in any callback spawned by that request. The stable API for this is AsyncLocalStorage, which is built on async hooks and is the recommended approach for request-scoped state such as trace IDs, user IDs, and transaction contexts . The AsyncLocalStorage.run(store, callback) method makes the store available in every async operation within the callback’s scope, and getStore() retrieves it.
const { AsyncLocalStorage } = require('node:async_hooks');
const storage = new AsyncLocalStorage();
app.use((req, res, next) => {
storage.run({ requestId: req.id }, () => next());
});
// In any async callback:
const { requestId } = storage.getStore();
console.log(`[${requestId}] processing`);
The performance caveat is important. Async hooks themselves have overhead—the destroy hook in particular enables Promise tracking via garbage collection, which adds cost . AsyncLocalStorage has been optimized significantly, and Node.js 24+ uses AsyncContextFrame for a faster implementation . The recommendation is to use AsyncLocalStorage rather than raw async_hooks unless you specifically need the lifecycle callbacks, and to avoid enabling hooks globally in performance-sensitive code paths.
Complete Example Session
The following session builds an endpoint with a deliberate blocking operation, measures the event loop delay, profiles the blocking function, and uses AsyncLocalStorage to correlate logs across the request.
// ============================================
// PART 1: THE APPLICATION WITH A BLOCKING ENDPOINT
// ============================================
const express = require('express');
const { monitorEventLoopDelay } = require('node:perf_hooks');
const { AsyncLocalStorage } = require('node:async_hooks');
const app = express();
const storage = new AsyncLocalStorage();
const delayHistogram = monitorEventLoopDelay({ resolution: 20 });
delayHistogram.enable();
// ============================================
// PART 2: REQUEST CONTEXT MIDDLEWARE
// ============================================
app.use((req, res, next) => {
storage.run({ requestId: Math.random().toString(36).slice(2) }, next);
});
// ============================================
// PART 3: THE BLOCKING ENDPOINT
// ============================================
app.get('/block', (req, res) => {
const { requestId } = storage.getStore();
console.log(`[${requestId}] starting blocking work`);
// Deliberate synchronous CPU work
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
}
console.log(`[${requestId}] blocking work done: ${sum}`);
res.json({ requestId, sum });
});
// ============================================
// PART 4: THE DELAY MONITOR
// ============================================
setInterval(() => {
const p99 = (delayHistogram.percentile(99) / 1e6).toFixed(1);
const max = (delayHistogram.max / 1e6).toFixed(1);
console.log(`[monitor] P99 delay: ${p99}ms, max: ${max}ms`);
delayHistogram.reset();
}, 5000);
app.listen(3000, () => console.log('Server on :3000'));
# ============================================
# PART 5: RUN AND TRIGGER
# ============================================
node --cpu-prof --cpu-prof-dir=./profiles app.js
# In another terminal:
curl http://localhost:3000/block
# Monitor output shows:
# [monitor] P99 delay: 2.1ms, max: 15.0ms (before)
# [monitor] P99 delay: 1200.0ms, max: 1250.0ms (during /block)
The session demonstrates three measurements working together: the monitor interval shows the event loop delay spike during the blocking request, the --cpu-prof flag captures the profile that identifies the loop as the hot function, and the AsyncLocalStorage correlates the log lines for the specific request even though the blocking work has no natural async boundary.
Quick Reference
Event Loop Delay
| Option | Default | Meaning |
|---|---|---|
resolution | 10ms | Sampling interval |
samplePerIteration | false | Sample per loop iteration |
h.percentile(p) | — | Delay at percentile p (ns) |
h.reset() | — | Clear accumulated samples |
CPU Profiling
| Flag/Method | Output |
|---|---|
--cpu-prof | .cpuprofile file |
--inspect | DevTools Profiler |
inspector.Session | Programmatic profile |
Async Hooks Callbacks
| Callback | When |
|---|---|
init | Resource created |
before | Before callback |
after | After callback |
destroy | Resource freed |
Context Tracking
| API | Status | Use |
|---|---|---|
async_hooks | Experimental | Lifecycle debugging |
AsyncLocalStorage | Stable | Request context |
Best Practices
✅ Do This:
monitorEventLoopDelay({ resolution: 20 }); // ✅ block detection
node --cpu-prof app.js // ✅ profile blocking
storage.run({ requestId }, () => next()); // ✅ context
h.reset() after logging // ✅ rolling window
❌ Don’t Do This:
// enable async hooks globally for logging // ❌ overhead
// profile without load // ❌ misleading
// compare samplePerIteration with resolution results // ❌ different modes
// leave histogram running without reset // ❌ stale P99
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| P99 always equals resolution | Sampling overhead included | Expected; subtract resolution |
| Profile shows no hot function | Load too light | Profile under realistic load |
getStore() returns undefined | Outside run() scope | Ensure middleware wraps request |
| Async hooks slow process | destroy enables GC tracking | Use AsyncLocalStorage instead |
Real-World Examples
1. Delay monitor
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => { console.log(h.percentile(99) / 1e6); h.reset(); }, 5000);
2. CPU profile on demand
const { Session } = require('node:inspector');
const session = new Session();
session.connect();
session.post('Profiler.enable', () => session.post('Profiler.start', runWork));
3. Request context
storage.run({ requestId: req.id }, () => next());
4. Retrieve context
const { requestId } = storage.getStore();
5. Event loop utilization
const { eventLoopUtilization } = require('node:perf_hooks');
const elu = eventLoopUtilization();
6. Profile directory
node --cpu-prof --cpu-prof-dir=./profiles app.js
7. Async hook init
createHook({ init(id, type) { console.log(type, id); } }).enable();
8. Reset histogram
h.reset();
9. Detect blocking during request
const start = h.percentile(99);
// ... operation
const delta = h.percentile(99) - start;
10. AsyncLocalStorage middleware
app.use((req, res, next) => storage.run({}, next));
Visual
Event Loop Delay Measurement
┌──────────────────────────────────────────────┐
│ Timer scheduled for 20ms │
│ │ │
│ ▼ │
│ Event loop is idle → fires at 20ms │
│ Delay: 0ms │
│ │
│ Timer scheduled for 20ms │
│ │ │
│ ▼ │
│ Synchronous 500ms task runs │
│ │ │
│ ▼ │
│ Timer fires at 520ms │
│ Delay: 500ms │
└──────────────────────────────────────────────┘
Profiling Workflow
┌──────────────────────────────────────────────┐
│ 1. monitorEventLoopDelay confirms blocking │
│ │ │
│ ▼ │
│ 2. CPU profile identifies hot function │
│ │ │
│ ▼ │
│ 3. Fix: batch, offload, or optimize │
│ │ │
│ ▼ │
│ 4. Re-measure to verify improvement │
└──────────────────────────────────────────────┘
AsyncLocalStorage Flow
┌──────────────────────────────────────────────┐
│ Request arrives │
│ │ │
│ ▼ │
│ storage.run({ requestId }, next) │
│ │ │
│ ▼ │
│ Handler runs │
│ │ │
│ ▼ │
│ await database.query() │
│ │ │
│ ▼ │
│ Callback runs │
│ storage.getStore() → { requestId } │
│ (context preserved across async boundary) │
└──────────────────────────────────────────────┘
The Three Measurements
┌──────────────────────────────────────────────┐
│ EVENT LOOP DELAY │
│ "Is the loop blocked?" │
│ P99 > 100ms → blocked │
│ │
│ CPU PROFILE │
│ "What is blocking it?" │
│ Flame graph → hot function │
│ │
│ ASYNC HOOKS / ALS │
│ "How do I correlate the work?" │
│ Request ID across async boundaries │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Event loop delay | monitorEventLoopDelay |
| Unit | Nanoseconds |
| Blocking threshold | P99 > 100ms |
| CPU profiling | --cpu-prof or --inspect |
| Profile format | .cpuprofile |
| Async context | AsyncLocalStorage |
| Raw hooks | async_hooks (experimental) |
| Request correlation | storage.run(store, next) |
Key takeaways:
monitorEventLoopDelaymeasures blocking directly. The delay is how late a timer fires; a P99 of 500ms means the loop was blocked for 480ms. Useresolution: 20and reset the histogram after logging .- CPU profiling identifies the function responsible.
--cpu-profwrites a.cpuprofilefile that Chrome DevTools renders as a flame graph. The widest bar in the Bottom Up view is the optimization target . - Async hooks expose resource lifecycles, but
AsyncLocalStorageis the practical API. UseAsyncLocalStorage.run()to attach request context andgetStore()to retrieve it in any callback . - The two measurements answer different questions. Event loop delay says whether the loop is blocked. CPU profiling says what is blocking it. Neither alone is sufficient .
- Async hooks have overhead. The
destroyhook enables GC tracking, which adds cost. UseAsyncLocalStoragefor context tracking and avoid enabling raw hooks in hot paths .
Remember: Event loop lag is not a performance metric—it is a symptom. A high P99 delay during a specific operation means synchronous code is blocking the thread, and the CPU profile will show which function. Async hooks are the correlation layer: they let you attach a request ID to the context and retrieve it in every callback, which is how you trace a slow request through a chain of asynchronous operations. The workflow is always the same: measure delay, profile CPU, correlate with context. The first tells you that there is a problem, the second tells you where, and the third tells you how the problem is connected to the request that caused it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!