# Two Fixes Made Claude Code 15x Faster Per Command on Windows

**Author:** Mozex | **Published:** 2026-08-03 | **Tags:** Claude Code, Bash, Developer Tools, Performance, Windows | **URL:** https://mozex.dev/blog/24-two-fixes-made-claude-code-15x-faster-per-command-on-windows

---


I ran `echo` in Claude Code 178 times last week. Median time: 7,395 milliseconds.

Not a build. Not a test suite. A command that prints a word and exits. I'd filtered down to commands that do nothing at all, no pipes, no loops, no subshells, because I wanted to know what the tool costs when it isn't doing anything.

Claude Code had felt heavy for weeks. I'm on Windows 11 with Claude Code running through Git Bash, and I'd filed the sluggishness under "the model is thinking." It wasn't the model. Two local fixes later, that same `echo` returns in under half a second and a file read went from 909 ms to 24 ms.

<!--more-->

Across one normal week of my own work, 110 sessions and 14,731 tool calls, those two fixes remove **12.2 hours** of pure overhead. Nothing about the model changed. I stopped paying for two things I was never using.

Before any of the story, if you only want to know whether this applies to you, run this:

```bash
S=$(ls -1t ~/.claude/shell-snapshots/*.sh | head -1)
echo "size: $(wc -c < "$S") bytes"
echo "functions: $(bash --noprofile --norc -c "source '$S' >/dev/null 2>&1; compgen -A function | wc -l")"
time bash --noprofile --norc -c "source '$S' >/dev/null 2>&1"
```

