The morning I added eslint --fix to PostToolUse, my terminal filled with a red wall of Hook 'lint-all' command timed out after 60s. The hook was firing fine — but my over-grown monorepo lint config couldn't finish in the default sixty seconds. As an indie developer who has been shipping apps since 2014 (around 50M cumulative downloads across wallpaper, healing, and manifestation titles), I've watched that exact scenario play out more than once. Frontend tooling bloats quietly, and Claude Code's hook watchdog is the first thing that notices.
This failure is distinct from a hook returning a non-zero exit code: the command starts correctly, but Claude Code's internal watchdog sends SIGTERM at the timeout boundary. Here's how I diagnose it and the configuration patterns I now reach for.
What Actually Happens When command timed out Fires
When Claude Code runs a hook, it tracks the wall-clock time of the spawned process tree. If you exceed the configured (or default) timeout, the watchdog sends SIGTERM to the entire tree. The default I see on my client is 60 seconds, though this varies by version. The hook script is a child process, so Claude Code's main session stays alive — only the hook dies mid-flight.
The easiest trap to fall into is backgrounding inside the hook: writing npm run lint & doesn't help, because Claude Code is watching the whole process tree, not just the shell. Unless you fully detach the child (which I'll cover further down), it's still subject to the timeout.
The patterns that reliably blow past 60 seconds are predictable: ESLint over the full project, Prettier on a monorepo, tsc --noEmit, the full Jest suite, or Docker image builds. None of these are wrong commands — they're just wrong for a per-tool-call hook.
The First Question Is "What's the Hook Granularity," Not "How Big Is the Timeout"
When the timeout hits, the instinctive move is to bump timeout from 60 to 300 seconds. That rarely solves the real problem. Hooks fire on every matching tool call, so a five-minute lint means you wait five minutes every time you edit a single file. Claude Code's speed advantage evaporates. The fix is almost always to make the hook itself terminate in seconds.
A practical pattern: narrow the PostToolUse matcher to Edit / Write / NotebookEdit, then write the hook body so it only inspects the file Claude Code just touched. Claude Code exposes tool_input.file_path through the hook payload — read it from stdin and dispatch per extension.
#!/usr/bin/env bash
# .claude/hooks/post-edit-lint.sh
set -euo pipefail
# Extract file_path from the JSON payload Claude Code passes in
FILE="$(jq -r '.tool_input.file_path // empty' <<< "${CLAUDE_HOOK_INPUT:-}")"
[[ -z "$FILE" ]] && exit 0
[[ ! -f "$FILE" ]] && exit 0
# Route to the right tool based on extension; everything else is a no-op
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
npx --no-install eslint --fix --max-warnings=0 "$FILE"
;;
*.md|*.mdx|*.json|*.yaml|*.yml)
npx --no-install prettier --write "$FILE"
;;
esacThis never scans the whole project. On a repo that used to take 90 seconds for eslint ., the per-file version finishes in 2–3 seconds — well below the watchdog ceiling and barely noticeable in the loop.
Writing the timeout Field Correctly When You Truly Need More Time
There are legitimate cases where a hook needs to run longer than 60 seconds — a build script triggered from PostToolUse, or a larger test suite hanging off a Stop hook. Claude Code's settings.json lets you set timeout per hook entry.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/post-edit-lint.sh",
"timeout": 30
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/run-build.sh",
"timeout": 300
}
]
}
]
}
}The unit is seconds, not milliseconds. I see people occasionally treat it as milliseconds and end up with surprisingly long waits — 500 means 500 seconds. Stop hooks fire when the user has already decided to end the turn, so a longer timeout there doesn't hurt perceived performance. PostToolUse fires on every edit, so I keep it at 30 seconds maximum.
Settings changes don't hot-reload. Exit the Claude Code session (/exit) and start a new one so the new timeout takes effect.
Detaching Long Work So the Hook Returns Instantly
When you really do want long-running work but you don't want Claude Code waiting on it, detach the job entirely and let the hook exit immediately. setsid + nohup + disown removes the process from the parent's job table and the controlling terminal.
#!/usr/bin/env bash
# .claude/hooks/post-edit-build.sh
set -euo pipefail
LOG_DIR="${HOME}/.claude/hook-logs"
mkdir -p "$LOG_DIR"
TS="$(date +%Y%m%d-%H%M%S)"
LOG="${LOG_DIR}/build-${TS}.log"
# Fully detach from Claude Code's hook watchdog
setsid nohup bash -c "
cd \"$(pwd)\"
npm run build > '$LOG' 2>&1
echo \"exit=\$?\" >> '$LOG'
" </dev/null >/dev/null 2>&1 &
disown || true
# Return success immediately
echo "build kicked off → $LOG"
exit 0The hook finishes in milliseconds. The build keeps running in the background. The log file is your only signal — which is fine for things like deploy verification or AdMob revenue aggregation that you want triggered automatically but don't need feedback on in real time.
The trade-off is that build failures don't surface to Claude Code immediately. If you want failure visibility, add a Stop hook or a separate script that scans the latest log file and reports errors back into the next turn.
Where to Look When You're Stuck
Claude Code does log timeout events to stdout, but they scroll away quickly. I add a couple of lines at the top of every hook to log who ran, when, and with what file. This pays for itself the first time you need it.
# Top of every hook script
LOG_DIR="${HOME}/.claude/hook-logs"
mkdir -p "$LOG_DIR"
exec > >(tee -a "$LOG_DIR/hook-$(date +%Y%m%d).log") 2>&1
echo "[$(date '+%H:%M:%S')] hook=post-edit-lint file=$FILE"Now you have a per-day file with timestamps and the file_path Claude Code passed in. If the timeout is hitting in a specific spot, turn on set -x temporarily to see which command burns the budget.
In my case, an iOS-side SwiftLint hook was crossing 60 seconds and the culprit turned out to be xcodebuild -showBuildSettings running every single call. The settings don't change between runs, so I added five lines to cache the result in /tmp for the day, and the hook went from 70 seconds to 3. The root cause of timeouts is rarely "this command is heavy" — it's usually "this heavy command is running on every call."
The Hook Rules I'm Settled On Right Now
A short list of rules I keep coming back to. First: PostToolUse only runs things that finish in 30 seconds or less. Anything bigger goes into a Stop hook or a fully detached background job. Second: every hook narrows its target — read tool_input.file_path and bail out fast on the wrong extension. Third: prefer npx --no-install so the first call isn't dragged down by a dependency download. Fourth: log to daily files; the time you save on diagnosis pays back many times over. Fifth: builds and end-to-end tests don't belong in hooks at all — push them to scheduled jobs or CI so they never block the session.
Hooks are powerful, but a wrong design tanks the perceived speed of the whole tool. When command timed out shows up, my rule is to look at granularity before reaching for the timeout knob. Thanks for reading — I hope this saves someone the morning I lost staring at a wall of red.