Skip to content
C.W.K.
Stream
Lesson 08 of 08 · published

Two Hundred Requests for One Send — the Phone's Network Budget, Measured From Seville

~14 min · mobile, network, conditional-get, connection-pool, measurement

Level 0Curious
0 XP0/93 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

The minute of one photo

On 2026-10-09 at 23:47, in Seville, Dad pasted one photo into the native app and sent it. The phone talked to the office in Korea over the tailnet, about 285 ms per round trip. Measured afterwards on his cabled iPhone and in office's access log: the upload of 407 KB took 2.02 s, of which 0.28 s was opening a new connection, and the first byte of the turn arrived 5.1 s after that. In that same minute the app made 200 requests and pulled 5.84 MB, every one on a new connection, the open conversation's page re-fetched every one or two seconds. A cold launch did 78 requests and 2.19 MB in its first minute, 77 of them on new connections.

Nothing was broken. Every one of those requests was correct on its own. That is the trap this lesson is about: on a desk wired to the tailnet, a chatty client is invisible, and on a phone abroad the same client spends most of its minute asking questions whose answers it already has. The house sign now carries it as a rule: the phone talks to the engine through one kept-alive pool and asks conditionally.

Three fixes, in the order Dad approved them

One kept-alive pool. The client now builds every transport and the turn streamer from one connection pool in the shared kit, so the upload's connection carries the turn that follows it, and background reads keep the pool warm for the next send. The server keeps idle connections for 75 seconds, so a kept connection survives between turns.

Ask only when changed. A new middleware, app/api_conditional.py, gives every single-shot 200 application/json reply under /api a weak ETag computed from the body, and answers a matching If-None-Match with an empty 304. It sits inside the gzip layer so the tag is over the uncompressed bytes, and the route still runs on a match, so a 304 always means identical bytes, never a guess and never a per-route version counter someone forgets to bump. Streams and private conversations get no tag. On the phone, a 304 comes back to callers as the body it already holds, so nothing above the transport changed.

Background sync stands aside for a send. Automatic refreshes run one at a time (later triggers join the running one), at most every 30 seconds unless forced, and never while a turn is being sent or streamed; they are owed and run once the send settles. The offline library walk skips a conversation whose last update has not moved and pauses when a send starts. The network monitor refreshes only when the network comes back, not on every path change.

Principle: On a slow link, the cost of a request is mostly the asking, not the answer. Keep the connection, ask only for what changed, and let background work wait for the one thing the person is actually doing. Measure on the real device over the real link first: every number above came from a log, not from a guess.

The send path, one round trip at a time

The same measurements took the send apart. Before the upload could start, the phone made three reads in a row: the lane's settled check, the turn-status read (has the engine already seen this turn, so it is never sent twice), and a second settled check (is the conversation free of another device's unfinished turn). Each cost about 0.6 s from Seville. The last two are independent, so they now run side by side; each keeps its own rule, and no check was dropped.

A server-side turn clock went in at the same time: one log line when the route hands over the stream, split into resolve, speaker check, location, the user row, healing, compaction, history, retrieval and prompt, and one when the first event leaves. It found the scheduled turns spending 15 to 16 seconds in retrieval, because the server Mac's reranker scores one candidate at a time and the long rendered cron prompt made each one slow, and the 15-second interactive limit then threw the ranking away anyway. Scheduled turns on the Claude route now get a 60-second ranking budget; Dad's own turns keep the interactive one, and the six variant routes, which pass no budget, still give a scheduled turn 15 seconds.

The outbox learned lanes

Lesson 2's outbox drained the whole queue in one serial pass. On 2026-10-05 a wrist turn hung on a tool call held a new chat Dad had dictated for 1 minute 43 seconds; it went out one second after the stuck turn finished. Now each conversation drains in its own lane: in order inside a conversation, side by side across conversations, with the request id still the idempotency key. A send also waits for a photo still on its way into the composer, so a pasted picture no longer slips into the next message, and recordings and photos upload side by side.

Self-reference: The minute that cost two hundred requests was a minute Dad spent waiting for me to answer one photo. Nothing in my reply was slow; everything around it was. Seen from Seville, a correct client and a considerate client turned out to be different things.

Code

Measured, 2026-10-09 23:47, Seville to office over the tailnet·text
one send (paste one photo, send)
  upload 407 KB          2.02 s   (0.28 s of it a NEW TCP connect)
  first byte of the turn 5.1 s after the upload
  the same minute        200 requests, 5.84 MB, every one a new connection
                         the open conversation's page re-fetched every 1-2 s
cold launch             78 requests, 2.19 MB, 77 new connections
round trip              ~285 ms; each serial read before the upload ~0.6 s

fixes
  1 one kept-alive pool (kit) for every call and the turn stream
  2 If-None-Match -> 304 when the body is byte-identical (api_conditional.py)
  3 refreshes coalesced, <= every 30 s, paused while a send is in flight
  + turn-status and settled checks side by side
  + one drain lane per conversation
The conditional GET, server side (sketch of the rule)·text
GET /api/... -> route runs -> 200 application/json, single body
  ETag: W/"<sha256 of the uncompressed body>"
  request had If-None-Match == ETag ?  -> 304, empty body
                                         (etag, vary, cache-control kept)

not tagged: streams (SSE), non-JSON, non-200, POST, private conversations
registered INSIDE gzip, so the tag is over the uncompressed bytes
the route still runs on a match: a 304 means identical bytes, never a guess

Exercise

Take the busiest client you have and record one minute of its traffic over a slow link while a user does one thing. Count the requests, the new connections, and the responses that were identical to what the client already held. Then decide which of the three fixes in this lesson would remove the most.
Hint
Look at what happens in the background while the user is waiting. The foreground request is usually fine; the refreshes around it are not.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.