The timing is the verdict. Under a second and you don't have this, so the rest is just a story. Several seconds and you're paying the same tax I was, and [the fix is a few sections down](#a-guard-on-claudecode-fixes-it). Mine read 92,844 bytes, 93 functions, 5.5 seconds. The size and function count are there to show you where the time went.

A low number is a real answer. I ran this on a second Windows 11 machine with the same Claude Code build, the same Git for Windows, and the same completion file sitting on disk. Its login shell defines all 144 completion functions, exactly like mine. Its snapshots have never held a single one, across 28 days of sessions. Claude Code captured nothing there at all, not even shell options. It writes that minimal snapshot when it finds no user config file to source, and for bash it looks for `~/.bashrc` rather than `~/.bash_profile`. That's why the check comes before the story.

## The evidence was already sitting on my disk

Claude Code writes a transcript of every session to `~/.claude/projects/<project>/*.jsonl`. Every tool call lands there as an `assistant` line with a `tool_use` block and a timestamp. Every result lands as a `user` line with a matching `tool_use_id` and its own timestamp.

Subtract one from the other and you have the real wall-clock latency of everything the agent has ever done on your machine.

That's the whole trick. No profiler, no instrumentation, no flag to turn on. I pulled 30 days: 442 sessions, 39,722 tool calls, 10,715 of them shell commands.

The median Bash call was 7.4 seconds. That proves nothing on its own, because plenty of my shell calls genuinely take seconds. So I filtered to commands that couldn't possibly be slow and got the same number.

That's when it stopped being a hunch.

## What's actually inside the shell snapshot

Claude Code creates a shell snapshot at session start. It captures your shell environment, functions, aliases, PATH, into a file, then sources that file before every Bash call so each command runs somewhere consistent. Sensible design. Mine was 92,925 bytes.

I timed sourcing it, five runs:

```
6,139 ms   6,347 ms   6,188 ms   6,235 ms   6,453 ms
```

For scale, `bash -c true` on the same machine takes about 80 ms.

Then I opened it. It's full of 92 blocks shaped like this:

```bash
eval "$(echo 'X19naXQgKCkgCnsgCiAg...' | base64 -d)" > /dev/null 2>&1
```

Claude Code serialises every shell function by base64-encoding it, then decodes each one back through a subprocess pipeline when the snapshot loads. Two processes per function. 92 functions is 184 process spawns, every time you run a shell command.

Rather than trust a number I'd read somewhere, I ran 92 equivalent `eval`/`base64` pairs in isolation, doing nothing useful:

```
6,781 ms   6,930 ms   6,921 ms
```

Those runs land slightly *above* the snapshot's own source time, so the decoding accounts for the entire cost within measurement noise. That works out to roughly 37 ms per process spawn on this machine. Linux spawns are usually quoted around 1 ms, which is the ratio that makes this bite here and stay invisible on a CI box. It is not Windows-only, though. The macOS report I get to at the end measures roughly 40 ms per function for the same reason, so the real condition is any platform where creating a process is expensive.

## How I traced it to git completion

The blocks are base64, so I decoded one. Inside was a shell function definition. Then another, and another: `__git`, `__git_complete`, `__git_aliased_command`.

To get a proper census rather than eyeballing it, I diffed `compgen -A function` before and after sourcing the snapshot in a clean shell. 93 functions appeared. 84 were double-underscore completion machinery, 83 of those from git. Eight were my own helpers. The 93rd is Claude Code's own `pkill` shadow, which it writes into every snapshot as plain text instead of an eval block, and that single function is why the file holds 92 blocks but restores 93 names.

That says what the functions are. It doesn't say how they got into a snapshot that exists to run `echo`. So I went looking for who loads them:

```bash
grep -rl "__git_complete" /etc/ /mingw64/share/
```

The hit that mattered was `/mingw64/share/git/completion/git-completion.bash`, which Git for Windows ships as standard. The file that sources it is `/etc/profile.d/git-prompt.sh`, on line 28.

Which left the last link in the chain: why is a *profile* script running at all when Claude Code just wants to execute one command? Because the snapshot isn't built by a plain shell. Here it's built by a login shell, so everything in `/etc/profile.d/` runs exactly as it would when you open a terminal yourself. Someone traced that same detail independently in one of the issues I'll get to at the end, which is the only part of this chain I didn't have to work out from my own machine.

## I was paying six seconds for a feature that cannot run

Those 84 functions exist for exactly one purpose: completing what you type when you hit TAB at a prompt yourself. Claude Code's Bash tool is non-interactive. There is no TAB key, readline isn't active, and nothing will ever call them.

They aren't broken, either. I drove the completion machinery directly and it resolved `git chec` to `checkout` exactly as it should. They simply cannot be reached from the one place that was paying for them.

## Claude Code already tries to filter these out

I went into the binary expecting to find that nobody had considered this. That isn't what's in there. The snapshot builder filters functions on the way in, and the comment sitting above the filter says exactly what it is for:

```bash
# Now get user function names - filter completion functions (single underscore prefix)
# but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init)
declare -F | cut -d' ' -f3 | grep -vE '^_[^_]' | while read func; do
```

Read the regex closely. `^_[^_]` requires the second character to be something other than an underscore. So `_foo` gets dropped, while `__foo` is kept on purpose so helpers from tools like mise and pyenv survive into the snapshot.

Git names every one of its completion functions with two underscores. `__git_complete`, `__git_ps1`, `__git_aliased_command`.

Counting my own login shell against that regex:

| Function shape | Count in my login shell | Filter verdict |
|---|---|---|
| `_x` single underscore | 60 | dropped |
| `__x` double underscore | 84 | **kept** |

Those two rows are the 144 completion functions. The 84 survivors, plus the nine helpers of my own that the snapshot also carried, are the 93 I counted coming out of it. So the filter written specifically to exclude completion functions correctly excludes 60 of them, then admits the 84 that account for nearly all of the cost. The largest source of completion functions on a Windows machine happens to use the exact prefix reserved for things worth keeping.

That reframes the whole thing for me. This was never an oversight. It is a one-character gap in a filter that was aimed at this problem and very nearly hit it.

## A guard on CLAUDECODE fixes it

There's no setting for this. I searched the 265 MB binary for `CLAUDE_CODE_DISABLE_SHELL_SNAPSHOT`, `DISABLE_SHELL_SNAPSHOT`, `shellSnapshot` and `snapshotDisabled`, then swept every environment-variable-shaped string containing `SNAPSHOT`. Nothing. Snapshots aren't optional, and on this machine Git Bash loads that completion file into every login shell.

But Claude Code sets `CLAUDECODE=1`, which is enough to fork the behaviour. This goes at the end of `~/.bash_profile`:

```bash
if [ -n "$CLAUDECODE" ]; then
    for _cc_fn in $(compgen -A function 2>/dev/null | grep '^_'); do
        unset -f "$_cc_fn" 2>/dev/null
    done
    unset _cc_fn
    complete -r 2>/dev/null
fi
```

It only touches underscore-prefixed functions, which by convention is completion machinery and nothing else, so my own helpers survive. Both directions checked before I trusted it:

| | Functions | My helpers | `__git*` |
|---|---|---|---|
| With `CLAUDECODE=1` | **3** | present | 0 |
| Normal terminal | **147** | present | 83 |

Then I restarted and measured the snapshot Claude Code wrote fresh:

| | Before | After |
|---|---|---|
| Size | 92,925 bytes | **6,860 bytes** |
| `eval`/`base64` blocks | 92 | **3** |
| Time to source | 6,196 ms | **261 ms** |

Full git completion still works in my normal terminal. It just doesn't follow Claude Code around anymore.

## Then a file read still took 672 milliseconds

With the snapshot fixed, a trivial Bash call was down to about 1,089 ms. And reading a file still took 672 ms.

That second number gave it away. Reading a file spawns no processes and touches no shell. It should be nearly free.

Claude Code plugins can register hooks that fire on events, and a hook config can specify a `matcher` to scope which tools it applies to. The Warp plugin registers a `PostToolUse` hook with no `matcher` at all, so it runs after every tool call. Every read, every edit, every write.

Worth separating the two clearly. The snapshot tax lands on anyone running Claude Code through Git Bash on Windows, whatever terminal that Git Bash sits in. This one only lands if you installed this plugin. The transferable part is the shape rather than the plugin: any hook without a matcher bills you on every tool call, and you won't feel it as slowness.

The transcripts record hook runs with durations. Over seven days:

| Hook | Runs | Average | p90 | Worst |
|---|---|---|---|---|
| `PostToolUse` (all tools) | 14,081 | 799 ms | 1,047 ms | 7,942 ms |
| `Stop` | 401 | 1,678 ms | | 7,934 ms |
| `SessionStart` | 72 | 1,465 ms | | 10,517 ms |

14,081 hook runs against 14,731 tool calls in the same window. 95.6% coverage, which confirms "every tool call" is meant literally.

I removed the plugin and restarted. Zero hook records in the new transcript:

| | Before both fixes | Snapshot fixed | Plugin removed |
|---|---|---|---|
| Bash (fastest observed) | 5,529 ms | 1,089 ms | **350 ms** |
| Read (average) | 909 ms | 672 ms | **24 ms** |

One caveat on that Bash row, because it looks wrong next to the snapshot numbers. It's a minimum, the single luckiest call in the window, so it lands below the 6,250 ms I measured for sourcing the snapshot on its own. Minimums do that. The typical case is the one that reconciles: a median trivial command ran 7,395 ms against components of 6,250 snapshot plus 800 hook plus 80 shell startup, leaving 265 ms for the command itself.

Read going from 909 ms to 24 ms is the cleanest evidence in the exercise. Reading a file does no shell work, so its entire latency *was* the hook. I'd been paying roughly 38 times the real cost on every file read for months.

As a multiplier, a trivial shell command went from a 7,395 ms median to 484 ms. Call it 15x. Be careful with that number though, including the one in this post's title: it's the ratio on commands where overhead *was* the runtime. A two-minute test suite is still a two-minute test suite, six seconds lighter. What actually got 15 to 38 times faster is the tax, and the tax is most of what an agent spends its day paying.

The problem is the price is invisible until you go looking, and a matcher-less `PostToolUse` hook is the most expensive shape a hook can have.

## What the two fixes actually bought

Not by comparing before-and-after averages. That's contaminated by real work, and a week of heavy test suites would read as a regression.

Instead: measure the per-call overhead directly, then multiply by the calls that happened. That's 5.95 s per Bash call for the snapshot and 0.80 s per tool call for the hook.

| | Count | Per call | Total |
|---|---|---|---|
| Bash calls | 5,378 | 5.95 s | 8.9 h |
| All tool calls | 14,731 | 0.80 s | 3.3 h |
| | | | **12.2 hours** |

That's 1.74 hours a day.

The previous 30 days, as a cross-check, come to 26.5 hours over 442 sessions. Bigger number, safer-sounding, and I nearly led with it. But it's diluted: that month includes a stretch where I worked far less than usual. The week is the accurate picture, not the flattering one.

Two checks before trusting it. Whether my own investigation inflated the week, since I ran heavy transcript scans while working this out: 143 Bash calls, 2.7% of the total. Noise. And whether the per-call costs were measured rather than estimated: the snapshot figure comes from timed runs of the file, the hook figure from 14,081 recorded runs.

At that weekly rate a month lands closer to 52 hours, but that's a projection. I'd rather stand behind what I counted.

### What I'd push back on if I were reading this

The post-fix numbers come from two sessions: eight trivial Bash calls, five reads. Magnitudes leave no doubt about direction, and the post-fix arithmetic reconciles to within 9 ms, but the exact medians will move as data accumulates. My before-numbers are firmer: 178 trivial commands, 1,617 reads, 14,081 hook runs.

This is also a Windows story, and it rests on one number I did not measure. My 37 ms per spawn is first-party. The ~1 ms Linux figure it's compared against is the commonly cited one, not something I've benchmarked on my own Ubuntu boxes yet. If that ratio is off, the "nobody else would see this" argument weakens with it.

One more gap worth naming rather than papering over: all of this is measured from the outside. Claude Code ships as a compiled binary, so I can time what it does and read what it writes to disk, but the mechanism is inferred from its behaviour rather than read off its implementation. Everything here is consistent with the numbers. That isn't the same as having seen the code.

## Check whether a hook is billing you too

You ran the snapshot check at the top. This is the other half, also read-only:

```bash
grep -h '"hookEvent"' ~/.claude/projects/*/*.jsonl 2>/dev/null \
  | grep -o '"hookName":"[^"]*"' | sort | uniq -c | sort -rn | head
```

If one `PostToolUse` entry dwarfs everything else, open that plugin's `hooks.json` and look for a `matcher`. No matcher means it's firing on every tool you use, and the fix is either to narrow the matcher or drop the plugin.

If you end up tinkering with your setup after this, I've also written about [the status line I use to keep usage, pace, and context visible](https://mozex.dev/blog/1-my-claude-code-status-line-usage-pace-and-context-at-a-glance), which lives in the same corner of the config.

## Five issues describe this. The one that found the cause was closed as NOT_PLANNED.

[claude-code#19585](https://github.com/anthropics/claude-code/issues/19585) describes 30 to 90 second delays per Bash command on Windows. Closed as NOT_PLANNED. [#15756](https://github.com/anthropics/claude-code/issues/15756), snapshot creation hanging on a SIGTERM timeout, also NOT_PLANNED. [#57435](https://github.com/anthropics/claude-code/issues/57435) traced that the snapshot is built by a login shell, which is exactly why `/etc/profile.d/git-prompt.sh` runs at all. Also NOT_PLANNED.

The one I wish I had found before starting is [#31437](https://github.com/anthropics/claude-code/issues/31437), filed in March. Someone got to the same place I did and measured it harder: 194 of their 199 captured functions were completion machinery, `__git_*` alongside `__brew_*` and `__gh_*`, at roughly 8 seconds per Bash call. Closed as NOT_PLANNED.

Two neighbouring issues point the other way. [#40602](https://github.com/anthropics/claude-code/issues/40602) and [#60397](https://github.com/anthropics/claude-code/issues/60397) both report the inverse complaint, single-underscore helpers like `_safe_eval` going missing from the snapshot, and both closed as COMPLETED. Read together with #31437 they show the filter being tuned from one direction while the double-underscore hole stayed open. In the plugins repo, [#186](https://github.com/anthropics/claude-plugins-official/issues/186) is still open, and it is a snapshot-creation hang on Windows rather than anything to do with plugin hooks.

#31437 got there first and named completion functions outright, `__git_*` among them, so the cause was on the record in March. What none of them names is why the snapshot keeps those functions at all: a filter written to exclude completion machinery, with git's double-underscore names walking straight past it. That last link came out of measurement here.

Since #19585 is won't-fix and the behaviour still reproduces on 2.1.220, a local guard is the only route I know of.

## Go read your transcripts

The thing to take from this isn't the snippet. Your setup probably differs from mine.

It's that six seconds per command never felt like six seconds. It felt like the tool being a bit heavy. A constant tax spread across thousands of small operations registers as texture rather than slowness, and you stop noticing it entirely. I only found it because I stopped trusting the feeling and read the timestamps, the same way I'd [audit an unfamiliar codebase](https://mozex.dev/blog/15-how-i-audit-a-new-laravel-codebase-in-30-minutes) rather than guess where its problems are.

Every tool call you've ever made is sitting in `~/.claude/projects/` with a timestamp on it.

If I've got something wrong, or the guard misbehaves on a setup I haven't seen, tell me and I'll update the post. I'd particularly like numbers from anyone running Claude Code in a plain Git Bash window, no Warp anywhere near it, because the snapshot half of this should hit that setup in exactly the same way.