Node.js
async operations
promises
monitoring
JavaScript

Monitoring pending async operations in Node.js promised environment

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

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:

javascript
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:

javascript
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

javascript
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:

javascript
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:

bash
# 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:

javascript
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:

javascript
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);

Method 5: wtfnode (Debug Tool)

The wtfnode package shows what is keeping Node.js from exiting:

bash
npm install wtfnode
javascript
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:

javascript
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

Course illustration
Course illustration

All Rights Reserved.