You've instrumented your Node.js app with I/O tracking. You're catching slow HTTP requests, slow database connections, slow DNS lookups. Your monitoring dashboard looks comprehensive.
Then a developer refactors a service client from axios to the built-in fetch(). Or switches from dns.lookup callbacks to dns.promises.lookup. The code is cleaner. The tests pass. The deployment goes out.
And your I/O tracking goes blind.
This is the story of two API surfaces that silently bypass traditional Node.js instrumentation — and how we patched them in node-loop-detective v1.5.0.
The Problem: Two Parallel Worlds
Node.js has evolved its networking APIs over the years, creating parallel paths that don't share the same internal plumbing.
fetch() vs http.request
Before Node.js 18, if you wanted to make an HTTP request, you used http.request (or a library built on top of it like axios, got, or node-fetch). Every HTTP client in the npm ecosystem ultimately called http.request or https.request under the hood.
Then Node.js 18 introduced a global fetch() function. It looks like the browser Fetch API. It's convenient. It's modern. And it's backed by undici — a completely separate HTTP client that does NOT use http.request internally.
This means if you monkey-patch http.request to track slow HTTP calls, fetch() calls are completely invisible. They go through undici's own connection pool, its own socket management, its own DNS resolution. Your instrumentation sees nothing.
// This is tracked (goes through http.request)
const axios = require('axios');
await axios.get('https://api.example.com/users');
// This is NOT tracked (goes through undici, bypasses http.request)
const res = await fetch('https://api.example.com/users');
As more codebases adopt fetch() — it's the standard API, it's built-in, it doesn't need a dependency — this blind spot grows.
dns.promises.lookup vs dns.lookup
A similar split exists in the DNS module. The original dns.lookup uses callbacks:
const dns = require('dns');
dns.lookup('example.com', (err, address) => {
// ...
});
Node.js 10.6 introduced dns.promises.lookup, the promise-based equivalent:
const dns = require('dns');
const { address } = await dns.promises.lookup('example.com');
These are separate functions with separate code paths. Patching dns.lookup doesn't intercept dns.promises.lookup. Modern async/await code increasingly uses the promise API, making callback-only instrumentation incomplete.
Why This Matters in Practice
Consider a typical Node.js microservice in 2025:
// auth.js — uses fetch (Node.js 18+)
async function verifyToken(token) {
const res = await fetch(`${AUTH_SERVICE_URL}/verify`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ token }),
});
return res.json();
}
// dns-cache.js — uses dns.promises
const dns = require('dns');
async function resolveService(hostname) {
const { address } = await dns.promises.lookup(hostname);
return address;
}
If the auth service starts responding slowly (800ms instead of 20ms), or DNS resolution starts hanging (2 seconds in a misconfigured container), your I/O tracker reports nothing. The event loop isn't blocked. The CPU profile looks healthy. But every request is paying a multi-second tax.
This is exactly the scenario users reported: "The report says healthy, but my app is slow."
The Fix: Patching Both Worlds
In v1.5.0, node-loop-detective now patches both the old and new API surfaces.
Patching fetch()
The global fetch is a regular function on globalThis. We wrap it the same way we wrap http.request — call the original, measure the time, record if it's slow:
(function patchFetch() {
if (typeof globalThis.fetch !== 'function') return;
const origFetch = globalThis.fetch;
globalThis.fetch = function patchedFetch(input, init) {
const startTime = Date.now();
const callerStack = captureCallerStack();
// Extract URL and method
let target = 'unknown';
let method = 'GET';
if (typeof input === 'string') {
target = input;
} else if (input && typeof input === 'object') {
target = input.url || input.href || String(input);
method = (input.method || 'GET').toUpperCase();
}
if (init && init.method) {
method = init.method.toUpperCase();
}
return origFetch.call(this, input, init).then(
(res) => {
const duration = Date.now() - startTime;
if (duration >= threshold) {
recordSlowOp({
type: 'fetch', method, target,
statusCode: res.status, duration,
timestamp: Date.now(), stack: callerStack,
});
}
return res;
},
(err) => {
const duration = Date.now() - startTime;
if (duration >= threshold) {
recordSlowOp({
type: 'fetch', method, target,
error: err.message, duration,
timestamp: Date.now(), stack: callerStack,
});
}
throw err;
}
);
};
})();
Key details:
-
Graceful detection:
if (typeof globalThis.fetch !== 'function') return— on Node.js 16/17 wherefetchdoesn't exist, the patch is silently skipped. -
Input parsing:
fetch()accepts a string URL, aURLobject, or aRequestobject. We handle all three. -
Promise wrapping:
fetch()returns a Promise, so we chain.then()to measure timing without altering the response. -
URL shortening: We parse the URL to show
host + pathnameinstead of the full URL with query parameters, keeping the output readable. -
Cleanup: The original
fetchis stored and restored when loop-detective disconnects.
Patching dns.promises.lookup
The promise-based DNS API wraps similarly:
if (dns.promises && dns.promises.lookup) {
const origPromiseLookup = dns.promises.lookup;
dns.promises.lookup = function patchedPromiseLookup(hostname, options) {
const startTime = Date.now();
const callerStack = captureCallerStack();
return origPromiseLookup.call(dns.promises, hostname, options).then(
(result) => {
const duration = Date.now() - startTime;
if (duration >= threshold) {
recordSlowOp({
type: 'dns', target: hostname, duration,
timestamp: Date.now(), stack: callerStack,
});
}
return result;
},
(err) => {
const duration = Date.now() - startTime;
if (duration >= threshold) {
recordSlowOp({
type: 'dns', target: hostname, duration,
error: err.message, timestamp: Date.now(), stack: callerStack,
});
}
throw err;
}
);
};
}
This sits alongside the existing dns.lookup callback patch. Both are active simultaneously, covering code that uses either API style.
What the Output Looks Like
With both patches active, the report now catches everything:
🌐 Slow FETCH: 1820ms GET api.example.com/users → 200
at loadUsers (/app/services/user.js:23:18)
at handleRequest (/app/routes/api.js:45:5)
🌐 Slow HTTP: 950ms POST payment-gateway.com/charge → 200
at processPayment (/app/services/payment.js:67:12)
🔍 Slow DNS: 2100ms lookup internal-service.local
at Agent.createConnection (node:_http_agent:321:5)
🔌 Slow TCP: 1520ms db-server:5432
at createConnection (/app/db.js:12:5)
⚠ Slow Async I/O Summary
Total slow ops: 8
🌐 FETCH — 3 slow ops, avg 1400ms, max 1820ms
GET api.example.com/users
3 calls, total 4200ms, avg 1400ms, max 1820ms
🌐 HTTP — 2 slow ops, avg 900ms, max 950ms
POST payment-gateway.com/charge
2 calls, total 1800ms, avg 900ms, max 950ms
🔍 DNS — 2 slow ops, avg 1800ms, max 2100ms
internal-service.local
2 calls, total 3600ms, avg 1800ms, max 2100ms
🔌 TCP — 1 slow ops, avg 1520ms, max 1520ms
db-server:5432
1 calls, total 1520ms, max 1520ms
Notice that FETCH and HTTP are reported as separate types. This is intentional — it tells you which API surface the code is using, which matters when you're deciding how to add timeouts or caching.
The Full Coverage Map
Here's what node-loop-detective now tracks across Node.js versions:
| I/O Type | What's Patched | Node.js Version | Covers |
|---|---|---|---|
| HTTP/HTTPS |
http.request, http.get, https.*
|
All | axios, got, node-fetch, superagent, needle, etc. |
| Fetch | globalThis.fetch |
18+ | Built-in fetch, any code using the Fetch API |
| DNS (callback) | dns.lookup |
All | Internal hostname resolution for http/https |
| DNS (promise) | dns.promises.lookup |
10.6+ | Modern async/await DNS code |
| TCP | net.Socket.connect |
All | MySQL, PostgreSQL, Redis, MongoDB, AMQP, etc. |
On Node.js 16-17, the fetch patch is silently skipped (it doesn't exist). On Node.js < 10.6, the dns.promises patch is skipped. Everything degrades gracefully.
A Note on undici Internals
You might wonder: if fetch() uses undici internally, and undici uses TCP sockets, wouldn't the net.Socket.connect patch catch undici's connections?
It depends. undici maintains a connection pool. If a connection is already established and pooled, a fetch() call reuses it without creating a new socket. The TCP patch only fires on new connections. So:
- First
fetch()to a new host: TCP connect is tracked + fetch timing is tracked - Subsequent
fetch()calls to the same host: only fetch timing is tracked (connection is reused)
This is actually the correct behavior. You want to know "this fetch call took 1.8 seconds" regardless of whether it created a new connection or reused one. The fetch-level timing captures the full request lifecycle.
Upgrading
npm install -g node-loop-detective@1.5.0
# Track everything including fetch() and dns.promises
loop-detective <pid> --io-threshold 200
If your Node.js app uses fetch() or dns.promises, the slow calls that were previously invisible will now appear in the report. No configuration needed — the patches are applied automatically based on what's available in the target process.
Top comments (0)