Skip to content

perf: faster fresh-client load — WS snapshot replay, map chunk preload, news feed SWR#327

Merged
d3mocide merged 4 commits into
devfrom
claude/confident-babbage-pd1k3z
Jun 13, 2026
Merged

perf: faster fresh-client load — WS snapshot replay, map chunk preload, news feed SWR#327
d3mocide merged 4 commits into
devfrom
claude/confident-babbage-pd1k3z

Conversation

@d3mocide

@d3mocide d3mocide commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Three independent fixes for "a fresh client takes a long time to load data," each addressing a different layer.


1. WebSocket snapshot replay (backend) — the data

A late joiner on /api/tracks/live received no backlog: the broadcast consumer reads Kafka from latest, so the map stayed empty until each poller re-emitted its next full sweep (the orbital sweep alone is a ~15–37 s cycle, ~11k satellites). The faster render just made this pre-existing gap obvious.

Fix: a last-value cache in BroadcastManager — every TAK frame is stored per uid and replayed to each newly-connected client before live streaming begins. Sent directly (not via the bounded 256-slot live queue, which would drop most of a multi-thousand-entity replay), yielding every 256 frames. Stale entries pruned via TTL (LIVE_SNAPSHOT_TTL_SECONDS, 300 s) + hard cap (LIVE_SNAPSHOT_MAX_ENTITIES, 20 000). Wire format unchanged → no frontend changes; the client's lastSourceTime guard de-dups any overlap.

Fresh-client data latency: "next poller sweep"one connect round-trip, served from memory.

2. Critical map-chunk preload (frontend) — the bundle

The default TACTICAL view (TacticalMap) needs deck-gl (~1 MB) and the GL engine (~1 MB MapLibre / ~1.7 MB Mapbox), but since the views are lazy-loaded and the entry no longer preloads vendors (f32c30d), a cold client only discovered those chunks after the entry + App parsed and the dynamic import fired — a multi-hop waterfall before first paint.

Fix: a build-only Vite plugin injects <link rel="modulepreload"> for deck-gl, the active engine, and the TacticalMap chunk, so they download in parallel with the entry. The engine is picked at build time to mirror mapStyles.ts (Mapbox if a valid VITE_MAPBOX_TOKEN is set, else MapLibre); the unused engine isn't preloaded. Cacheable vendor split otherwise unchanged. Verified in dist/index.html.

3. News feed: concurrent fetch + stale-while-revalidate (backend) — the text feeds

GET /api/news/feed (dashboard NewsWidget) fetched 5 RSS feeds sequentially (up to ~50 s worst case) and populated the cache lazily, so every 15-min expiry blocked the next caller on the full upstream fetch.

Fix: fetch all feeds concurrently (asyncio.gather) so latency is bounded by the slowest single feed; serve stale-while-revalidate — return cached data immediately and kick off a background refresh once past the freshness window, so callers never block. Refresh deduped within a worker (task ref) and across workers (Redis SET NX); data kept 6 h for stale serving. Only a truly cold cache fetches synchronously (now concurrent).


Changes

  • backend/api/services/broadcast.py, backend/api/core/config.py — LVC + snapshot replay.
  • frontend/vite.config.tsmapCriticalPreloadPlugin.
  • backend/api/routers/news.py — concurrent fetch + SWR.
  • Tests: tests/test_broadcast_snapshot.py (new), tests/test_news_router.py (extended).
  • Task logs under agent_docs/tasks/2026-06-13-*.

Verification

  • Backend (backend/api): ruff clean; full pytest172 passed.
  • Frontend (frontend): typecheck + lint clean; test278 passed; production build injects modulepreload for deck-gl + maplibre + TacticalMap (mapbox correctly excluded with no token).

Notes

  • No news poller pre-warms the cache, so the first request after a backend restart still fetches synchronously (now concurrent, ~1–2 s). A startup warm in the lifespan could remove even that; left out to avoid scope creep — happy to add if wanted.

https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7

claude added 2 commits June 13, 2026 05:30
A late joiner on /api/tracks/live received no backlog — the broadcast
consumer reads Kafka from "latest", so the map stayed empty until each
poller re-emitted its next full sweep (the orbital sweep alone is a
~15-37s cycle for ~11k satellites). The recent near-instant render made
this pre-existing data-load gap glaringly obvious.

