DEV Community

pponali
pponali

Posted on

I Ran a Health Check on My Claude Code Setup — Here's What Was Actually Slowing It Down

I've been running Claude Code against a mid-size monorepo (a Node.js + Spring Boot + Flutter + Kotlin agricultural platform, if you're curious — five components, a strangler-fig microservices migration in progress) for a few months now, wired up with a fairly aggressive set of hooks: repo memory that indexes symbols on every edit, a Postgres-backed transcript store for every prompt and tool call, automatic RCA triggers when a subagent fails, feedback capture on session end. It's a lot of automation, and automation you don't audit eventually silts up.

So I ran /doctor — a setup-health check that treats the Claude Code installation itself as a system worth debugging: duplicate installs, dead config, unused extensions burning context, and — the part that turned out to matter most — hooks that run on every single turn and have quietly gotten slow.

This post is the account of that audit: what it found, what surprised me, and the one bug that was costing 10+ seconds on every conversation turn for a reason that had nothing to do with the code doing the actual work.

Why bother auditing a CLI tool's config

Hooks in Claude Code are shell commands the harness runs automatically around specific events — before a tool call, after a tool call, when a prompt comes in, when the model finishes responding. They're how you bolt persistent memory, telemetry, or guardrails onto an otherwise stateless agent loop. Mine do things like: re-index the codebase's symbol graph after an edit, capture the git diff for feature attribution, and log every prompt/response pair to a database for a later confidence-scoring pass.

The problem with hooks specifically is that they're invisible by design. They don't show up in the conversation. They just... run, in the background, adding latency to every turn, and unless you go looking, a hook that's degraded from "instant" to "12 seconds" reads to the user as "the model got slower today" — nobody thinks to blame the plumbing.

What the audit actually checks

The scan pulls from local, read-only sources only — no telemetry upload, nothing sent anywhere:

  • Installation health. Are there duplicate installs (native launcher vs. an old npm-global leftover)? Does the resolved binary match what the config thinks is installed? Any broken or colliding agent definition files?
  • Usage signal for extensions. Every skill, plugin, and MCP server (a connection to an external tool) has either a lifetime usage counter or — for MCP servers, which have none — transcript evidence from recent sessions. Cross-reference both against a window of the 50 most-recently-touched session transcripts.
  • Checked-in memory file bloat. CLAUDE.md files get loaded into every session's context. Anything in them a fresh session could reconstruct by reading the code (a docker-compose port table, standard flutter build commands already in pubspec.yaml) is dead weight paid every session.
  • Hook performance. Aggregate durationMs per hook, per event, from the transcript's attachment records. Flag anything that's both frequent (fires every prompt or every tool call) and slow.
  • Permission friction. Which safe, read-only commands keep getting denied and re-prompted for, that could be pre-approved once.

I ran it, then acted on the findings, which is the part worth walking through.

What was actually wrong

A duplicate install. which -a claude turned up two resolutions — the native launcher I actually use, and a stale @anthropic-ai/claude-code@2.1.76 sitting in an npm-global prefix, two minor versions behind. Harmless on its own, but it's the kind of thing that causes "wait, which version am I even running" confusion six months later. Removed.

