Experiment 02 · Published 22 September 2026
What a check before every tool call costs
About 14 ms, in Go. 88 ms in Node against 6.5 ms for a bare Go start, across 265 tool calls; the full hook runs in 11 to 14 ms.
Answer: 88ms in Node, 6.5ms in Go. The hook is written in Go because of this.
Why it matters more than it looks
A PreToolUse hook runs before every single tool call, synchronously,
because it has to be able to block. Its startup cost is paid every time.
A single real session measured here made 265 tool calls. So the cost is not per-call milliseconds:
| Per call | Per session |
|---|---|
| 85ms | 22.5 seconds |
| 20ms | 5.3 seconds |
| 5ms | 1.3 seconds |
Twenty-two seconds of lag, spread out as a pause before every action. That is how a tool gets uninstalled.
Method
Four equivalent hooks, each doing the same job: read stdin, parse the event,
check whether the tool input touches a forbidden path, exit 2 with a reason if
so. 40 runs each after a warm-up, median and p95 reported. Code in
code/latency/.
Results
| Runtime | median | p95 | per 265 calls |
|---|---|---|---|
| Node | 88.0ms | 135.6ms | 23.3s |
| Python 3 | 44.6ms | 87.8ms | 11.8s |
| Go client → warm Node daemon | 8.7ms | 15.7ms | 2.3s |
| Go, compiled | 6.5ms | 7.3ms | 1.7s |
| C, compiled | 4.5ms | 6.4ms | 1.2s |
All four block correctly. The difference is entirely process startup.
Why Go
13.6x faster than Node, and far more predictable. The p95 matters more than the median, because lag is felt at the worst case:
- Node: median 88ms, p95 136ms — a 48ms spread
- Go: median 6.5ms, p95 7.3ms — a 0.8ms spread
Node's worst case is 19x Go's. Consistency is worth as much as speed here.
It ships as one file with nothing to install. All five targets cross-compile from a single machine, around 2MB each: darwin/arm64, darwin/amd64, linux/amd64, linux/arm64, windows/amd64. No runtime for the user to install and no version to get wrong.
Why not the alternatives
C was fastest and rejected anyway. Saving 2ms over Go costs manual memory management and hand-rolled JSON parsing, in code whose entire job is parsing untrusted JSON on a security-sensitive path. Wrong trade.
A thin client talking to a warm daemon was the interesting option, because it would keep all logic in one language. At 8.7ms it is fast enough. Rejected because:
- p95 is 15.7ms, more than double its own median. The socket round trip adds variance exactly where it hurts.
- It introduces a class of bug that otherwise does not exist: is the daemon running, did it crash, is the socket stale, who restarts it, what about Windows named pipes.
- When the daemon is down the client must fail open. Codex hooks already fail open on crash. Stacking a second fail-open path into a safety tool is the wrong direction.
Consequence
Two languages, deliberately, because there are two performance regimes:
| Component | Language | Budget |
|---|---|---|
| Hook binary and deterministic checks | Go | under 10ms, every tool call |
| CI eval gate | TypeScript | seconds are fine, runs once per merge |
They are different programs. What is shared between them is design — baselines, tolerances, safety metrics having no tolerance — not code.
Follow-up: what the real hook actually costs
The measurements above compare bare runtimes doing trivial work. The shipped hook does considerably more on every call: load configuration, read project rules, read the session transcript, consult per-turn state, run five checks, and write two files.
Measured on the real binary, five checks enabled:
| median | p95 | per 265-call session | |
|---|---|---|---|
| trackline hook, all checks | 11.5ms | 13.4ms | 3.1s |
| the same logic in Node would be | ~90ms+ | — | ~24s |
The 10ms target in this document was derived from a trivial hook and the real one does not meet it. Recording that rather than moving the goalposts: 11.5ms is the number, and it is still 8x better than the runtime it replaced.
One design change the measurement forced
The first implementation of the checks that need memory — "how many files has this request touched", "has this action been tried before" — read the session recording back on every call. That made latency grow with the session:
| session length | latency |
|---|---|
| 0 events | 10.8ms |
| 150 events | 12.6ms |
| 300 events | 15.1ms |
| 600 events | 18.7ms |
Every call paid for the whole session, so the total cost was O(n²) and the tool got slower the longer someone worked. That is the wrong shape for something that runs before every action, and it would have been invisible in a short test.
The fix was to stop reading the recording on the hot path. A small state file holds only the current turn and resets when the request changes, so reads are bounded by how much an agent does in one request rather than by the length of the session. The full recording is still written and is still what replay reads; nothing time-critical touches it.
| session length | after |
|---|---|
| 0 events | 10.8ms |
| 150 events | 11.2ms |
| 300 events | 11.7ms |
| 600 events | 12.2ms |
A regression test asserts the state file stays small no matter how long the session runs.
The code and raw data behind this result are in the repository: docs/experiments.