This is another entry in my Claude Code environment series, following the previous piece on the project ledger and symlink tree.
As you add more hooks, a vague feeling creeps in: "things got heavier." There's a mysterious pause between turns, and a long gap between hitting enter on a prompt and the response starting. But pinning down which hook is responsible is hard. Hooks are called from inside Claude Code, and unless they error, nothing shows up in any log. In this article I introduce a single wrapper that writes each hook's execution time to a JSONL file, plus a script to aggregate it. I'll show all the real numbers too.
The problem: I can tell hooks are slow, but not which one
My Claude Code environment currently has more than 10 kinds of hooks wired in.
-
UserPromptSubmit: cost guard, model routing notification, context loading -
PreToolUse(Edit|Write): secret scan,.envprotection -
PreToolUse(Bash): git command guard -
SessionStart: automatic skill update, Obsidian context loading -
MessageDisplay: filtering
When a hook runs synchronously, Claude Code blocks until it finishes. Manually tracing whether "cost_guard.sh is heavy every time" or "it's actually obsidian_context.sh" is impossible.
How the measurement works: a single $EPOCHREALTIME wrapper
~/.claude/scripts/hook-latency-wrap.sh is the wrapper itself.
#!/usr/bin/env bash
# hook-latency-wrap.sh — 任意の hook をラップして実行時間を JSONL に記録
# 使い方: settings.json の command を以下に置き換える
# "command": "~/.claude/scripts/hook-latency-wrap.sh /path/to/hook.sh"
set -uo pipefail
HOOK_BIN="${1:-}"
[ -z "$HOOK_BIN" ] && { echo "usage: $0 <hook-binary> [args...]" >&2; exit 64; }
shift || true
LOG_DIR="$HOME/.claude/logs"
mkdir -p "$LOG_DIR"
JSONL="$LOG_DIR/hook-latency.jsonl"
# EPOCHREALTIME = bash 5+ で SS.NNNNNN 形式(秒.マイクロ秒)
start_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
# 万一 EPOCHREALTIME が空(bash <5)なら python フォールバック
[ -z "$start_us" ] && start_us=$(python3 -c 'import time;print(int(time.time()*1000000))')
"$HOOK_BIN" "$@"
exit_code=$?
end_us=$(printf '%s' "${EPOCHREALTIME//./}" | sed 's/^0*//')
[ -z "$end_us" ] && end_us=$(python3 -c 'import time;print(int(time.time()*1000000))')
elapsed_ms=$(( (end_us - start_us) / 1000 ))
hook_name=$(basename "$HOOK_BIN")
ts=$(date -u +%Y-%m-%dT%H:%M:%S)
printf '{"ts":"%s","hook":"%s","elapsed_ms":%d,"exit_code":%d}\n' \
"$ts" "$hook_name" "$elapsed_ms" "$exit_code" >> "$JSONL"
exit "$exit_code"
There are two key design points.
① Don't call python — use $EPOCHREALTIME. date +%s%N only works on Linux, and macOS's date doesn't support nanoseconds. The built-in variable $EPOCHREALTIME in bash 5 and later returns seconds plus microseconds in the form 1716912345.678901. On macOS Ventura and later, /bin/bash --version is 5.x, so it's usable out of the box.
② Pass through the original exit code with exit "$exit_code". If a hook returns exit 2, Claude Code is supposed to block and show an error, but if the wrapper always returns 0, that information is lost. Since this is a measurement-only wrapper, it makes no changes whatsoever to the hook's own behavior.
How to slot it into settings.json
You just pass the existing hook command through the wrapper. The surrounding structure stays the same.
// 変更前
{
"type": "command",
"command": "~/.claude/hooks/cost_guard.sh"
}
// 変更後(1行変えるだけ)
{
"type": "command",
"command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/hooks/cost_guard.sh"
}
The relevant part of my actual ~/.claude/settings.json looks like this. All three hooks wired into UserPromptSubmit are wrapped.
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/hooks/user_prompt_submit.sh"
},
{
"type": "command",
"command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/hooks/cost_guard.sh"
},
{
"command": "~/.claude/scripts/hook-latency-wrap.sh ~/.claude/hooks/model_routing_reminder.sh",
"type": "command"
}
]
}
]
With this, every hook call is appended, one line at a time, to ~/.claude/logs/hook-latency.jsonl.
{"ts":"2026-06-21T20:10:41","hook":"cost_guard.sh","elapsed_ms":5196,"exit_code":0}
{"ts":"2026-06-21T20:10:33","hook":"obsidian_context.sh","elapsed_ms":827,"exit_code":0}
{"ts":"2026-06-21T20:11:18","hook":"skills-auto-update.sh","elapsed_ms":45705,"exit_code":0}
Aggregation script: hook-latency-report.sh
~/.claude/scripts/hook-latency-report.sh [days] produces the aggregation. The default is the last 7 days.
python3 - "$JSONL" "$DAYS" <<'PY'
import sys, json, datetime, collections
log, days = sys.argv[1], int(sys.argv[2])
cutoff = datetime.datetime.now() - datetime.timedelta(days=days)
stats = collections.defaultdict(list)
fail = collections.Counter()
total_records = 0
with open(log) as f:
for line in f:
try:
r = json.loads(line)
ts = datetime.datetime.fromisoformat(r["ts"])
if ts < cutoff:
continue
total_records += 1
stats[r["hook"]].append(r["elapsed_ms"])
if r.get("exit_code", 0) not in (0, ):
fail[r["hook"]] += 1
except Exception:
continue
rows = []
for hook, vals in stats.items():
vals_sorted = sorted(vals)
n = len(vals_sorted)
p95 = vals_sorted[min(n-1, int(n*0.95))]
rows.append((hook, n, sum(vals_sorted)//n, p95, vals_sorted[-1], fail.get(hook, 0)))
rows.sort(key=lambda r: -r[3]) # p95 降順
for hook, n, mean, p95, mx, fl in rows:
flag = " ⚠" if p95 > 1500 else ""
print(f"{hook:<32} {n:>5} {mean:>6}ms {p95:>6}ms {mx:>6}ms {fl:>5}{flag}")
PY
The decision criterion is to display ⚠ when p95 exceeds 1,500ms. A synchronous Claude Code hook blocks the user for exactly this long. When you're kept waiting 1.5 seconds every time you send a prompt, you're into the range where it perceptibly feels "heavy."
Note
The reason to use p95 is that "the mean hides, the max deceives." The mean gets pulled down by the healthy majority of calls and hides the spikes. The max shoots up from a single outlier (waking from sleep, a timeout, a zombie process) and diverges from the everyday experience. p95 means "95 out of 100 calls fall within this time," so it honestly shows the everyday slowness.
Real numbers: results from 29,866 records over the last 7 days
=== hook latency (last 7d, 29866 records) ===
hook n mean p95 max fail
----------------------------------------------------------------------
skills-auto-update.sh 365 46659ms 46114ms 1073920ms 0 ⚠
cost_guard.sh 1493 605ms 2518ms 5802ms 0 ⚠
obsidian_context.sh 728 27816ms 782ms 19636398ms 1
model_routing_reminder.sh 23 148ms 451ms 641ms 0
pre_secrets_check.sh 2642 90ms 235ms 5561ms 0
user_prompt_submit.sh 1494 73ms 210ms 997ms 0
pre_env_guard.sh 2642 59ms 154ms 4754ms 0
pre_git_guard.sh 5726 43ms 117ms 2665ms 2
message_display_filter.sh 14753 47ms 96ms 5859ms 0
Two hooks have the ⚠ raised. Their actual situations are different.
skills-auto-update.sh (p95: 46 seconds): On SessionStart it spends 45 seconds every time doing a git pull of the skill list. At a glance this looks fatal, but because I've set "async": true in settings.json, it does not block Claude Code. A hook with the async flag lets the session start without waiting for the result. There's no problem, but without the async flag it would have frozen for 45 seconds on every startup.
cost_guard.sh (p95: 2,518ms): This one is a synchronous UserPromptSubmit hook. Every time you enter a prompt, it calls ccusage to check the plan quota. A block of 2.5 seconds at p95, up to 5.8 seconds at max, happens on every turn. It matches the feeling — the mysterious 2 to 3 seconds between sending a prompt and the response starting was this all along.
obsidian_context.sh's mean is broken: The contradiction of a mean of 27,816ms (27 seconds!) while the p95 is 782ms is because records where the hook survived as a zombie during a Mac sleep, then got recorded all at once after waking, are mixed in. There's also one record with exit code 1, which matches a post-sleep failure. The mean is unusable. The p95 is 782ms with async: false, so there is indeed a wait at SessionStart, but it's within acceptable range.
Pitfalls I hit
-
When stripping the decimal point out of
$EPOCHREALTIME, leading zeros remain → without insertingsed 's/^0*//', the shell errors on the integer conversion -
On bash 4.x (macOS's default
/bin/bash),$EPOCHREALTIMEis empty → either install bash 5 withbrew install bashor leave it to the python3 fallback. Check whether the fallback is kicking in withecho "${BASH_VERSION}" -
You can't measure a hook wrapped with the async flag → an
async: truehook is forked by Claude Code as a child process that it never waits for, soelapsed_msis still written correctly to the JSONL. However, that hook's block time is zero, so even if a ⚠ shows up in the aggregation, it's harmless operationally - Even when the mean is broken, p95 is fine → even if zombie records during sleep push the mean up, they aren't a large enough quantity to move the 95th-percentile position. Don't trust the mean; always look at p95
-
You'll have an accident if you don't pass through the exit code → if the wrapper always returns
exit 0, then even whenpre_git_guard.shblocks, it slips through. Returning the original code withexit "$exit_code"is mandatory - The JSONL grows to 5.8MB → that's the size for 7 days and 29,866 records. Without rotation it goes over 100MB in half a year. You need logrotate or a periodic launchd trim
Summary
- Wrap hooks with
hook-latency-wrap.sh→ the execution time is recorded to~/.claude/logs/hook-latency.jsonl - Aggregate count/mean/p95/max per hook name with
hook-latency-report.sh, with ⚠ for p95 > 1500ms - What the real numbers revealed:
skills-auto-update.shtakes 46 seconds but isasync: trueso it doesn't block.cost_guard.shwas causing a synchronous block of p95 2.5 seconds on every turn - The mean gets broken by outliers (zombie processes, waking from sleep). p95 is closer to reality
- The presence or absence of the async flag changes what a hook's "heaviness" even means. Check async/sync before measuring
Next time I'll write about making cost_guard.sh asynchronous based on this JSONL — the trade-offs of switching a synchronous hook to asynchronous, and how to guarantee the things you "always want to check before blocking" even when it's asynchronous.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)