We spent three releases building async I/O tracking into node-loop-detective. HTTP request timing. DNS lookup monitoring. TCP connection tracking. Global fetch() support. dns.promises.lookup coverage. It's the feature that fills the blind spot every other Node.js profiler has.
Then we added a flag to turn it all off.
loop-detective 12345 --no-io
This isn't a contradiction. It's a design principle: diagnostic tools that attach to production processes must give operators full control over what gets injected. Here's why.
What --no-io Does
When you pass --no-io, loop-detective skips the entire _startAsyncIOTracking() phase. No monkey-patching of http.request. No wrapping of dns.lookup. No touching net.Socket.connect. No fetch() interception. None of it.
What still works:
- Event loop lag detection (the lightweight
setIntervalprobe) - CPU profiling (V8's built-in sampling profiler via CDP)
- All analysis: heavy functions, call stacks, blocking pattern detection
-
--save-profilefor flame graph export
What's disabled:
- Slow HTTP/HTTPS request tracking
- Slow
fetch()tracking - Slow DNS lookup tracking
- Slow TCP connection tracking
- The "Slow Async I/O Summary" section in the report
Why Someone Would Want This
Reason 1: The Monkey-Patching Risk
I/O tracking works by monkey-patching core Node.js modules inside the target process. We replace http.request with a wrapper that calls the original, measures the time, and records slow operations. Same for dns.lookup, net.Socket.connect, and globalThis.fetch.
This is inherently more invasive than the other diagnostic methods:
| Method | How it works | Invasiveness |
|---|---|---|
| Event loop lag |
setInterval with unref()
|
Minimal — one timer |
| CPU profiling | V8's built-in sampler via CDP | Minimal — native V8 feature |
| I/O tracking | Monkey-patches 6+ core functions | Moderate — modifies module behavior |
For most applications, the monkey-patches are safe. We store originals, we restore them on cleanup, we don't alter the return values or error behavior. But "most" isn't "all."
Some environments have strict policies about runtime code modification. Financial systems, healthcare platforms, and security-critical services may have compliance requirements that prohibit monkey-patching production code — even temporarily, even from a diagnostic tool.
Reason 2: The CPU-Only Investigation
Sometimes you already know the problem is CPU-bound. A developer pushed a commit with an O(n²) algorithm. A regex is backtracking. A JSON.parse is choking on a 50MB payload.
In these cases, I/O tracking is noise. The report shows "No slow I/O operations detected" (because there aren't any), but the I/O patches are still active, adding a tiny overhead to every network call. With --no-io, you get a cleaner report focused on what matters:
# CPU-only investigation
loop-detective 12345 --no-io -d 30
# Output focuses entirely on:
# - Event loop lag events
# - CPU heavy functions
# - Call stacks
# - Blocking patterns (cpu-hog, json-heavy, regex, gc, sync-io, crypto)
Reason 3: Reducing Attack Surface
When you attach to a production process, every piece of injected code is a potential risk. The I/O patches interact with the application's network stack — the most security-sensitive part of most services.
A conservative operator might want to start with the least invasive option:
# Step 1: CPU profile only — zero monkey-patching
loop-detective 12345 --no-io -d 10
# Step 2: If CPU looks fine, enable I/O tracking
loop-detective 12345 -d 30 --io-threshold 500
This graduated approach lets you get initial diagnostics with minimal risk, then opt into deeper instrumentation only if needed.
Reason 4: Isolating Variables
When debugging a complex performance issue, you want to change one thing at a time. If you're seeing unexpected behavior and you're not sure whether it's caused by the I/O patches or by the application itself, --no-io lets you eliminate the patches as a variable.
The Implementation
The implementation is deliberately simple. In detective.js:
async _singleRun() {
try {
await this._startLagDetection();
if (!this.config.noIO) {
await this._startAsyncIOTracking();
}
const profile = await this._captureProfile(this.config.duration);
// ...
} finally {
await this.stop();
}
}
One if statement. That's it. The _startAsyncIOTracking method — with its 200+ lines of monkey-patching code — is simply never called. No patches are injected. No cleanup is needed. The target process is never touched beyond the lag detector and the CPU profiler.
The same guard exists in _watchMode():
async _watchMode() {
await this._startLagDetection();
if (!this.config.noIO) {
await this._startAsyncIOTracking();
}
// ... profiling cycles
}
In the CLI, it's a boolean flag:
const boolMap = {
'--no-io': 'no-io',
// ...
};
const config = {
noIO: values['no-io'],
// ...
};
In the programmatic API:
const detective = new Detective({
pid: 12345,
duration: 10000,
noIO: true,
});
The Design Philosophy
Every feature in a diagnostic tool should be opt-out-able. This is different from application features, where you want sensible defaults that "just work." Diagnostic tools operate in a different trust model:
You're modifying someone else's process. The target application didn't ask to be profiled. The operator is making a judgment call about acceptable risk.
Production is not staging. What's safe in development might not be acceptable in production. Giving operators granular control respects this reality.
Less can be more. A focused CPU-only report is sometimes more useful than a comprehensive report with I/O data that isn't relevant to the current investigation.
This is why node-loop-detective has --no-io but doesn't have --no-lag or --no-profile. The lag detector and CPU profiler are minimally invasive (a single unref'd timer and V8's native sampler). The I/O tracker is qualitatively different — it modifies the behavior of core modules. That difference warrants an opt-out.
When to Use Each Mode
| Scenario | Command |
|---|---|
| General diagnosis (default) | loop-detective <pid> |
| Known CPU issue | loop-detective <pid> --no-io |
| Sensitive production system |
loop-detective <pid> --no-io (start here, add I/O later if needed) |
| Suspected slow I/O | loop-detective <pid> --io-threshold 200 |
| Full investigation with export | loop-detective <pid> -d 60 --save-profile ./profile.cpuprofile |
| Minimal risk, maximum info | loop-detective <pid> --no-io --save-profile ./profile.cpuprofile |
Try It
npm install -g node-loop-detective@1.6.0
# Full diagnostics (default)
loop-detective <pid>
# CPU and event loop only — no monkey-patching
loop-detective <pid> --no-io
Source: github.com/iwtxokhtd83/node-loop-detective
The best diagnostic tool is one you trust enough to run in production. Sometimes that means giving you the option to use less of it.
Top comments (0)