Add a last-value cache to BroadcastManager: every transformed TAK frame
is stored per uid (with a monotonic receive time) and replayed to each
newly connected client before live streaming begins. The snapshot is
sent directly (not through the bounded 256-slot live queue, which would
drop most of a multi-thousand-entity replay), yielding to the event loop
every 256 frames so it never starves the consume loop or other clients.

Stale entries (not re-emitted within LIVE_SNAPSHOT_TTL_SECONDS, default
300s) are excluded and periodically pruned; LIVE_SNAPSHOT_MAX_ENTITIES
(default 20000) caps memory. Wire format is unchanged (one frame per
entity), so the frontend needs no changes — the client's lastSourceTime
guard harmlessly de-dups any snapshot/live overlap.

Fresh-client data latency drops from "next poller sweep" to one connect
round-trip, served entirely from memory.

Verification (backend/api, host):
- ruff check (changed files): passed
- pytest tests/test_broadcast_snapshot.py: 9 passed
- pytest (full API suite): 167 passed

https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7
…d with stale-while-revalidate

Two more fresh-client load wins, follow-ups to the WS snapshot work.

Map asset waterfall:
- The default TACTICAL view (TacticalMap) needs the deck-gl (~1MB) and GL
  engine (~1MB MapLibre / ~1.7MB Mapbox) vendor chunks, but since views are
  lazy and the entry no longer preloads vendors, a cold client only discovered
  them after the entry+App parsed and the dynamic import fired — a multi-hop
  waterfall before first paint.
- Add a build-only Vite plugin that injects <link rel="modulepreload"> for
  deck-gl, the active engine, and the TacticalMap chunk, so they download in
  parallel with the entry. Engine is chosen at build time to mirror
  mapStyles.ts (Mapbox when a valid token is set, else MapLibre); the unused
  engine is not preloaded. Cacheable vendor split is otherwise unchanged.

News feed (dashboard text feeds):
- GET /api/news/feed fetched 5 RSS feeds sequentially (up to ~50s worst case)
  and populated the cache lazily, so every 15-min expiry blocked the next
  caller on the full upstream fetch.
- Fetch all feeds concurrently (gather) so latency is bounded by the slowest
  single feed. Serve stale-while-revalidate: return the cached payload
  immediately and kick off a background refresh once it's past the freshness
  window, so callers never block. Refresh is deduped within a worker (task ref)
  and across workers (Redis SET NX lock); data kept 6h for stale serving. Only
  a cold cache fetches synchronously.

Verification:
- frontend: typecheck, lint, test (278 passed); production build injects
  modulepreload links for deck-gl + maplibre + TacticalMap (mapbox correctly
  excluded with no token).
- backend/api: ruff (changed files) clean; full pytest suite 172 passed (+5).

https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7
@d3mocide d3mocide changed the title perf(backend): replay last-value snapshot to fresh WebSocket clients perf: faster fresh-client load — WS snapshot replay, map chunk preload, news feed SWR Jun 13, 2026
claude added 2 commits June 13, 2026 08:46
…d never blocks

Add news.warm_cache() and call it from the API lifespan after the broadcast
service starts. It delegates to the same deduped background refresh used by the
stale-while-revalidate path, so it returns immediately and populates the Redis
feed cache out-of-band. This removes the one remaining blocking case — the
first /api/news/feed request after a backend restart (cold cache) — so a fresh
dashboard load gets instant cached text instead of waiting on the upstream RSS
fetch.

Verification (backend/api, host): ruff clean; pytest 173 passed (+1).

https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7
Promote the one-shot startup warm into a supervised pre-warm loop
(news.prewarm_loop) launched from the API lifespan. It refreshes the feed
cache on startup and then every NEWS_PREWARM_INTERVAL (default 600s, inside the
900s freshness window), so the cache stays warm even when no client is polling
the feed — a fresh dashboard always hits a warm cache instead of blocking on
the upstream RSS fetch. Per-cycle errors are logged and the loop continues;
the task is cancelled cleanly on shutdown alongside the historian/RF-cleanup
tasks. Reuses the deduped refresh path (within-worker task ref + cross-worker
Redis NX lock), so multiple workers still trigger at most one fetch per cycle.

Verification (backend/api, host): ruff clean; pytest 175 passed (+2).

https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7
@d3mocide d3mocide marked this pull request as ready for review June 13, 2026 16:08
@d3mocide d3mocide changed the base branch from main to dev June 13, 2026 16:08
@d3mocide d3mocide merged commit 2342199 into dev Jun 13, 2026
10 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