Skip to content

Answer unchanged conversations with 304, pause chat polls while hidden - #17

Merged
devswha merged 2 commits into
devswha:mainfrom
Yoonwoo-Ha:perf/chat-poll-etag
Sep 24, 2026
Merged

devswha merged 2 commits into
devswha:mainfrom
Yoonwoo-Ha:perf/chat-poll-etag

Conversation

@Yoonwoo-Ha

@Yoonwoo-Ha Yoonwoo-Ha commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The chat polls its conversation every 2s, and each answer can be megabytes: 1.6MB per poll measured on a working agent. On a phone that spends data and battery on polls that bring nothing new.

  • 304 when unchanged. Each conversation answer carries an ETag built from the page asked for, the transcript file's state (dev, inode, size, mtime) and the server process, so a restart never matches an old ETag. The chat sends the ETag back, and an unchanged conversation answers 304 with no body. The chat then keeps the answer it already shows and skips comparing and laying it out again. Cache-Control: no-store keeps the browser's own cache out of the way so the chat sees the 304. The PC proxy carries the ETag both ways and passes a 304 on without a body.
  • Pause while hidden. The conversation and prompt polls stop while the page is hidden: another tab, a minimised window, or a phone app in the background. They poll again at once when the page is back. chatmux does the same.

Rebased onto main now that #5 is merged: the ETag covers #5's pages (before/since/from). #6 and #18 touch ChatView.tsx and conversation.ts nearby, so whichever merges later needs a small rebase.

Measured (live server with #5 applied, polling every 2s for 20s; without #5 a full answer is larger, the 304s are the same 0 bytes)

before after
bytes, working agent's chat ~16.5MB 1.65MB (first answer, then 0-byte 304s)
server CPU 2.6% of a core ~1%
while hidden polls continue 0 requests

Testing

  • bun run typecheck passes and bun test passes 271/271 after the rebase.
  • New tests:
    • a contract test on real herdr: a 200 with an ETag, then a bodyless 304 while unchanged, then a 200 with a new ETag after the file changes
    • a PC-proxy test against a stub remote: the ETag goes both ways and the 304 comes through bodyless
    • a client test: If-None-Match is sent, and a 304 returns the very same object
  • Real server plus headless Chrome on a Codex rollout (re-run after the rebase):
    • unchanged polls are 304/0B
    • an appended answer shows on the next poll
    • a hidden page makes 0 requests in 7s
    • back on screen, the first poll goes out within about 0.2s

🤖 Generated with Claude Code

@Yoonwoo-Ha
Yoonwoo-Ha force-pushed the perf/chat-poll-etag branch 2 times, most recently from 50975e5 to e87cf39 Compare September 24, 2026 10:50

@devswha devswha left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. The 304 path and the pause while hidden are good ideas. #18 is now merged, so please rebase onto it. #17 must not land before #18: without #18's chain fingerprint, the ETag doesn't change when the earlier-rollout chain does, so the chat would stay on the old answer. On top of #18 the ETag follows the chain. Beyond the rebase:

  1. The chat can freeze on an unfilled gap. In ChatView.tsx ~295, lastAnswer.current = conversation is set before the gap fill (await turnsBetween) and before setState. Scenario: older pages are shown and the newest page has moved past the held start, so a gap fill is running. The tab goes hidden, and the read is cancelled. Back on screen, the same from= request gets a 304 and the same object, so the early return fires. The gap is never filled, heldFrom never moves, and new turns never appear until the file changes, which may be never if the agent has finished. Please set lastAnswer only at the end of the success path, or clear it when the read is cancelled.
  2. before pages fill the client cache. Pages fetched with before are never requested again, but each takes a slot in conversationAnswers (api.ts ~91–110), and a 304 doesn't move the polled entry up. Once 16 slots fill, the newest-page entry is the first to go, which costs a multi-MB reload while the dead pages stay in memory. Please only send and store ETags when before is not set.

Yoonwoo-Ha and others added 2 commits September 24, 2026 20:49
…e hidden

The chat polls its conversation every 2s, and a newest page can be
megabytes (1.6MB measured on a working agent): on a phone that is data and
battery spent on nothing new.

- Each conversation answer carries an ETag naming the page asked for, the
  transcript file's state and the server process. The chat sends it back,
  and an unchanged conversation answers 304 with no body; the chat then keeps
  the very answer it shows and skips laying it out again. The PC proxy
  carries the ETag both ways and passes a 304 on bodyless.
- The conversation and prompt polls pause while the page is hidden (another
  tab, a minimized window, a phone app in the background) and poll at once
  when it is back, as chatmux does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…led pages only

Review follow-ups:

- The chat recorded the answer as shown before the gap fill ran. A read
  cancelled mid-way (the page hidden while the turns between the held start
  and the newest page were fetched) left it recorded. Back on screen, the
  same held-start request got a 304 and the same object, so the read
  returned early: the gap never filled and new turns never appeared.
  The answer is now recorded only once it is laid out in full. A browser
  check that hides the page during a slow gap fill froze before this
  change and fills the gap after it.
- Older pages (`before`) are asked for once, so they no longer send or keep
  an ETag. They used to take the cache's slots and push out the polled
  newest page, and a 304 did not move that entry up. A 304 now refreshes
  the polled entry, and only polled pages are kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Yoonwoo-Ha

Copy link
Copy Markdown
Contributor Author

Thanks. Rebased onto main with #18 (the ETag now follows the chain's stream id). Addressed in 1ca749f:

  1. Unfilled gap. lastAnswer is set only at the end of the success path, once the answer is laid out in full, so a read cancelled during the gap fill is redone on return. There is a browser check with older pages held, a slow gap fill, and the page hidden during it. It froze before this change (no new turns within 15s) and fills turns 50–169 in order after it.
  2. Cache. before pages no longer send or keep an ETag. A 304 now moves the polled entry up. The client test reads 40 older pages and polls 20 other panes in between, and the polled answer stays cached. It fails without the change.

bun test: 273 pass. verify-poll still sees bodyless 304s and pauses while hidden.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants