Introduction
In Node.js applications, unresolved promises and pending async operations can cause memory leaks, unresponsive servers, and silent failures. Monitoring these operations helps identify resource leaks, performance bottlenecks, and unhandled rejections. Node.js provides built-in tools and patterns for tracking async activity.
Why Monitor Async Operations?
Resource Leaks: Identifying resource leaks caused by unhandled rejections or unresolved promises that hold references to database connections, file handles, or network sockets.
Performance Bottlenecks: Spotting operations that do not resolve in expected time frames, blocking the event loop or the execution of other async operations.
Complexity in Chains: Managing chains of promises and ensuring proper error handling for all possible outcomes.
Method 1: async_hooks (Built-in)
Node.js async_hooks module tracks the lifecycle of all async resources:
1const async_hooks = require('async_hooks');
2
3const activeOps = new Map();
4
5const hook = async_hooks.createHook({
6 init(asyncId, type, triggerAsyncId) {
7 activeOps.set(asyncId, {
8 type,
9 triggerAsyncId,
10 startTime: Date.now(),
11 stack: new Error().stack
12 });
13 },
14 destroy(asyncId) {
15 activeOps.delete(asyncId);
16 },
17 promiseResolve(asyncId) {
18 activeOps.delete(asyncId);
19 }
20});
21
22hook.enable();
23
24// Check pending operations
25setInterval(() => {
26 console.log(`Active async operations: ${activeOps.size}`);
27 for (const [id, info] of activeOps) {
28 const age = Date.now() - info.startTime;
29 if (age > 30000) { // older than 30 seconds
30 console.warn(`Stale operation [${id}]: ${info.type}, age: ${age}ms`);
31 }
32 }
33}, 10000);
Method 2: Promise Wrapper with Timeout
Wrap promises to detect ones that never resolve:
1function trackPromise(promise, label, timeoutMs = 30000) {
2 let resolved = false;
3
4 const timer = setTimeout(() => {
5 if (!resolved) {
6 console.warn(`Promise "${label}" still pending after ${timeoutMs}ms`);
7 }
8 }, timeoutMs);
9
10 return promise.finally(() => {
11 resolved = true;
12 clearTimeout(timer);
13 });
14}
15
16// Usage
17const dbQuery = trackPromise(
18 db.query('SELECT * FROM users'),
19 'user-query',
20 5000
21);
Generic Promise Tracker
1class PromiseTracker {
2 constructor() {
3 this.pending = new Map();
4 this.idCounter = 0;
5 }
6
7 track(promise, label = 'unnamed') {
8 const id = ++this.idCounter;
9 const entry = { label, startTime: Date.now() };
10 this.pending.set(id, entry);
11
12 return promise.finally(() => {
13 this.pending.delete(id);
14 });
15 }
16
17 getStatus() {
18 const entries = [];
19 for (const [id, entry] of this.pending) {
20 entries.push({
21 id,
22 label: entry.label,
23 elapsed: Date.now() - entry.startTime
24 });
25 }
26 return { count: this.pending.size, entries };
27 }
28}
29
30const tracker = new PromiseTracker();
31
32// Track operations
33tracker.track(fetch('https://api.example.com/data'), 'api-call');
34tracker.track(fs.promises.readFile('large.csv'), 'file-read');
35
36// Monitor
37setInterval(() => {
38 const status = tracker.getStatus();
39 if (status.count > 0) {
40 console.log(`${status.count} pending operations:`, status.entries);
41 }
42}, 5000);
Method 3: Unhandled Rejection Detection
Catch promises that reject without a handler:
1// Global unhandled rejection handler
2process.on('unhandledRejection', (reason, promise) => {
3 console.error('Unhandled Rejection:', reason);
4 // Log to monitoring service
5 // metrics.increment('unhandled_rejection');
6});
7
8// Track promises that eventually get handled
9process.on('rejectionHandled', (promise) => {
10 console.log('Late rejection handler attached');
11});
In Node.js 15+, unhandled rejections terminate the process by default. Configure with:
# Options: throw (default), warn, none
node --unhandled-rejections=warn app.js
Method 4: Event Loop Monitoring
Detect when the event loop is blocked by long-running sync operations:
1function monitorEventLoop(thresholdMs = 100) {
2 let lastCheck = Date.now();
3
4 setInterval(() => {
5 const now = Date.now();
6 const delay = now - lastCheck - 1000; // expected interval is 1000ms
7 lastCheck = now;
8
9 if (delay > thresholdMs) {
10 console.warn(`Event loop blocked for ${delay}ms`);
11 }
12 }, 1000).unref(); // .unref() prevents this timer from keeping process alive
13}
14
15monitorEventLoop(50);
Or use the built-in perf_hooks:
1const { monitorEventLoopDelay } = require('perf_hooks');
2
3const h = monitorEventLoopDelay({ resolution: 20 });
4h.enable();
5
6setInterval(() => {
7 console.log({
8 min: h.min / 1e6, // ms
9 max: h.max / 1e6,
10 mean: h.mean / 1e6,
11 p99: h.percentile(99) / 1e6
12 });
13 h.reset();
14}, 5000);
The wtfnode package shows what is keeping Node.js from exiting:
1const wtf = require('wtfnode');
2
3// At any point, dump active handles
4wtf.dump();
5// Output:
6// [WTF Node?] open handles:
7// - Timers:
8// - (10000 ~ 10 s) <anonymous> @ /app/server.js:45
9// - TCP sockets:
10// - 127.0.0.1:5432 -> 127.0.0.1:54321
Health Check Endpoint
Expose async operation status via HTTP:
1const express = require('express');
2const app = express();
3
4app.get('/health/async', (req, res) => {
5 const status = tracker.getStatus();
6 const healthy = status.entries.every(e => e.elapsed < 60000);
7
8 res.status(healthy ? 200 : 503).json({
9 healthy,
10 pendingOperations: status.count,
11 operations: status.entries
12 });
13});
Common Pitfalls
async_hooks performance: async_hooks adds overhead to every async operation. Use it for debugging and development, not production monitoring at scale.
Missing finally/catch: Forgetting .catch() on a promise creates an unhandled rejection. Use Promise.allSettled() to handle mixed success/failure batches.
Timer leaks: setInterval and setTimeout keep the Node.js process alive. Call .unref() on monitoring timers so they do not prevent graceful shutdown.
Connection pool exhaustion: Unresolved database queries hold connection pool slots. Set query timeouts to prevent pool starvation.
Graceful shutdown: On SIGTERM, wait for pending operations to complete with a timeout: Promise.race([pendingWork, timeout(5000)]).
Summary
Use async_hooks to track the lifecycle of all async resources in development
Wrap promises with timeout tracking to detect operations that never resolve
Handle unhandledRejection globally to catch missed .catch() calls
Monitor event loop delay with perf_hooks.monitorEventLoopDelay()
Use wtfnode to debug what keeps a Node.js process from exiting