Experiment 08 · Published 24 September 2026
Can the hook upload without getting slower?
Unchanged when not connected, about 1 ms when connected. Three hooks fed the same tool call, interleaved over 200 rounds. The hook never touches the network: a separate process sends.
Yes, by never uploading from the hook. On a machine that is not connected the hook is unchanged. On a connected one it costs about 1ms more, for writing one small file. The network is someone else's problem: a separate process sends, in batches, after the hook has already answered.
Connecting a machine to a trackline account (trackline connect) sends what
the checks conclude to a dashboard. The hook runs before every tool call, and
experiment 2 is why it is written in Go at all. An upload
that made it slower would be a bad trade.
The design
The hook writes each event to an outbox on disk: one file per event, written
under a temporary name and renamed into place, so parallel tool calls never
interleave and a half-written event is never read. If nobody is sending, it
starts trackline sync in the background, with its output going nowhere,
because a host waits for the hook's stdout and stderr to close. Then it
answers.
trackline sync sends the outbox oldest first, and deletes a file only once
the server has accepted it. Every event carries an id the server deduplicates
on, so a sync killed at any point loses nothing and doubles nothing. Offline,
events wait and the next attempt backs off (30 seconds, doubling, at most 15
minutes), so the hook does not start a doomed sender on every call.
Method
The same Write tool call, fed to three hooks, interleaved so that load on the
machine falls on all three alike: the hook from before this change, the new
hook on a machine that is not connected, and the new hook inside a connected
project, sending to a local server. 10 warm-up runs each, then 200 rounds.
Code in code/upload-latency/.
Two things the first measurement found
Linking network code into the hook cost 3.4ms on every start, before it
did anything. The first version reached the account through one package that
held both the saved credential and the HTTP client. The hook only reads the
credential, but Go initialises everything linked in. Splitting the two, so the
hook links no network code at all, took the unconnected cost back to noise. A
test now fails if net or crypto/tls ever reaches the hook's imports.
| First version | median | p95 |
|---|---|---|
| before | 11.2ms | 13.5ms |
| not connected | 14.6ms | 16.9ms |
| connected | 16.9ms | 19.1ms |
210 events went up in 120 requests. The sender waits two seconds before sending, so that a burst of tool calls travels as one batch. It took its lock after the wait, not before, so every hook during those two seconds saw nobody sending and started another sender. With the lock held through the wait, the same 210 events went up in 8 requests.
Result
After both fixes, three runs. The machine was under heavy load throughout (load average 14 to 17), which moves every number; the interleaving is what keeps the comparison fair.
| Run | before | not connected | connected |
|---|---|---|---|
| 1 | 16.45ms | 16.90ms | 17.55ms |
| 2 | 22.13ms | 22.29ms | 23.14ms |
| 3 | 28.46ms | 28.63ms | 29.59ms |
Medians. Not connected is within 0.5ms of before in every run, and the p95s cross over. Connected is 0.7 to 1.1ms slower in every run: the outbox write. That is the cost, and only a machine that chose to connect pays it.
In the same runs every event arrived: 210 queued, 210 stored, none left in the outbox.
What else was checked
Against a local copy of the backend, through the real CLI and hook:
- Paths arrive relative to the project.
/etc/passwdand~/.ssh/id_rsa, read by a command in the same session, were dropped before leaving. - No file content, command text or token reached the database. The stored rows
were searched for the content written, the key in
.envand the home directory; none were there. - Times first went up cut to the second, and four calls in one second came back from the database in reverse. They now carry milliseconds.
The outbox is tested for going offline and coming back (all events, in order, once), a sender killed after the server stored a batch but before it heard so (nothing lost, nothing doubled), a sender that died holding the lock, a revoked machine, and a batch the server refuses. Each test was checked by breaking the code it guards and watching it fail. One of those breaks, a sender that never removed what it sent, looped until the machine ran out of disk; the test's stand-in server now stops after 5,000 requests.
What this does not show
Real sessions in Claude Code, Codex and Cursor uploading to a deployed backend. That is the exit test for this phase, and it has not been run yet.
The code and raw data behind this result are in the repository: docs/experiments.