{"slug": "your-claude-code-hooks-are-costing-you-minutes-a-day-here-s-how-i-measured-it", "title": "Your Claude Code Hooks Are Costing You Minutes a Day — Here's How I Measured It", "summary": "A developer has created a shell script wrapper to measure the latency of Claude Code hooks, which can silently add minutes of waiting time per session. The wrapper, hook-latency-wrap.sh, uses bash's EPOCHREALTIME variable to log execution times without altering hook behavior, addressing a common performance issue in AI-assisted development workflows.", "body_md": "If Claude Code feels sluggish lately, the culprit probably isn't the model — it's the pile of shell scripts you wired into it months ago and never looked at again. Going from $0/month to a real income in six months came down to a lot of small habits, and one of them was refusing to leave my Claude Code hooks unmeasured.\n\nClaude Code has a feature called \"hooks.\" It's a simple mechanism, wired up in `settings.json`\n\n, that lets you inject arbitrary shell scripts before and after tool calls. Right now I have a dozen-plus hooks bundled across three types — PreToolUse, PostToolUse, and Stop — running everything from automatic git commits to latency reports, self-audits, and project categorization.\n\nThe problem is that **hooks run dozens of times in a single session**.\n\nSay Claude Code edits files 10 times in one session. If a PostToolUse hook fires each time, the execution time of that one hook × 10 becomes pure waiting cost. A lot of people never notice this and just feel like \"Claude Code got slower somehow.\" I was one of them — I once had a heavy Python-based process wired into my self-audit hook, and everything felt sluggish. It took me days to figure out why.\n\n**Perceived \"heaviness\" is proportional not to the number of hooks, but to the latency of each one.**\n\nThree hooks are fine if they all finish under 50ms. But a single hook with a p95 above 2000ms racks up 20 seconds of pure waiting after just 10 calls. The operator is doing nothing and losing 20 seconds. Run several sessions a day, and you're burning minutes — or tens of minutes — without realizing it.\n\nYou can't spot this loss by staring at the hooks section of `settings.json`\n\n. All that's written there is a command string; nothing records how many milliseconds it takes. That's why **measurement is the only answer**.\n\nWhat matters here is that you can **start with zero configuration changes**. The `hook-latency-wrap.sh`\n\nscript described below is just a shell script that takes an existing hook binary as an argument and wraps it. Without altering the original hook's behavior at all, it writes the elapsed time and exit code of each invocation to a log file in JSONL format. You don't need to rewrite your production hook logic to instrument it.\n\nMost people using Claude Code for side projects or solo development get absorbed in just getting hooks working and never get around to measuring them. I was the same. Every time I added a hook, I felt \"upgraded\" and left it at that. But that's close to loading a car with so many parts that fuel economy tanks while you celebrate the \"mods.\"\n\nPrecisely because Claude Code is an autonomous agent, the density of a single session matters. The time between issuing an instruction and getting a response shifts a lot with accumulated hooks. I think of it as \"cutting hook latency = raising my own hourly rate.\" Behind a figure like ¥1.2M monthly revenue is a stack of unglamorous habits, including making the tools I use as fast as possible.\n\n`$EPOCHREALTIME`\n\nThe wrapper script I'm showing here doesn't use Python — it measures with the bash builtin variable `$EPOCHREALTIME`\n\n. This variable is available in bash 5.0 and later and returns the current time in `seconds.microseconds`\n\nformat (e.g. `1720000000.123456`\n\n).\n\nWhy not call Python? Because it defeats the purpose if the measurement script itself becomes heavy. Python3 startup can cost tens to hundreds of milliseconds depending on the environment. If you invoke Python every time just to measure, that startup cost contaminates the measurement itself. With a bash builtin, you get the current time at microsecond resolution without spawning an additional process.\n\nThat said, if bash is older than 5.0 (like the default shell on older macOS), `$EPOCHREALTIME`\n\ncomes back empty. The implementation includes a Python3 fallback for that case. I'll cover it in the code walkthrough in the next section.\n\nHere's how the whole system is structured.\n\n```\nsettings.json\n  └─ command: \"hook-latency-wrap.sh  本来のhook.sh\"\n                        │\n                        ├─ 本来の hook.sh を実行（動作は変わらない）\n                        │\n                        └─ 経過時間・終了コード を JSONL に追記\n                                      │\n                            ~/.claude/logs/hook-latency.jsonl\n                                      │\n                            hook-latency-report.sh [days]\n                                      │\n                              ターミナルに集計表を出力\n                              （hook名・回数・mean・p95・max・fail）\n```\n\nThe wrapper writes to JSONL, and the reporter reads the JSONL and aggregates it. Two scripts, one log file. The only change to settings.json is \"prepend the wrapper's path to the command string.\"\n\nIt's 43 lines total. Here's the actual code, verbatim.\n\n``` bash\n#!/usr/bin/env bash\n# hook-latency-wrap.sh — 任意の hook をラップして実行時間を JSONL に記録\n#\n# 使い方:\n#   settings.json で command を以下に置き換える:\n#     \"command\": \"~/.claude/scripts/hook-latency-wrap.sh /path/to/hook.sh\"\n#\n# 出力: ~/.claude/logs/hook-latency.jsonl  (1行 = 1呼び出し)\n#   {\"ts\":\"...\",\"hook\":\"...\",\"elapsed_ms\":N,\"exit_code\":N}\n\nset -uo pipefail\n\nHOOK_BIN=\"${1:-}\"\n[ -z \"$HOOK_BIN\" ] && { echo \"usage: $0 <hook-binary> [args...]\" >&2; exit 64; }\nshift || true\n\nLOG_DIR=\"$HOME/.claude/logs\"\nmkdir -p \"$LOG_DIR\"\nJSONL=\"$LOG_DIR/hook-latency.jsonl\"\n\n# EPOCHREALTIME = bash 5+ で SS.NNNNNN 形式（秒.マイクロ秒）\nstart_us=$(printf '%s' \"${EPOCHREALTIME//./}\" | sed 's/^0*//')\n# 万一 EPOCHREALTIME が空(bash <5)なら python フォールバック\n[ -z \"$start_us\" ] && start_us=$(python3 -c 'import time;print(int(time.time()*1000000))')\n\n\"$HOOK_BIN\" \"$@\"\nexit_code=$?\n\nend_us=$(printf '%s' \"${EPOCHREALTIME//./}\" | sed 's/^0*//')\n[ -z \"$end_us\" ] && end_us=$(python3 -c 'import time;print(int(time.time()*1000000))')\n\nelapsed_ms=$(( (end_us - start_us) / 1000 ))\nhook_name=$(basename \"$HOOK_BIN\")\nts=$(date -u +%Y-%m-%dT%H:%M:%S)\n\nprintf '{\"ts\":\"%s\",\"hook\":\"%s\",\"elapsed_ms\":%d,\"exit_code\":%d}\\n' \\\n  \"$ts\" \"$hook_name\" \"$elapsed_ms\" \"$exit_code\" >> \"$JSONL\"\n\nexit \"$exit_code\"\n```\n\nThree points worth calling out.\n\n**① How timestamps are taken**\n\n`$EPOCHREALTIME`\n\nreturns a string like `1720000000.123456`\n\n. Stripping the dot with `${EPOCHREALTIME//./}`\n\ngives `1720000000123456`\n\n— an integer in microseconds. Leading zeros are trimmed with `sed 's/^0*//'`\n\n. Taking the difference between start and end times in microseconds and dividing by 1000 gives elapsed time in milliseconds. The external `date`\n\ncommand is used only to record the end timestamp (the `ts`\n\nfield); it isn't on the measurement critical path.\n\n**② Preserving the original hook's exit code**\n\nIt matters that the wrapper ends with `exit \"$exit_code\"`\n\n. Claude Code looks at a hook's exit code to determine errors. If the wrapper always returned 0, a failure in the original hook would never reach Claude Code. This design means inserting the wrapper doesn't change behavior as a hook.\n\n**③ Writing in append mode ( >>)**\n\nBecause it appends with `>> \"$JSONL\"`\n\n, the file doesn't get corrupted even when multiple hooks are called at the same time. JSONL is one record per line, so even if appends collide, the damage is limited to individual lines. The aggregation script uses `try/except`\n\nto skip broken lines, so it's a non-issue in practice.\n\nHere's the aggregation script in full as well.\n\n``` bash\n#!/usr/bin/env bash\n# hook-latency-report.sh — hook-latency.jsonl を hook ごとに集計\n# 使い方: ~/.claude/scripts/hook-latency-report.sh [days]\n#   days=7 がデフォルト\n\nset -uo pipefail\nDAYS=\"${1:-7}\"\nJSONL=\"$HOME/.claude/logs/hook-latency.jsonl\"\n[ -f \"$JSONL\" ] || { echo \"no data: $JSONL\"; exit 0; }\n\npython3 - \"$JSONL\" \"$DAYS\" <<'PY'\nimport sys, json, datetime, collections\nlog, days = sys.argv[1], int(sys.argv[2])\ncutoff = datetime.datetime.now() - datetime.timedelta(days=days)\n\nstats = collections.defaultdict(list)\nfail = collections.Counter()\ntotal_records = 0\nwith open(log) as f:\n    for line in f:\n        try:\n            r = json.loads(line)\n            ts = datetime.datetime.fromisoformat(r[\"ts\"])\n            if ts < cutoff:\n                continue\n            total_records += 1\n            stats[r[\"hook\"]].append(r[\"elapsed_ms\"])\n            if r.get(\"exit_code\", 0) not in (0, ):\n                fail[r[\"hook\"]] += 1\n        except Exception:\n            continue\n\nif not stats:\n    print(f\"no records in last {days}d\")\n    sys.exit(0)\n\n# 集計: count, mean, p95, max\nrows = []\nfor hook, vals in stats.items():\n    vals_sorted = sorted(vals)\n    n = len(vals_sorted)\n    p95 = vals_sorted[min(n-1, int(n*0.95))]\n    rows.append((hook, n, sum(vals_sorted)//n, p95, vals_sorted[-1], fail.get(hook, 0)))\nrows.sort(key=lambda r: -r[3])  # p95 降順（遅いものを上に）\n\nprint(f\"=== hook latency (last {days}d, {total_records} records) ===\")\nprint(f\"{'hook':<32} {'n':>5} {'mean':>7} {'p95':>7} {'max':>7} {'fail':>5}\")\nprint(\"-\" * 70)\nfor hook, n, mean, p95, mx, fl in rows:\n    flag = \" ⚠\" if p95 > 1500 else \"\"\n    print(f\"{hook:<32} {n:>5} {mean:>6}ms {p95:>6}ms {mx:>6}ms {fl:>5}{flag}\")\nPY\n```\n\nThe Python script is defined inline with a `<<'PY'`\n\nheredoc. I didn't split it into a separate `.py`\n\nfile because I didn't want to create a \"doesn't work unless you have both files\" dependency. Hold on to this one script and aggregation is self-contained.\n\nThe core of the aggregation logic is the single line `p95 = vals_sorted[min(n-1, int(n*0.95))]`\n\n. It computes the 95th-percentile index from the sorted list. The `min(n-1, ...)`\n\nis a guard against out-of-range access when the sample count is small. Output is sorted by p95 descending, so **the most problematic hook is always at the top**. One glance tells you what to fix.\n\nA `⚠`\n\nis appended to the end of the line when p95 exceeds 1500ms. I picked that threshold on the judgment that \"1.5 seconds of pure waiting per tool call is simply not acceptable.\"\n\nSample output looks like this.\n\n```\n=== hook latency (last 7d, 843 records) ===\nhook                              n    mean     p95     max  fail\n----------------------------------------------------------------------\nself_audit_stop.sh              127  1823ms  3240ms  8102ms     0 ⚠\npre_git_guard.sh                 98   420ms   890ms  2100ms     2\nhook-latency-wrap.sh            618    12ms    18ms    45ms     0\n```\n\nLooking at this table, you immediately see: \"`self_audit_stop.sh`\n\nhas a p95 of 3240ms with a ⚠ — that's the top improvement target.\" Its mean is 1823ms, so even on average it takes 1.8 seconds. It was called 127 times, so over 7 days it consumed at least 1823ms × 127 ≈ **3 minutes 54 seconds** of pure waiting. These are numbers you only see once you measure.\n\nThe code itself is what you've read so far, but to actually run it, how you write the `settings.json`\n\nside is the key. It looks like this.\n\n```\n{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nYou put the wrapper's path at the front of the `command`\n\nfield and pass the original hook path as the first argument. If there are additional arguments, write them after the original hook and they pass straight through via `\"$@\"`\n\n. The only change is this one line — just move the original command behind `command`\n\n.\n\nWhen Claude Code invokes a hook, it **streams the tool-call context to stdin as JSON**. For PostToolUse it's a structure like `{\"tool_name\":\"Edit\",\"tool_input\":{...},\"tool_response\":{...}}`\n\n. Because the wrapper launches the original hook with `\"$HOOK_BIN\" \"$@\"`\n\nwithout reading stdin, stdin is automatically inherited by the child process through bash's process inheritance. This part requires no special code — \"doing nothing\" is the correct answer.\n\nLet's read the timestamp line carefully once more.\n\n```\nstart_us=$(printf '%s' \"${EPOCHREALTIME//./}\" | sed 's/^0*//')\n```\n\n`$EPOCHREALTIME`\n\nreturns a string like `1720543200.847231`\n\non bash 5 and later. Replacing all dots with `//./`\n\nyields `1720543200847231`\n\n. That's a UNIX timestamp in microseconds. `sed 's/^0*//'`\n\nstrips leading zeros, but in practice UNIX timestamps don't have leading zeros, so this sed is mostly defensive code.\n\nThe same processing happens after completion, and the difference is computed.\n\n```\nelapsed_ms=$(( (end_us - start_us) / 1000 ))\n```\n\nDividing the microsecond difference by 1000 gives milliseconds. Bash's integer arithmetic `$(( ))`\n\ntruncates the fractional part, so this yields integer milliseconds.\n\nOne caveat: **bash integers are a mix of 32-bit and 64-bit depending on the environment**. `1720543200847231`\n\nis just under 16 decimal digits. A 64-bit integer (`long long`\n\n) maxes out at 9,223,372,036,854,775,807, so it fits with plenty of room. macOS's bash 5 is a 64-bit build, so there's no problem. But if you use this on an embedded device or an old 32-bit Linux environment, the arithmetic could overflow. I don't think many people care that much, but it can surface as a cause when you hit the problem \"a negative elapsed_ms got logged and broke the report\" (covered later).\n\nLook at the cutoff computation in the aggregation script.\n\n```\ncutoff = datetime.datetime.now() - datetime.timedelta(days=days)\n```\n\nAnd in wrap.sh, the timestamp is recorded like this.\n\n```\nts=$(date -u +%Y-%m-%dT%H:%M:%S)\n```\n\nThe `-u`\n\nflag records in **UTC**. But on the Python side, `datetime.datetime.now()`\n\nreturns **local time**. For Japan Standard Time (JST), that's UTC+9.\n\nThis produces a skew. Concretely: if you run `hook-latency-report.sh 1`\n\nat 8:00 in the morning JST, the cutoff becomes \"24 hours ago in local time\" (yesterday 8:00 JST). But the log's `ts`\n\nis recorded in UTC, so the cutoff is yesterday 8:00 JST = yesterday 23:00 UTC. In other words, only \"records after 23:00 UTC\" actually get picked up, which in JST terms means \"you only see the 9 hours since 8:00 this morning.\"\n\nWhen the day count is large (like `hook-latency-report.sh 7`\n\n), the impact is relatively small, but when you specify `1`\n\nor `2`\n\n, the 9-hour skew becomes non-negligible. To fix it properly, the cutoff should be compared in UTC — it should be changed to `datetime.datetime.utcnow()`\n\nor `datetime.datetime.now(datetime.timezone.utc)`\n\n. This is a known skew in the current implementation.\n\nThe error-detection code looks a bit odd at first glance.\n\n```\nif r.get(\"exit_code\", 0) not in (0, ):\n    fail[r[\"hook\"]] += 1\n```\n\nIt's written as `not in (0, )`\n\nwith a tuple. A plain `!= 0`\n\nwould give the same result, but writing it as a tuple makes the design \"easy to add more normal exit codes later.\" For example, if you wanted to treat termination by `SIGINT`\n\n(exit code 130) as normal, you'd just write `not in (0, 130)`\n\n. Right now only `(0, )`\n\nis in there, but this pattern is an intentional extension point.\n\nAlso, the default value of `0`\n\nin `r.get(\"exit_code\", 0)`\n\nexists so that records missing the `exit_code`\n\nfield (e.g. JSONL corrupted mid-write) aren't miscounted as errors. If it's missing, \"treat it as a success\" — a conservative design.\n\nThe implementation is small, but in a few weeks of actual operation I hit five problems.\n\nThis is the first thing that tripped me up. macOS's default `/bin/bash`\n\nis in the 3.2 line. Because Apple doesn't want to adopt GPLv3, it hasn't been updated since 2007. `$EPOCHREALTIME`\n\nwas added in bash 5.0, so it's undefined on the system bash.\n\nSince `set -uo pipefail`\n\nis at the top, the shell dies instantly with exit code 1 the moment it references an undefined variable.\n\n```\n# bash 3.2 では EPOCHREALTIME が unbound variable → ここで死ぬ\nstart_us=$(printf '%s' \"${EPOCHREALTIME//./}\" | sed 's/^0*//')\n```\n\nThe symptom was \"no sign the hook is running at all.\" The log file stayed empty, but no error was visible either. Claude Code was silently discarding the hook's error output, so I had no idea what was happening.\n\nI only noticed when I tried it directly in a shell.\n\n```\n$ /bin/bash --version\nGNU bash, version 3.2.57(1)-release\n\n$ /bin/bash ~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh\n/bin/bash: EPOCHREALTIME: unbound variable\n```\n\nThe fix is to install bash 5.x with `brew install bash`\n\nand set your PATH so that `#!/usr/bin/env bash`\n\nresolves to the Homebrew bash. You could also make the shebang explicit as `#!/opt/homebrew/bin/bash`\n\n, but that hurts portability, so I chose the former.\n\nThe reason the fallback existed but didn't work is that it died at `set -u`\n\nbefore ever reaching the fallback.\n\nWhen I learned that Claude Code streams JSON to hooks via stdin, I worried: \"if the wrapper reads stdin, won't it stop reaching the original hook?\" To be safe, I tried a form that receives stdin once and pipes it through again.\n\n```\n# やってしまったNG実装\ninput=$(cat)\necho \"$input\" | \"$HOOK_BIN\" \"$@\"\n```\n\nThis was wrong in two ways. First, because `cat`\n\nreads all of stdin into a variable before proceeding, it creates contention if the original hook was implemented to read stdin asynchronously or as a stream. Second, passing it via `echo`\n\nthrough a pipe turns stdin into a pipe, which breaks cases where the hook expects stdin to be a tty.\n\nIn reality, when bash runs a command, stdin is inherited as-is by the child process. `\"$HOOK_BIN\" \"$@\"`\n\nalone is enough for stdin to flow. \"Do nothing\" was the right answer. Reverting it made it work.\n\n`hook_name=$(basename \"$HOOK_BIN\")`\n\ntakes **only the file name**. If you use identically named hooks in different projects, they're treated as the same hook in the aggregation.\n\nI had `pre_git_guard.sh`\n\nin both my global config and a certain personal project. When I looked at the report, `pre_git_guard.sh`\n\n's invocation count was implausibly high and its p95 was higher than expected.\n\n```\nhook                              n    mean     p95\npre_git_guard.sh               312   198ms   940ms\n```\n\nIn reality it was the sum of the global version (fast) and the project version (a heavy process that checks the Git remote), which pulled the p95 up. It looks like \"pre_git_guard.sh is slow,\" but really only one of them is.\n\nAs a fix, I added the full path to the log fields.\n\n```\nhook_name=$(basename \"$HOOK_BIN\")\nhook_path=\"$HOOK_BIN\"   # フルパスも記録\n\nprintf '{\"ts\":\"%s\",\"hook\":\"%s\",\"path\":\"%s\",\"elapsed_ms\":%d,\"exit_code\":%d}\\n' \\\n  \"$ts\" \"$hook_name\" \"$hook_path\" \"$elapsed_ms\" \"$exit_code\" >> \"$JSONL\"\n```\n\nOn the `report.sh`\n\nside I changed the group-by from `hook`\n\nto `path`\n\n. That separates identically named hooks at different paths in the view.\n\nI manage both the global `~/.claude/settings.json`\n\nand a project `.claude/settings.json`\n\n, and at one point when consolidating settings, I applied the wrapper on top of a hook that was already wrapped.\n\n```\n\"command\": \"~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/hook-latency-wrap.sh ~/.claude/scripts/self_audit_stop.sh\"\n```\n\nThe symptoms were \"the report's n has doubled\" and \"`hook-latency-wrap.sh`\n\nitself appears in the report.\" Because the wrapper records its own execution time, the outer wrapper appends to the JSONL under the hook name `hook-latency-wrap.sh`\n\n.\n\n```\nhook                              n    mean     p95\nhook-latency-wrap.sh            843     8ms    14ms   ← これが出たら二重ラップ\nself_audit_stop.sh              843  1923ms  3890ms\n```\n\nI've made it a rule to immediately suspect double-wrapping if `hook-latency-wrap.sh`\n\nshows up in the report. The check command is:\n\n```\ngrep -r \"hook-latency-wrap\" ~/.claude/settings*.json .claude/settings*.json 2>/dev/null\n```\n\nFind a line where the path appears nested, and that's the spot.\n\nOnce, the report's mean displayed as `-1ms`\n\n. Opening the JSONL, I found a few records mixed in like `\"elapsed_ms\":-7`\n\n.\n\nThe cause wasn't **bash integer overflow** but a precision issue with `$EPOCHREALTIME`\n\n. When a hook finishes extremely fast (under 1ms), `start_us`\n\nand `end_us`\n\ncan match exactly and the difference can be zero. But going negative is strange.\n\nInvestigating, it turns out that within the same bash session, `$EPOCHREALTIME`\n\nvalues can occasionally \"invert.\" This happens when the timing of a macOS system-clock adjustment by NTP correction overlaps with the timing of the bash variable update. It's a matter of single microseconds, so it's usually not noticeable, but the occurrence rate goes up in environments where NTP corrections happen frequently (VMs, etc.).\n\nThe remedy is to discard records with `elapsed_ms < 0`\n\non the report.sh side.\n\n```\nif r.get(\"elapsed_ms\", 0) < 0:\n    continue\n```\n\nSince adding this, the aggregate values have been stable. The original implementation doesn't include this guard, so if you see negative values, I recommend adding it.\n\nBeyond the five covered in \"Where I Got Stuck\" (instant death on bash 3.2, stdin wrapping, basename collisions, double wrapping, negative elapsed_ms), there are several more points where I got stuck while continuing to operate this. Here's a comprehensive list so you don't stall in the same places.\n\n**Forgetting chmod +x gives you a silent exit with \"Permission denied.\"** Claude Code doesn't display hook error output, so you can't distinguish \"the wrapper never launched\" from \"the hook failed.\" When the JSONL is empty, permissions are the first thing to check. Run\n\n`ls -la ~/.claude/scripts/hook-latency-wrap.sh`\n\nand confirm the `x`\n\nin `-rwxr-xr-x`\n\nis there. Both wrap.sh and the original hook need the execute bit.**Don't rely on tilde ~ expansion in settings.json.** If you write\n\n`\"command\": \"~/.claude/scripts/hook-latency-wrap.sh ...\"`\n\n, Claude Code's implementation may not expand the tilde in a path it passes to execv without going through a shell. There are cases where it looks like it works but breaks after a session restart. The safe move is to use `$HOME`\n\n, as in `$HOME/.claude/scripts/hook-latency-wrap.sh`\n\n, or to write an absolute path.**When running report.sh periodically via launchd or cron, PATH is insufficient.** The Homebrew PATH (`/opt/homebrew/bin`\n\n) set in `~/.zshrc`\n\nis only read by interactive shells. Unless you specify launchd's `EnvironmentVariables`\n\nexplicitly, `python3`\n\nisn't found and report.sh fails silently. Writing `<key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string>`\n\nin the plist, or `export PATH=\"/opt/homebrew/bin:$PATH\"`\n\nat the top of the script, is the reliable route.\n\n**On macOS Ventura and later, if python3 isn't installed the fallback dies too.** On macOS Ventura and later, the\n\n`python3`\n\ncommand doesn't exist in environments without Xcode Command Line Tools installed. Because wrap.sh's `$EPOCHREALTIME`\n\nfallback (for bash < 5) calls `python3`\n\n, the fallback dies at the same time. Running `brew install python`\n\n, or using Homebrew's bash 5, solves it at the root. Either route is a single command.**The JSONL grows without bound.** There's no mechanism to stop appending to `hook-latency.jsonl`\n\n, and after months of operation it exceeds tens of thousands of lines. Since report.sh parses every line before filtering by period, there were environments where startup began taking hundreds of milliseconds past 100,000 lines. It's safer to put in a one-line launchd entry for monthly compress-and-rotate from the very beginning.\n\n```\n# 月次実行（launchd StartCalendarInterval）\ngzip -c ~/.claude/logs/hook-latency.jsonl \\\n  > ~/.claude/logs/hook-latency-$(date +%Y%m).jsonl.gz \\\n  && : > ~/.claude/logs/hook-latency.jsonl\n```\n\n**Omitting the days argument and staying stuck on the fixed 7 days.** Right after introduction you only have a day's worth of data, so\n\n`hook-latency-report.sh`\n\n's `days=7`\n\ndefault leaves you in a state where \"n is 10 or fewer and p95 isn't trustworthy.\" A stable usage pattern is to look at the current day with `hook-latency-report.sh 1`\n\nfor the first week, then switch to `hook-latency-report.sh 7`\n\nafter a week.**Feeling that things got slower after adding the wrapper, and suspecting the wrapper.** The perceived heaviness is latency from hooks that existed before the wrapper and has \"only now become visible thanks to measurement.\" wrap.sh's own overhead measures around mean 12ms / p95 18ms (see the `hook-latency-wrap.sh`\n\nrow in the sample output above). Latency beyond that value is the original hook's cost. Feeling \"it got faster\" after removing the wrapper is the illusion of measurement disappearing.\n\n**JSONL breaks if a hook name contains pipe characters or quotes.** `hook_name=$(basename \"$HOOK_BIN\")`\n\nembeds the file name directly into a JSON string field. If the script name contains `\"`\n\nor `\\`\n\n, the JSON is corrupted, gets absorbed by report.sh's `try/except`\n\n, and the count silently drops. Standardizing hook names to alphanumerics, underscores, and hyphens only is the safest approach.\n\n**Multiple Claude Code sessions running simultaneously mix up the aggregation.** If you run the desktop app and the terminal CLI at the same time, logs from both sessions get mixed into the JSONL. If you hit the phenomenon \"p95 hasn't changed even though I improved it in that session,\" suspect contamination from another session. If you want to append a `session_id`\n\nto the JSONL, the first step is to check whether a `CLAUDE_SESSION_ID`\n\nenvironment variable exists (as of July 2026 it isn't exposed as an environment variable, so substituting the process ID is the realistic approach).\n\n**Overlooking the 9-hour skew between report.sh's local-time cutoff and UTC logs.** As detailed earlier, logs recorded in UTC with the `-u`\n\nflag and a cutoff taken in local time (JST) via `datetime.datetime.now()`\n\nare out of sync. When you specify `hook-latency-report.sh 1`\n\nand run it at 8 a.m., you can hit the extreme situation of seeing \"only the 0 hours since 8 a.m. this morning.\" In a JST environment, if the record count is abnormally low when you specify `days=1`\n\nor `days=2`\n\n, this skew is the cause.\n\nAfter running this for a while, here are 12 items where I felt \"I should have done it this way from the start.\"\n\n**① Start with measurement, optimize later**\n\nIt's easy to feel \"upgraded\" every time you add a hook, but improvement without numbers is a placebo. Stick to the order: install `hook-latency-wrap.sh`\n\n, accumulate a week of data, then start improving. Numbers first, intuition second.\n\n**② Use p95, not mean, as your improvement metric**\n\nHook latency isn't uniform. It may finish within 200ms in most cases, but the instant you hit a Git remote timeout it records 5000ms. That spike is the true identity of perceived \"heaviness.\" Looking at p95 lets you grasp the reality that \"5 out of every 100 calls have unacceptable latency.\" This is why focusing on lowering the mean doesn't change how it feels.\n\n**③ For hooks with p95 > 1500ms, identify the cause before deleting**\n\nIt's important not to immediately try to delete a hook that shows ⚠. The countermeasure differs completely depending on whether the heaviness comes from network I/O (Git remote), CPU (Python processing), or shell startup cost (heavy subshell use). First run it standalone with `time ~/.claude/scripts/slow_hook.sh < /dev/null`\n\nto isolate the cause.\n\n**④ Know the wrapper's own overhead as a constant**\n\nIn my environment, wrap.sh's own overhead was mean 12ms / p95 18ms (see the `hook-latency-wrap.sh`\n\nrow in the sample output above). When setting improvement targets for a hook, treat the value with this fixed cost subtracted as \"the original hook's cost.\"\n\n**⑤ Use #!/usr/bin/env bash as the shebang so you go through Homebrew bash**\n\n`#!/bin/bash`\n\nbinds directly to the system bash (3.2 on macOS). With `#!/usr/bin/env bash`\n\n, whatever bash is on your PATH gets used. Install bash 5.x with `brew install bash`\n\nand put it on your PATH, and all bash 3.2-derived problems disappear at once.\n\n**⑥ Write settings.json commands with full paths**\n\nTilde notation is expanded when invoked via a shell, but that depends on Claude Code's implementation. Writing `$HOME/.claude/scripts/hook-latency-wrap.sh`\n\nor an absolute path means you're unaffected if Claude Code's internals change in the future.\n\n**⑦ Set up JSONL rotation first**\n\nAdd it later and you only notice once the existing log is already heavy. Set it up first. It's just two commands — compress + truncate — so all you do is put it in a monthly launchd `StartCalendarInterval`\n\n.\n\n**⑧ Add an elapsed_ms < 0 guard to report.sh**\n\nIt's not included in the current implementation (see the report.sh source above). If a negative value is occasionally recorded due to NTP correction timing, the mean goes negative and the report breaks. It's just one line added at the top of the `try/except`\n\nblock.\n\n```\n# with open(log) as f: ループ内、json.loads 直後に追記\nif r.get(\"elapsed_ms\", 0) < 0:\n    continue\n```\n\n**⑨ Prefix project-specific hooks**\n\nUsing generic names like `pre_check.sh`\n\nacross multiple projects mixes them together in basename-based aggregation. Adding a project prefix, as in `proj-foo_pre_check.sh`\n\n, makes it obvious at a glance in the table which project's what. To rename existing hooks, you can create an alias with `ln -s`\n\nand migrate without changing the actual file.\n\n**⑩ Run report.sh on a schedule and record changes**\n\nIt's easy to forget to run it manually. I run it via launchd every Monday at 9:00 and get a notification through `terminal-notifier`\n\n. The point is not to miss changes like \"this week's p95 got 300ms worse than last week's.\" Numbers only have meaning once they're recorded.\n\n**⑪ Have a rule to check the total p95 of existing hooks before adding one**\n\nMy condition is: \"before adding a new hook, look at report.sh and confirm the total p95 time of existing hooks is 3000ms or less.\" If it's over, cut before you add. Without this rule, hooks grow without limit. PostToolUse hooks are called dozens of times in a single session.\n\n**⑫ Customize the 1500ms threshold to your environment**\n\nThe design shows `⚠`\n\nat p95 > 1500ms, but for a hook that includes a Git remote check, there are cases where 2000ms as the norm is acceptable. Conversely, for a hook you want to keep within 100ms, you'd want a warning at 500ms. If you make the `1500`\n\nin report.sh's `flag = \" ⚠\" if p95 > 1500 else \"\"`\n\nan environment variable, you can change it dynamically at call time, as in `HOOK_WARN_MS=500 hook-latency-report.sh 7`\n\n.\n\nClaude Code hooks aren't a \"it works, that's enough\" design — \"it runs fast\" feeds directly into productivity. If one hook takes 2000ms and gets called 50 times in a session, you're spending 1 minute 40 seconds waiting on hooks rather than on the agent's response. If you don't notice, it goes on forever.\n\nWith just two scripts — `hook-latency-wrap.sh`\n\n(43 lines) and `hook-latency-report.sh`\n\n(53 lines) — you can make each hook's p95, mean, max, and error rate visible. As we saw above, in the case where `self_audit_stop.sh`\n\nhad a p95 of 3240ms and was called 127 times, it was only then that I learned there had been **3 minutes 54 seconds** of pure waiting over 7 days. Before measuring, all I had was a feeling of \"maybe it's heavy,\" with no way to prioritize improvements.\n\nYou can start just by prepending the wrapper's path to the `command`\n\nfield in settings.json. Put it in tonight and you'll have a day's worth of data by tomorrow morning.\n\nThe ¥1.2M monthly revenue figure is the accumulation of converting each individual \"feeling of heaviness\" into a measurable problem and killing it. Make a tool you use every day 20 seconds faster and you get more than 2 hours back over a year. You can build the next system in that time.\n\nI've written up the full picture of the setup, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/your-claude-code-hooks-are-costing-you-minutes-a-day-here-s-how-i-measured-it", "canonical_source": "https://dev.to/bokuwalily/your-claude-code-hooks-are-costing-you-minutes-a-day-heres-how-i-measured-it-4im4", "published_at": "2026-08-20 11:04:09+00:00", "updated_at": "2026-08-20 11:15:35.771055+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-agents"], "entities": ["Claude Code"], "alternates": {"html": "https://wpnews.pro/news/your-claude-code-hooks-are-costing-you-minutes-a-day-here-s-how-i-measured-it", "markdown": "https://wpnews.pro/news/your-claude-code-hooks-are-costing-you-minutes-a-day-here-s-how-i-measured-it.md", "text": "https://wpnews.pro/news/your-claude-code-hooks-are-costing-you-minutes-a-day-here-s-how-i-measured-it.txt", "jsonld": "https://wpnews.pro/news/your-claude-code-hooks-are-costing-you-minutes-a-day-here-s-how-i-measured-it.jsonld"}}