DEV Community

Bill Tu
Bill Tu

Posted on

Less Is More: Why We Added a Flag to Disable Our Best Feature

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
Enter fullscreen mode Exit fullscreen mode

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 setInterval probe)
  • CPU profiling (V8's built-in sampling profiler via CDP)
  • All analysis: heavy functions, call stacks, blocking pattern detection
  • --save-profile for 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

In the CLI, it's a boolean flag:

const boolMap = {
  '--no-io': 'no-io',
  // ...
};

const config = {
  noIO: values['no-io'],
  // ...
};
Enter fullscreen mode Exit fullscreen mode

In the programmatic API:

const detective = new Detective({
  pid: 12345,
  duration: 10000,
  noIO: true,
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.

  2. Production is not staging. What's safe in development might not be acceptable in production. Giving operators granular control respects this reality.

  3. 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
Enter fullscreen mode Exit fullscreen mode

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)