Split The code is public; the app answers only to the crew.

Every refresh re-sends the whole first paint: no ETag on `/`, so `no-cache` means re-download instead of 304 #219

open told opened this
opened this

Give the first paint an entity tag so a revalidation costs a 304 and a few hundred bytes instead of the whole page.

Measured against main at 6cb6517, booted locally:

$ curl -sI http://localhost:8137/
cache-control: private, no-cache
vary: accept-encoding
content-length: 21320          # 6841 with accept-encoding: gzip
(no etag, no last-modified)

private, no-cache is right - #126 put it there, and the page carries a name, a guest label and a session cookie, so no shared cache may keep it. But no-cache does not mean do not store, it means revalidate before reuse, and a revalidation with nothing to validate against is a full 200 every time: 6.8 KB of gzip on every refresh, every alt-tab back, every phone waking up, when the page has not changed. The server already answers in 1.6-7 ms, so this is bytes on a phone's connection rather than server time - which is exactly the kind of thing the README claims the app cares about.

encoded (src/server.ts:54) is the single place a body is chosen and is where the tag belongs: it already knows the bytes it is about to send and whether it gzipped them.

Done looks like

  • [ ] GET / carries a strong etag derived from the bytes actually sent (Bun.hash over the identity bytes is enough - name it, do not reach for a crypto hash). Two visits that render the same page produce the same tag; a different player name, a different guest label or a different commit stamp produces a different one.
  • [ ] A request with a matching if-none-match gets 304 with no body, and with the same cache-control, vary, etag and security headers the 200 carries. content-length is absent or 0, never the 200's length.
  • [ ] The tag accounts for the encoding, so a gzip and an identity answer never share a tag and a proxy cannot hand one visitor the other's bytes. vary: accept-encoding stays.
  • [ ] A visitor who is about to be given a session cookie is never answered 304: a first visit must set set-cookie, so the tag check comes after that decision, not before. A test asserts a cookieless request with any if-none-match still gets 200 plus set-cookie.
  • [ ] HEAD / is unchanged: headers of the 200, no body, as encoded already does.
  • [ ] if-none-match: * and a malformed or multi-valued header are handled at the boundary rather than trusted - a list is a list, and W/ prefixes compare weakly.
  • [ ] The immutable bundle route is untouched: a year of max-age with immutable is never revalidated, so it needs nothing.
  • [ ] Tests in src/server.test.ts: 200 carries an etag; that etag echoed back gives 304; a changed page gives a different etag and a 200; gzip and identity tags differ; the cookie case above.
  • [ ] bun run check green. Quote the before/after byte count of a refresh in the pull.

Files I expect to change

  • src/server.ts, src/server.test.ts.
  • README.md - one line where it describes the first paint's caching.

Risks

  • A 304 that drops vary or the security headers is a cache-poisoning shape; the floor belongs on every response, 304 included.
  • Hashing per request is only free because the page is 21 KB; hash the bytes once inside encoded and do not hash twice for the two encodings.
  • Do not soften cache-control: the page is per-visitor, and a shared cache holding it is the fault #126 fixed.

Crew

scout - the server's boundary and the tests that hold it.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs