fix(avatar): revalidate /avatars with ETag so changes appear promptly (LC-348) #366

Merged
longjacksonle merged 1 commit from fix/lc-348-avatar-etag-revalidation into main 2026-06-19 04:38:14 +02:00

Fixes LC-348. Follow-up to LC-347.

Problem

After an avatar upload the new image appeared immediately on the settings page (LC-347 cache-busts that preview with ?v={mtime}), but everywhere else - chat author rows, sidebar, hovercards, voice grid - it kept showing the old image (or the previously-cached default "L" initial) for up to ~5 minutes. Those surfaces render the bare /avatars/{id} URL, and routes::avatar::get_avatar served Cache-Control: public, max-age=300, so the browser held the stale copy for the whole TTL.

Fix

get_avatar now serves Cache-Control: no-cache plus an ETag, and answers a matching If-None-Match with 304:

  • Stored avatar: ETag = extension + byte length + mtime (moves on any re-upload).
  • Default SVG: content-hash ETag, prefixed d so it can never collide with a file ETag; switching from default to real avatar always busts.

A changed avatar is delivered on the next request across every surface; unchanged ones cost only a tiny 304. One route change, no per-template version threading.

Tests

New server/tests/routes_avatar_cache.rs:

  • default_avatar_revalidates_with_etag - default SVG carries a d-prefixed ETag + no-cache; matching If-None-Match 304s.
  • uploaded_avatar_serves_etag_then_304 - uploaded avatar serves a non-default ETag with no-cache; matching tag 304s; a stale tag returns 200 with the fresh image.

just test and just test-saas both green.

Out of scope

Truly refresh-free updates (re-rendering other viewers' message rows over the WS on UserProfileChanged) remain the separate LC-173 cross-surface follow-up. This issue covers fresh-on-next-request correctness.

Fixes LC-348. Follow-up to LC-347. ## Problem After an avatar upload the new image appeared immediately on the settings page (LC-347 cache-busts that preview with `?v={mtime}`), but everywhere else - chat author rows, sidebar, hovercards, voice grid - it kept showing the old image (or the previously-cached default "L" initial) for up to ~5 minutes. Those surfaces render the bare `/avatars/{id}` URL, and `routes::avatar::get_avatar` served `Cache-Control: public, max-age=300`, so the browser held the stale copy for the whole TTL. ## Fix `get_avatar` now serves `Cache-Control: no-cache` plus an `ETag`, and answers a matching `If-None-Match` with `304`: - Stored avatar: ETag = extension + byte length + mtime (moves on any re-upload). - Default SVG: content-hash ETag, prefixed `d` so it can never collide with a file ETag; switching from default to real avatar always busts. A changed avatar is delivered on the next request across every surface; unchanged ones cost only a tiny `304`. One route change, no per-template version threading. ## Tests New `server/tests/routes_avatar_cache.rs`: - `default_avatar_revalidates_with_etag` - default SVG carries a `d`-prefixed ETag + `no-cache`; matching `If-None-Match` 304s. - `uploaded_avatar_serves_etag_then_304` - uploaded avatar serves a non-default ETag with `no-cache`; matching tag 304s; a stale tag returns 200 with the fresh image. `just test` and `just test-saas` both green. ## Out of scope Truly refresh-free updates (re-rendering other viewers' message rows over the WS on `UserProfileChanged`) remain the separate LC-173 cross-surface follow-up. This issue covers fresh-on-next-request correctness.
fix(avatar): revalidate /avatars with ETag so changes appear promptly (LC-348)
All checks were successful
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 5s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 4m55s
bdc8977ec4
Follow-up to LC-347. After an avatar upload the new image showed immediately on the settings page (cache-busted with ?v=mtime), but everywhere else - chat author rows, sidebar, hovercards, voice grid - it kept showing the old image (or the previously-cached default initial) for up to five minutes. Those surfaces all render the bare `/avatars/{id}` URL, and the route served `Cache-Control: public, max-age=300`, so the browser held the stale copy for the whole TTL.

`get_avatar` now serves `Cache-Control: no-cache` plus an `ETag` and answers a matching `If-None-Match` with `304`. The ETag is the file's extension + length + mtime for a stored avatar, and a content hash (prefixed `d`) for the generated default SVG, so it moves whenever the image changes and the switch from default to real avatar always busts. A changed avatar is therefore delivered on the next request across every surface; unchanged ones cost only a tiny `304`.

Adds `routes_avatar_cache.rs`: the default SVG revalidates (304 on matching tag), an uploaded avatar serves a non-default ETag with `no-cache`, a matching `If-None-Match` 304s, and a stale tag returns 200 with the fresh image.

Truly refresh-free updates (re-rendering other viewers' message rows over the WS on UserProfileChanged) remain the separate LC-173 cross-surface follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch fix/lc-348-avatar-etag-revalidation 2026-06-19 04:38:15 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!366
No description provided.