Node.js diagnostics_channel: Observing Your Runtime Without Touching Application Code
Your API is slow, but you cannot explain why. Your APM shows request time, but you need to break it into DNS resolution, TCP connect, TLS handshake, and downstream call durations. The diagnostics_channel module gives you this data with zero application code changes and zero overhead when nobody is listening.
Your Node.js service is slow in production. Not crash-the-server slow, not out-of-memory slow. Just slow enough that you are getting paged, and slow enough that the barely-mobile client is timing out.
You fire up your APM dashboard. It shows you that request latency has doubled, but it cannot explain why. The APM sees the HTTP request go in and the HTTP response come out, but it does not tell you that the delay is happening inside the DNS resolver, or inside the TLS handshake with a downstream service, or inside the connection pool of your Postgres driver.
You could add manual instrumentation. Wrap every fetch() call with a timer. Patch dns.lookup() with a wrapper. Add a @timed() decorator to every database call. That works, but it is fragile, it adds code to hot paths, and it changes timing characteristics the moment you add a log statement.
There is a better way. It ships with every Node.js runtime, it costs nothing when nobody is listening, and it lets you observe internal events you did not even know existed. It is the diagnostics_channel module, and most Node.js developers have never heard of it.
What diagnostics_channel Actually Does
diagnostics_channel is a publish-subscribe mechanism built into the Node.js runtime. Internal Node.js subsystems (the HTTP client, the DNS resolver, the net module, the event loop diagnostics) publish structured events to named channels. Your application code subscribes to the channels it cares about and receives the data without the publishing subsystem knowing you are listening.
The API is trivial:
import diagnostics_channel from 'node:diagnostics_channel';
const channel = diagnostics_channel.channel('my-custom-event');
// Subscribe
channel.subscribe((message, name) => {
console.log(`Channel ${name}:`, message);
});
// Publish
channel.publish({ hello: 'world' });
The real power is not in custom events, though. It is in the channels that Node.js publishes internally. Every internal diagnostics_channel event follows a start/end pattern. When an HTTP client request starts, a start message is published with the request metadata. When it completes, an end message is published with the result. You subscribe to both, correlate them by a shared identifier, and measure the delta.
No wrappers. No monkey-patching. No APM vendor lock-in.
The Channels You Will Use Every Day
Node.js publishes diagnostics channels across several subsystems. Here are the ones that matter for production observability:
HTTP Client (http.client.request)
This is the single most useful channel in production. It fires for every HTTP request your application makes via http.request(), https.request(), or Node’s native fetch(). The start message contains the URL, method, and headers. The end message contains the status code and timing.
DNS Resolution (dns.*)
The dns module publishes lookup and resolve events. When a DNS lookup blocks your request, these channels tell you exactly how long the resolution took, what resolver was used, and whether it hit the cache.
Net Connections (net.*)
The net module publishes connect and close events for TCP connections, including TLS sockets. This is where you measure the time spent in the TCP handshake versus the TLS handshake.
UDP Datagrams (udp.*)
Less commonly needed, but available when you are working with custom protocols.
Event Loop Lag (diagnostics:*)
The event loop monitoring event is published via the diagnostics namespace. You can track how long the event loop is blocked between ticks.
Full Channel List
Node documents all internal channels in the diagnostics_channel documentation. As of Node 22, the complete list includes http.client.request.start, http.client.request.end, dns.lookup.start, dns.lookup.end, dns.resolve.start, dns.resolve.end, net.connect.start, net.connect.end, net.server.listen.start, net.server.listen.end, and several more.
The channel naming convention is namespace.thing.stage, so you can subscribe to all stages of a namespace with a wildcard pattern:
// Subscribe to all DNS channels
const dnsChannel = diagnostics_channel.channel('dns.lookup.start');
There is no actual wildcard API, but you can iterate and subscribe to each channel individually, or use a helper that discovers channels at runtime (covered later).
Building a Lightweight HTTP Client Observer
Let me show you the code you actually need. This module intercepts every outgoing HTTP request your application makes and logs the breakdown without touching a single fetch() call.
// http-observer.ts
import diagnostics_channel from 'node:diagnostics_channel';
import { performance } from 'node:perf_hooks';
interface HttpStartMessage {
request: {
method: string;
path: string;
hostname: string;
port?: number;
headers?: Record<string, string>;
};
}
interface HttpEndMessage {
request: {
method: string;
path: string;
hostname: string;
port?: number;
};
response: {
statusCode: number;
statusMessage?: string;
headers?: Record<string, string>;
};
}
type RequestKey = string;
const inFlight = new Map<RequestKey, number>();
function requestKey(msg: HttpStartMessage | HttpEndMessage): RequestKey {
return `${msg.request.method}:${msg.request.hostname}:${msg.request.path}`;
}
function subscribeHttpClient() {
const startChannel = diagnostics_channel.channel('http.client.request.start');
const endChannel = diagnostics_channel.channel('http.client.request.end');
if (startChannel.hasSubscribers) {
return; // already subscribed
}
startChannel.subscribe((msg: unknown) => {
const data = msg as HttpStartMessage;
const key = requestKey(data);
inFlight.set(key, performance.now());
});
endChannel.subscribe((msg: unknown) => {
const data = msg as HttpEndMessage;
const key = requestKey(data);
const startTime = inFlight.get(key);
if (startTime === undefined) return;
const duration = performance.now() - startTime;
inFlight.delete(key);
console.log(
`[http] ${data.request.method} ${data.request.hostname}${data.request.path} ` +
`-> ${data.response.statusCode} (${duration.toFixed(1)}ms)`
);
});
}
subscribeHttpClient();
Import this module once at the top of your application entry point:
// server.ts
import './http-observer'; // side-effect import activates the subscriptions
import { createServer } from 'node:http';
// ... rest of your app
Now every fetch() call, every axios GET, every http.request() produces a line like this:
[http] GET api.example.com/users/123 -> 200 (342.1ms)
You get the breakdown without wrapping a single library function. If your downstream API starts taking 2 seconds instead of 200ms, you see it immediately.
DNS Resolution: The Hidden Slow Path
DNS is the most commonly overlooked cause of latency spikes in Node.js services. Your application calls fetch('https://api.example.com/data'), and Node needs to resolve api.example.com before it can even open a TCP socket. If the DNS resolver takes 500ms (and it does, especially in Kubernetes with busy DNS caches or misconfigured resolv.conf), the caller sees a 500ms delay that looks like “the API is slow” in the APM dashboard.
The dns.lookup channel reveals this immediately:
// dns-observer.ts
import diagnostics_channel from 'node:diagnostics_channel';
import { performance } from 'node:perf_hooks';
interface DnsStartMessage {
hostname: string;
family?: number;
hints?: number;
verbatim?: boolean;
}
interface DnsEndMessage {
hostname: string;
addresses?: Array<{ address: string; family: number }>;
error?: {
code: string;
message: string;
};
}
const pendingLookups = new Map<string, number>();
const startChannel = diagnostics_channel.channel('dns.lookup.start');
const endChannel = diagnostics_channel.channel('dns.lookup.end');
startChannel.subscribe((msg: unknown) => {
const data = msg as DnsStartMessage;
pendingLookups.set(data.hostname, performance.now());
});
endChannel.subscribe((msg: unknown) => {
const data = msg as DnsEndMessage;
const startTime = pendingLookups.get(data.hostname);
if (startTime === undefined) return;
const duration = performance.now() - startTime;
pendingLookups.delete(data.hostname);
if (data.error) {
console.error(`[dns] ${data.hostname} FAILED (${duration.toFixed(1)}ms): ${data.error.message}`);
return;
}
if (duration > 100) {
console.warn(`[dns] SLOW: ${data.hostname} resolved in ${duration.toFixed(1)}ms`);
}
});
With this, you catch DNS slowdowns immediately. When your incident response starts with “DNS took 800ms,” you are debugging the right layer from the first minute, not chasing a red herring in the application code.
TLS Handshake Timing
If your service communicates with downstreams over HTTPS (and it should), the TLS handshake adds latency that varies wildly by region, cipher suite negotiation, and session resumption success.
// tls-observer.ts
import diagnostics_channel from 'node:diagnostics_channel';
import { performance } from 'node:perf_hooks';
interface NetConnectStart {
host: string;
port: number;
localPort?: number;
}
interface NetConnectEnd {
host: string;
port: number;
error?: { code: string; message: string };
}
const pendingConnects = new Map<string, number>();
const tlsHosts = new Set<string>();
function connectKey(msg: NetConnectStart | NetConnectEnd): string {
return `${msg.host}:${msg.port}`;
}
// Track TCP connections
const tcpStart = diagnostics_channel.channel('net.connect.start');
const tcpEnd = diagnostics_channel.channel('net.connect.end');
tcpStart.subscribe((msg: unknown) => {
const data = msg as NetConnectStart;
const key = connectKey(data);
pendingConnects.set(key, performance.now());
});
tcpEnd.subscribe((msg: unknown) => {
const data = msg as NetConnectEnd;
const key = connectKey(data);
const startTime = pendingConnects.get(key);
if (startTime === undefined) return;
const duration = performance.now() - startTime;
pendingConnects.delete(key);
if (data.error) {
console.error(`[tcp] ${data.host}:${data.port} FAILED (${duration.toFixed(1)}ms)`);
} else if (duration > 50) {
console.warn(`[tcp] ${data.host}:${data.port} connected in ${duration.toFixed(1)}ms`);
}
});
When the TCP connect time exceeds the TLS handshake, the bottleneck is the network or the load balancer, not the encryption. When the TLS handshake dwarfs the TCP connect, the issue is cipher negotiation or certificate chain depth. The diagnostics channel data lets you distinguish these cases without packet captures.
Correlating the Full Request Lifecycle
The examples above work independently, but the real power comes from correlating events across channels. A single outgoing HTTP request triggers a DNS lookup, a TCP connect, a TLS handshake, and then the request-response cycle. Each of these fires separate diagnostics events with different identifiers.
The HTTP client channel includes the hostname, and the DNS and net channels share that hostname. You can stitch them together by hostname:
interface RequestTiming {
dns: number[];
tcpConnect: number[];
tlsHandshake: number[];
httpRequest: number[];
}
const timingStore = new Map<string, RequestTiming>();
function recordTiming(hostname: string, stage: keyof RequestTiming, duration: number) {
let entry = timingStore.get(hostname);
if (!entry) {
entry = { dns: [], tcpConnect: [], tlsHandshake: [], httpRequest: [] };
timingStore.set(hostname, entry);
}
entry[stage].push(duration);
// Keep only the last 100 samples
if (entry[stage].length > 100) entry[stage].shift();
}
function reportSlowdowns() {
for (const [hostname, timings] of timingStore) {
const avg = (arr: number[]) =>
arr.length === 0 ? 0 : arr.reduce((a, b) => a + b, 0) / arr.length;
const dnsAvg = avg(timings.dns);
const tcpAvg = avg(timings.tcpConnect);
const httpAvg = avg(timings.httpRequest);
if (dnsAvg > 50 || tcpAvg > 50 || httpAvg > 500) {
console.warn(
`[slowdown] ${hostname}: DNS=${dnsAvg.toFixed(0)}ms ` +
`TCP=${tcpAvg.toFixed(0)}ms HTTP=${httpAvg.toFixed(0)}ms`
);
}
}
}
// Report every 60 seconds
setInterval(reportSlowdowns, 60_000);
Now you have a dashboard-free, zero-dependency latency breakdown for every host your application talks to. When you get paged about slow responses, you check the logs and immediately know whether the bottleneck is DNS, the network, or the downstream service itself.
Where diagnostics_channel Falls Short
I have been selling this API hard, so let me be honest about its limits.
The internal channels are stable but not comprehensively documented. The messages are typed in Node’s TypeScript definitions but the interfaces are not exported, so you have to define your own types (as I did above) or extract them from Node’s source. The shape of messages can change between minor versions. I have seen this happen: a Node upgrade changed a field name in the HTTP client channel message, and our observer silently broke.
The channel names and message contracts are considered internal implementation details by the Node.js project. They are not covered by semantic versioning guarantees. In practice, the core channels (http.client.request, dns.lookup, net.connect) have been stable for years, but you should pin your Node version in production and test your observers after upgrades.
You also cannot observe every internal event through diagnostics channels. The event loop monitoring is available, but the garbage collector does not publish diagnostics events (use performance.mark() and the GC hooks for that). The module system does not publish load events (use the ESM loader hooks instead). Diagnostics channels are a complement to other observability tools, not a replacement.
When To Use This Over APM or OpenTelemetry
Diagnostics channels are not an OpenTelemetry replacement. OpenTelemetry gives you a standardized, cross-language tracing model with context propagation, sampling, and backend exporters. Diagnostics channels give you raw events with zero overhead when unsubscribed.
The right use case is targeted debugging during an incident. You enable the DNS observer, restart your service (or use a feature flag to subscribe at runtime), and get the data you need. No SDK installation, no collector configuration, no dependency on an APM vendor.
Use diagnostics channels when:
- You need to quickly understand why a specific subsystem is slow
- You cannot add dependencies (air-gapped environments, security-constrained deployments)
- You want to build a lightweight, purpose-built observer for a single deployment
- You are debugging a production issue and need data now, not after a 45-minute SDK configuration cycle
Use OpenTelemetry when:
- You need distributed tracing across services
- You need standardized trace context propagation
- You are building a centralized observability platform that spans multiple languages and runtimes
- You need sampling, tail-based sampling, or sophisticated trace analysis
They are not mutually exclusive. I run both in production. OpenTelemetry for the big picture. Diagnostics channel subscriptions for the sharp-end debugging that happens at 3 a.m. when the APM dashboard shows a spike but cannot explain why.
Production Checklist
If you add diagnostics channel observers to your production service, follow these rules:
-
Never log on every event in production. The HTTP client channel fires for every request your service makes. If you log each one, you double your log volume for no benefit. Aggregate, sample, or only report anomalies.
-
Guard against subscription leaks. If you subscribe to a channel in a hot-reloaded module (ESM loader, dynamic import), unsubscribe when the module is disposed. Otherwise you accumulate duplicate subscribers on every reload and your observer runs N times per event.
-
Test after Node upgrades. The channel message shapes are not SemVer-guaranteed. Your CI pipeline should run the observer tests after every Node minor version bump.
Putting It All Together
Here is the complete bootstrap module you can drop into any Node.js application today. It adds less than 100 lines to your codebase and gives you DNS, TCP, and HTTP latency breakdowns with no external dependencies:
// bootstrap-observability.ts
import diagnostics_channel from 'node:diagnostics_channel';
import { performance } from 'node:perf_hooks';
const slowThresholds = {
dns: 100, // ms
tcp: 50, // ms
httpClient: 500 // ms
};
const pending = new Map<string, { stage: string; time: number }[]>();
function start(stage: string, key: string) {
let list = pending.get(key);
if (!list) {
list = [];
pending.set(key, list);
}
list.push({ stage, time: performance.now() });
}
function end(stage: string, key: string, meta?: Record<string, unknown>) {
const list = pending.get(key);
if (!list) return;
const idx = list.findIndex(e => e.stage === stage);
if (idx === -1) return;
const entry = list.splice(idx, 1)[0];
const duration = performance.now() - entry.time;
const threshold = (slowThresholds as Record<string, number>)[stage];
if (threshold !== undefined && duration > threshold) {
console.warn(`[perf] ${stage} ${key} took ${duration.toFixed(1)}ms`, meta ?? '');
}
}
// HTTP client
const httpStart = diagnostics_channel.channel('http.client.request.start');
const httpEnd = diagnostics_channel.channel('http.client.request.end');
httpStart.subscribe((msg: any) => start('httpClient', msg.request.hostname));
httpEnd.subscribe((msg: any) => end('httpClient', msg.request.hostname, { status: msg.response.statusCode }));
// DNS
const dnsStart = diagnostics_channel.channel('dns.lookup.start');
const dnsEnd = diagnostics_channel.channel('dns.lookup.end');
dnsStart.subscribe((msg: any) => start('dns', msg.hostname));
dnsEnd.subscribe((msg: any) => end('dns', msg.hostname, msg.error ? { error: msg.error.message } : undefined));
// TCP connect
const tcpStart = diagnostics_channel.channel('net.connect.start');
const tcpEnd = diagnostics_channel.channel('net.connect.end');
tcpStart.subscribe((msg: any) => start('tcp', `${msg.host}:${msg.port}`));
tcpEnd.subscribe((msg: any) => end('tcp', `${msg.host}:${msg.port}`));
Import it at the top of your entry point:
import './bootstrap-observability';
That is it. You now have production observability into your service’s internal network behavior. No SDK. No config file. No vendor.
The Real Takeaway
The diagnostics_channel module is one of those Node.js features that sits in plain sight but almost nobody uses. It solves a real problem: when your service is slow, you need to know why at the system level, not just at the application level. DNS resolution time, TCP connect latency, and TLS handshake cost are invisible to application-level profiling, but they cause a disproportionate share of production incidents.
Start with the HTTP client observer. It is the one that catches the most bugs per line of code. Add DNS and TCP observers after your next latency incident, not before. You will learn more about your infrastructure in one incident with these tools than you will in a month of dashboard staring.
The best part: when you are done debugging, you can remove the import and the overhead goes to zero. No cleanup, no leftover instrumentation, no tech debt.
A note from Yojji
Building observability into a production Node.js service is the kind of unglamorous infrastructure work that separates a service you trust from a service you hope works. The patterns in this post (observing internal runtime events, correlating latency across network layers, building targeted debugging tools with zero application code changes) rely on deep familiarity with the Node.js runtime and production operations. Yojji is an international custom software development company, founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript stack (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle delivery from architecture design through production performance tuning. If your team needs Node.js services that are instrumented properly from day one, Yojji builds and operates the observability layer for you.