One broken, silently-dead agent file. Out of 193 project-level agent definitions, one had no description: in its frontmatter — which means Claude Code never loads it at all. It also happened to collide in name: with a working sibling file in the same directory, which is a separate failure mode (when two files in one directory share a name, the loser is discarded based on directory-read order, which isn't guaranteed stable across machines). Deleted the broken one.

Genuinely unused extensions. A plugin with zero recorded uses since install. An MCP server (Dropbox) with zero invocations across the whole scan window. Both disabled — reversibly, one command each to bring back.

Checked-in CLAUDE.md files carrying content the code already says. My Flutter app's memory file listed the full flutter build/flutter test command set and a directory tree — both fully derivable by reading pubspec.yaml and ls lib/. Same pattern in three other component files: a Kotlin Gradle command list, a Node.js npm run list, a docker-compose port table. None of it was wrong, it was just cost paid on every single session for something a few ls/cat calls reconstruct for free. Trimmed all four down to the parts that aren't derivable — setup steps that need an external file, a gotcha about a migration naming convention, a pointer to an external QA test-case spreadsheet. Those stay, because no amount of reading the repo tells you a Google Sheet exists.

And the big one: hook latency.

The hook that was eating 12 seconds a turn

The Stop hook — the one that fires after every model response — was averaging 12.1 seconds, with a worst case of 113 seconds, across 251 recorded runs in the scan window. The UserPromptSubmit hook (fires on every message you send) was averaging 5.5 seconds. For context: the rule of thumb is that anything running on every prompt or every tool call should stay under roughly 2 seconds, and even the less-frequent session-boundary hooks (SessionStart, Stop) should stay under 10.

My first assumption, walking in, was that the hook scripts referenced a hardcoded path from when I'd set this up on a different machine (a Mac) and were now silently failing on this Linux box — every python3 /Users/me/project/.claude/hooks/whatever.py call would just error out fast and move on. That turned out to be half right: I did find exactly that bug, but in a smaller, unrelated script (a debug-logging hook that was writing to a log path that no longer existed — fast to fail, ~5ms, not the source of the 12-second problem). The actual Stop and UserPromptSubmit hooks had already been fixed to use $PWD and $HOME instead of a hardcoded path.

The real cause was simpler and less obvious: the Stop hook chains six separate calls to a repo-memory CLI, each one invoked as npx -y @invariance/gps <subcommand>. npx -y doesn't just run a cached binary — every invocation re-resolves the package and, in the -y (auto-confirm) case, checks the registry for whether a newer version exists, before it runs anything. Timed cold: 1.6 seconds per call. Six of those, sequentially, in one hook, before the turn is considered "done." UserPromptSubmit chains three more. That's the 12-second average baseline right there — and the 113-second worst case is almost certainly a slow or flaky registry round-trip on top of that, since npx -y's freshness check is a live network call every single time, not a cached one.

The fix was almost embarrassingly small: install the package once, globally, so it's a real binary on $PATH.

npm install -g @invariance/gps --prefix ~/.npm-global
Enter fullscreen mode Exit fullscreen mode

Then swap every npx -y @invariance/gps <subcommand> in the hook config for a plain gps <subcommand>. Same six calls, same behavior — just no per-call registry check and no npm package-resolution overhead.

Before:  npx -y @invariance/gps brief --root "$PWD"   → ~1.6s, network round-trip every time
After:   gps brief --root "$PWD"                        → ~0.7s, resolved once, no network
Enter fullscreen mode Exit fullscreen mode

Six calls at 1.6s each versus six at 0.7s each is roughly the difference between a 10-second tax and a 4-second one, on every single turn — and it eliminates the network-flakiness tail that was almost certainly behind that 113-second outlier. I verified the fix with a direct timing test before and after: 1.601s cold via npx, 0.740s via the global binary, consistently, across repeated runs.

It's worth being precise about what that 0.7 seconds still is, because it's not zero. It's still a fresh Python/Node process spin-up, still a full CLI argument parse, still whatever the tool does internally before it touches the filesystem. Installing globally removes exactly one specific tax — the registry freshness check — and nothing else. If I wanted to go further, the next lever would be collapsing the six sequential subcommands into one long-running call, or making the non-critical ones (suggest, prune) asynchronous so the turn doesn't block on them at all. I didn't do that here, on purpose: the ask was "why is this slow," not "rewrite this tool's invocation model," and a six-line diff that's easy to verify beats a bigger one that's easy to get subtly wrong. Diminishing returns are still returns, but there's a point where the next fix belongs in its own, separately-reviewed change.

The diagram below is the shape of the fix: four hook events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) fire in sequence around every turn, three of them call into the repo-memory CLI, and that's where the cold-start tax was hiding. A separate transcript-logging hook writes every prompt/response pair to Postgres — I profiled that too, out of caution, and it's a non-issue: about 50ms per write, confirmed with a direct psql round-trip test.

What I didn't touch, and why

A couple of things came up during the audit that I deliberately left alone.

The repo memory MCP server (gps, exposed as a set of MCP tools inside Claude Code itself, distinct from the CLI binary discussed above) showed zero tool invocations in the scan window. By the "unused → disable" logic that applied everywhere else, that's a removal candidate. But the project's own guidance mandates using it before any non-trivial edit, and the cost of leaving it enabled is close to zero — MCP tool schemas are deferred by default, meaning only the tool name sits in context until it's actually called, not the full schema. One thin transcript window showing zero calls isn't strong enough evidence to override an explicit, repo-wide instruction, especially when the downside of being wrong (removing something actually load-bearing) is worse than the upside (saving essentially nothing). Sometimes the right call from an audit is "leave it, and say why," not "remove everything unused."

I also didn't touch anything requiring destructive or hard-to-reverse action without asking first — no force-pushes, no permission-mode changes applied without a separate confirmation step, no deleting anything that wasn't obviously dead. Every change made was either trivially reversible (a plugin disable is one command to undo) or a working-tree edit left for review before it got anywhere near a commit.

The takeaway

None of these problems were exotic. A stale duplicate install. A broken frontmatter field. Memory files repeating what the code already states. And a six-line hook chain that had quietly picked up a per-call network round-trip nobody asked for, because npx -y optimizes for "always get the latest version" in a context (a hook that fires hundreds of times a session) where that tradeoff makes no sense at all.

The pattern underneath all of it: automation you don't periodically re-examine degrades in ways that are individually invisible and collectively expensive. A hook doesn't announce that it's gotten 8x slower. It just quietly adds a few seconds to every response until "Claude feels slow today" becomes the ambient explanation, and the actual cause — six npx cold starts nobody's looked at in months — never gets named.

If you're running any nontrivial hook setup, it's worth timing it occasionally. Not because any single hook is doing something exotic, but because "small tax, every single turn" compounds in a way that's easy to stop noticing.

Top comments (0)