Skip to content

perf(viewer): lite snapshot + on-demand content + instant last-visit restore (10.2s → 1.6s cold @10Mbps)#254

Merged
ivanmkc merged 2 commits into
masterfrom
perf/viewer-load
Jul 10, 2026
Merged

perf(viewer): lite snapshot + on-demand content + instant last-visit restore (10.2s → 1.6s cold @10Mbps)#254
ivanmkc merged 2 commits into
masterfrom
perf/viewer-load

Conversation

@ivanmkc

@ivanmkc ivanmkc commented Jul 10, 2026

Copy link
Copy Markdown
Owner

The problem (measured, not guessed)

Opening the viewer reloaded everything from scratch, every time: the SSE snapshot event serialized the full content of every board into one uncompressed frame — 10.9 MB for the real 216-board workspace — re-sent on every page load and every reconnect, with warm loads paying exactly what cold loads pay (no cache, no diff, no compression). On a 10 Mbps connection that's ~10 s before the deep-linked board can even start rendering.

Baseline (deployed workspace, real boards; then A/B on an identical seeded 216-board/8.2 MB workspace):

cold first-board render unthrottled 30 Mbps/40ms 10 Mbps/80ms
before 2.5 s 5.0 s 10.2 s
after 0.65 s 1.0 s 1.6 s (0.8 s warm)

Snapshot: 8.9 MB → 0.03 MB (~300×). Total cold wire: 15.4 MB → 2.3 MB.

What changed

  1. Lite snapshot (/events?lite=1) — the snapshot now carries board metadata only ({project, agent, type, description, ts, bytes}); content ships on demand per board through the existing scoped GET /state, fetched on select/deep-link (coalesced, newer-ts wins). Old clients / the share surface keep the full-snapshot path untouched.
  2. JSON compressionsendJson() gzips/brotlis negotiated JSON ≥1 KB; a single board's /state can be ~1 MB raw and compresses 4–8× (base64-heavy boards especially).
  3. Instant last-visit restore — the catalog + last rendered board persist to localStorage (≤1.5 MB, debounced); the next visit paints before any network happens, then the snapshot reconciles — and fix(viewer): skip #diagram re-render on identical live updates (scroll-reset bug) #251's identical-content skip makes a clean reconcile zero-flicker, zero-scroll-reset. Offline probe: server killed, reload → the last board still paints in ~0.6 s.
  4. Batched catalog handling — one sidebar render per snapshot instead of one per board (was 216), deletion sweep, stale-content drop on newer ts.

Verification

  • e2e: 190/190 (scroll, focus, sidebar, keyboard, mobile, rich, archive, search, theme, i18n…) against the new architecture.
  • Unit: 647/647 incl. 2 new (lite snapshot shape; /state gzip round-trip).
  • Offline instant-restore probe (above).
  • Measurement harness: Playwright + CDP (per-chunk SSE byte accounting — loadingFinished misses open streams; network throttled via Network.emulateNetworkConditions).

Notes / follow-ups

  • The SSE stream itself is still uncompressed — after lite mode it's ~30 KB at connect, so the remaining heavy case is only live-watching an agent push MB-scale boards. Gzipping the stream (per-event Z_SYNC_FLUSH) is a clean follow-up if that ever matters.
  • ~2 MB of the remaining cold wire is renderer chunks (immutable, disk-cached warm) + board-embedded images (author content).
  • Needs a viewer deploy to reach the Cloud Run instance once merged.

…visit restore

The load path shipped the ENTIRE workspace on every page load: the SSE snapshot
serialized all boards' full content into one uncompressed event — measured
10.9 MB for a 216-board workspace, re-sent on every load and reconnect, with
warm loads paying the same as cold (no cache, no diff). At 10 Mbps that is
~10 s before the deep-linked board can render.

- server: /events?lite=1 sends metadata only ({...meta, bytes}); content ships
  per board via the existing scoped GET /state. Clients without the flag keep
  the full snapshot (old clients, share surface).
- server: sendJson() gzips/brotlis JSON bodies >=1KB when negotiated — a
  single board's /state can be ~1 MB raw and compresses 4-8x.
- client: snapshot handling is a batched catalog merge (one sidebar render,
  not one per board), content is fetched on select/deep-link and coalesced,
  a newer ts drops stale cached content, deletions are swept.
- client: last-visit cache in localStorage (catalog + last rendered board,
  <=1.5 MB, debounced) paints the previous screen BEFORE any network — the
  identical-content render skip (#251) makes reconciliation flicker-free.

A/B, same 216-board/8.2MB seeded workspace, cold first-board render:
             unthrottled   30 Mbps/40ms   10 Mbps/80ms
  before         2.5 s         5.0 s         10.2 s
  after          0.7 s         1.0 s          1.6 s   (warm 0.8 s)
  snapshot     8.9 MB  ->    0.03 MB  (~300x)
  total wire  15.4 MB  ->    2.3 MB

Offline probe: with the server killed, a reload still paints the last board
from the visit cache in ~0.6 s. e2e 190/190, unit 647/647 (+2 new).
…n-order first

The snapshot array is hydration order (oldest first), so a reload without a
deep-link auto-selected a stale, alphabetical-ish first board (the 'judo pane').
Default selection is now the most-recently-updated board; auto-selections no
longer claim user intent (select() sets `wanted`), so the persisted --focus
replay (#241) still overrides the default while an explicit hash stays king.
@ivanmkc ivanmkc force-pushed the perf/viewer-load branch from 315d6d6 to 35e07e5 Compare July 10, 2026 06:42
@ivanmkc ivanmkc marked this pull request as ready for review July 10, 2026 06:46
@ivanmkc ivanmkc merged commit f76cd9f into master Jul 10, 2026
5 checks passed
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