{"slug": "speeding-up-a-userpromptsubmit-hook-with-a-60-second-cache-designing-cost-guard", "title": "Speeding Up a UserPromptSubmit Hook with a 60-Second Cache: Designing cost_guard.sh", "summary": "A developer optimized their Claude Code UserPromptSubmit hook by adding a 60-second cache, reducing the cost-monitoring script's p95 latency from 5320ms to near-instant. The cache stores both warnings and empty results, ensuring the common no-warning path is the fastest. The developer emphasizes caching the normal case, not just exceptions, to avoid slow repeated executions.", "body_md": "In my previous post, \"[Writing hook scripts without stepping on macOS traps](https://zenn.dev/bokuwalily/articles/hook-script-macos-traps)\", I covered the basics of writing hooks. This time, the topic is speed.\n\nA UserPromptSubmit hook runs synchronously on every single prompt. If you call an external command inside the hook, the time you spend waiting for it becomes turn latency, one-for-one. When I measured my cost-monitoring hook, I got **mean 819ms / p95 5320ms / max 5753ms** (last 24h, 817 invocations). That works out to a worst case of more than five seconds of waiting every time I hit enter on a prompt.\n\nThe job of `~/.claude/hooks/cost_guard.sh`\n\nis to look at the output token count and burn rate of the active 5h block and warn when a threshold is exceeded. To do that, it was calling `ccusage blocks --active --json`\n\non every prompt.\n\nccusage is a Node.js CLI with a high startup cost, and depending on the situation it can keep you waiting for several seconds. Here are the actual numbers, measured and aggregated with `~/.claude/scripts/hook-latency-wrap.sh`\n\n.\n\n```\n=== hook latency (last 1d, 4086 records) ===\nhook                                 n    mean     p95     max  fail\n----------------------------------------------------------------------\ncost_guard.sh                      817    819ms   5320ms   5753ms     0 ⚠\nobsidian_context.sh                817    760ms   1386ms   4259ms     0\nmodel_routing_reminder.sh          817    298ms    549ms   2003ms     0\nmessage_display_filter.sh          817    212ms    435ms   1616ms     0\nuser_prompt_submit.sh              817    272ms    409ms  12295ms     0\n```\n\ncost_guard.sh's p95 of 5320ms towers over the other four. The situation doesn't change meaningfully within 60 seconds, so running the full thing on every prompt was clearly overkill.\n\nThe cache is self-contained in a single file, `/tmp/cost_guard_${USER}.cache`\n\n. Here's the top of `cost_guard.sh`\n\n.\n\n```\nCACHE=\"/tmp/cost_guard_${USER}.cache\"\nCACHE_AGE=60   # 秒\nif [ -f \"$CACHE\" ]; then\n  age=$(( $(date +%s) - $(stat -f %m \"$CACHE\" 2>/dev/null || echo 0) ))\n  [ \"$age\" -lt \"$CACHE_AGE\" ] && { cat \"$CACHE\" >&2 2>/dev/null; exit 0; }\nfi\n```\n\nIf the cache file exists and its mtime is less than 60 seconds old, dump its contents to stderr and immediately `exit 0`\n\n. The ccusage call itself is skipped. If the cache holds a warning string, it gets re-displayed; if the file is empty, nothing is printed and we just exit.\n\nThis is the heart of it. Look at the end of the script.\n\n```\n# 警告の有無に関わらず必ずキャッシュを更新する（空でも書く）。\n# これが無いと平常時（警告なし）はキャッシュ未作成のまま毎プロンプト ccusage フル実行になる。\n\n# ...\n\n# 警告の有無に関わらず必ずキャッシュを書く（空 WARN なら空ファイル＝「平常」を60秒キャッシュ）\nprintf '%b' \"$WARN\" > \"$CACHE\"\n[ -s \"$CACHE\" ] && cat \"$CACHE\" >&2\n\nexit 0\n```\n\nThe key point is that ** printf '%b' \"\" > \"$CACHE\" runs even when $WARN is empty**. The fact that there is no warning gets cached as an empty file.\n\nConsider what happens if you implement it as \"only write the cache when there's a warning.\" In the normal case (`$WARN`\n\nempty), no cache is created → the next prompt is also a cache miss → full ccusage run. **The most frequently taken path becomes the slowest one.**\n\nFor the same reason, when ccusage fails, we also write an empty file before exiting.\n\n```\nBLOCK_JSON=$(${TIMEOUT_BIN:+\"$TIMEOUT_BIN\" 5} \"$CCUSAGE\" blocks --active --json 2>/dev/null) || { : > \"$CACHE\"; exit 0; }\n[ -z \"$BLOCK_JSON\" ] && { : > \"$CACHE\"; exit 0; }\n```\n\n`|| { : > \"$CACHE\"; exit 0; }`\n\n— even on the failure path, write an empty file before exiting (fail-open). Without this, a retry fires on every failure.\n\nNote\n\n\"Only write the cache when there's a result\" feels intuitive, butan empty result — nothing wrong — is worth caching too. The point of a cache is to make the most common path the fastest, so you need the mindset of recording the normal case rather than recording the exceptions.\n\nThe reason p95 climbed to 5320ms was that ccusage sometimes kept us waiting without responding. `gtimeout 5`\n\ncaps that at 5 seconds.\n\n```\nTIMEOUT_BIN=$(command -v gtimeout 2>/dev/null || echo /opt/homebrew/bin/gtimeout)\n[ -x \"$TIMEOUT_BIN\" ] || TIMEOUT_BIN=\"\"\n\nBLOCK_JSON=$(${TIMEOUT_BIN:+\"$TIMEOUT_BIN\" 5} \"$CCUSAGE\" blocks --active --json 2>/dev/null) || { : > \"$CACHE\"; exit 0; }\n```\n\n`${TIMEOUT_BIN:+\"$TIMEOUT_BIN\" 5}`\n\nis parameter expansion. If `TIMEOUT_BIN`\n\nis non-empty it expands to `\"$TIMEOUT_BIN\" 5`\n\n; if empty, it expands to nothing. That switches between \"`gtimeout 5 ccusage ...`\n\n\" and \"`ccusage ...`\n\n\" with no conditional branch.\n\nOn a machine where `gtimeout`\n\nisn't installed via brew, `TIMEOUT_BIN=\"\"`\n\nand the call runs without a timeout. Hooks should be fail-open by default: a delayed cost warning is far less painful than Claude Code grinding to a halt.\n\n`printf '%b' \"$WARN\" > \"$CACHE\"`\n\nmust run unconditionally, even when `$WARN`\n\nis empty`{ : > \"$CACHE\"; exit 0; }`\n\n`stat -f %m`\n\nis macOS-specific`stat -c %Y`\n\n. If you use the hook on Linux too, you need a branch`/tmp/`\n\ndisappears after a reboot`/tmp/`\n\nis enough to fix it`gtimeout 5`\n\nto kill a hanging CLI at a 5-second ceiling. The `${TIMEOUT_BIN:+...}`\n\nexpansion gives you an optional prefix with no conditional branchNext time, I'll cover how Claude Code handles the warnings a hook writes to stderr — an overview of the whole hook protocol across UserPromptSubmit, PreToolUse, and PostToolUse.\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/speeding-up-a-userpromptsubmit-hook-with-a-60-second-cache-designing-cost-guard", "canonical_source": "https://dev.to/bokuwalily/speeding-up-a-userpromptsubmit-hook-with-a-60-second-cache-designing-costguardsh-1ffp", "published_at": "2026-08-01 00:00:05+00:00", "updated_at": "2026-08-01 00:12:04.424979+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Claude Code", "ccusage"], "alternates": {"html": "https://wpnews.pro/news/speeding-up-a-userpromptsubmit-hook-with-a-60-second-cache-designing-cost-guard", "markdown": "https://wpnews.pro/news/speeding-up-a-userpromptsubmit-hook-with-a-60-second-cache-designing-cost-guard.md", "text": "https://wpnews.pro/news/speeding-up-a-userpromptsubmit-hook-with-a-60-second-cache-designing-cost-guard.txt", "jsonld": "https://wpnews.pro/news/speeding-up-a-userpromptsubmit-hook-with-a-60-second-cache-designing-cost-guard.jsonld"}}