Reduce eager initial-load payload and repeated response computation #8

Open
opened 2026-08-23 22:37:47 +02:00 by kristofferopsahl · 2 comments
Collaborator

Finding

The initial page load eagerly fetches and parses the complete player projection graph before the UI becomes ready:

  • frontend/app.js:302-360 blocks startup on /api/players and /api/status, then starts matches, accuracy, live refresh, squad/rules, fixtures, market, overrides, and teams.
  • backend/web/main.py:305-368 rebuilds a large derived dictionary for every /api/players request, including per-gameweek rows, six-week intervals/SD, components, ownership fields, and differential scores for every player.
  • The source comment in backend/web/main.py records /api/players at 1,562,646 bytes uncompressed. Gzip reduces transfer size, but not server serialization, browser decompression, JSON parsing, allocation, or retaining the entire object graph.
  • refreshLiveFast(null, true) also initiates a live upstream FPL fetch on every page load, even when the server-side live data is within its documented 900-second TTL.

Suggested direction

  • Measure server timing, compressed transfer, parse time, retained heap, and first-use latency before choosing changes.
  • Cache the fully shaped player response by projection/live-ownership version instead of recomputing it per request.
  • Split summary fields needed for initial squad/player tables from detail-only per-gameweek/components data, loaded on demand or by tab.
  • Make /api/refresh-live honor freshness server-side so repeated clients do not stampede the upstream API.
  • Preserve gzip and functional output; this issue is about computation and loading strategy, not removing data.

Acceptance criteria

  • Benchmarks cover cold/warm /api/players, payload/parse cost, and initial readiness.
  • Unchanged cache/live inputs do not trigger repeated response shaping or upstream refreshes.
  • Detail views retain all current information while initial-load CPU, memory, or latency is materially reduced and documented.
## Finding The initial page load eagerly fetches and parses the complete player projection graph before the UI becomes ready: - `frontend/app.js:302-360` blocks startup on `/api/players` and `/api/status`, then starts matches, accuracy, live refresh, squad/rules, fixtures, market, overrides, and teams. - `backend/web/main.py:305-368` rebuilds a large derived dictionary for every `/api/players` request, including per-gameweek rows, six-week intervals/SD, components, ownership fields, and differential scores for every player. - The source comment in `backend/web/main.py` records `/api/players` at 1,562,646 bytes uncompressed. Gzip reduces transfer size, but not server serialization, browser decompression, JSON parsing, allocation, or retaining the entire object graph. - `refreshLiveFast(null, true)` also initiates a live upstream FPL fetch on every page load, even when the server-side live data is within its documented 900-second TTL. ## Suggested direction - Measure server timing, compressed transfer, parse time, retained heap, and first-use latency before choosing changes. - Cache the fully shaped player response by projection/live-ownership version instead of recomputing it per request. - Split summary fields needed for initial squad/player tables from detail-only per-gameweek/components data, loaded on demand or by tab. - Make `/api/refresh-live` honor freshness server-side so repeated clients do not stampede the upstream API. - Preserve gzip and functional output; this issue is about computation and loading strategy, not removing data. ## Acceptance criteria - Benchmarks cover cold/warm `/api/players`, payload/parse cost, and initial readiness. - Unchanged cache/live inputs do not trigger repeated response shaping or upstream refreshes. - Detail views retain all current information while initial-load CPU, memory, or latency is materially reduced and documented.
Author
Collaborator

A few measurement and compatibility choices are needed before changing the payload contract:

  1. What is the target environment for the baseline: local uvicorn plus Chromium, or the deployed host and a specific browser/device profile?
  2. Which primary success threshold should determine completion (for example, first-ready latency, server CPU, retained heap, or payload parse time), and what counts as “materially reduced”?
  3. May /api/players change shape and require the current frontend to migrate atomically, or must it remain backward compatible while summary/detail endpoints are introduced?
  4. Is an in-process response cache sufficient, or must caching work across multiple server processes?

Without these, several incompatible implementations could each appear to satisfy the issue.

A few measurement and compatibility choices are needed before changing the payload contract: 1. What is the target environment for the baseline: local uvicorn plus Chromium, or the deployed host and a specific browser/device profile? 2. Which primary success threshold should determine completion (for example, first-ready latency, server CPU, retained heap, or payload parse time), and what counts as “materially reduced”? 3. May `/api/players` change shape and require the current frontend to migrate atomically, or must it remain backward compatible while summary/detail endpoints are introduced? 4. Is an in-process response cache sufficient, or must caching work across multiple server processes? Without these, several incompatible implementations could each appear to satisfy the issue.
Owner

Answering all four, with the payloads measured on the running app just now.

Measured first, because it changes the issue. With Accept-Encoding: gzip, which every browser sends:

/api/players    1,968,717 b  ->  198,569 b   gzip
/api/matches      695,905 b  ->   68,515 b   gzip
/api/fixtures     359,741 b  ->   25,256 b   gzip

GZipMiddleware is installed and working. The wire is already 90% solved, so "reduce the payload" measured in bytes is largely done and is not where the remaining time goes. What is left is ~2 MB of JSON parsed and 623 players × 26 fields turned into objects before anything renders. That reframes every question below.

1. Baseline environment: local uvicorn + Chromium. Not a choice so much as a fact — there is no deployed host; the app binds 127.0.0.1 and require_local enforces it. Measure with the Playwright harness the suite already uses (tests/web/test_frontend_render.py), so the baseline is reproducible by anyone running the tests rather than a number from one machine.

2. Primary threshold: time from navigation to the Squad tab being usable — first paint of the pitch with real numbers — not payload bytes, not CPU, not retained heap. Bytes are already compressed; heap is not what the reader experiences; CPU on a single-user local app is not scarce. "Materially reduced" should be stated as an absolute, not a percentage: under 1.5s cold on the baseline above. Take the current figure first and put it in the issue, so the target is a diff and not an aspiration.

Secondary, worth recording but not gating: parse time for /api/players specifically, since that is the single largest contributor and the one a summary endpoint removes.

3. /api/players may change shape, but NOT atomically. Add /api/players/summary carrying only what the tables and pitch need (id, name, team, position, price, ev6, start_prob, badges, ownership), migrate readers one surface at a time, and keep /api/players serving the full rows until nothing reads it — then delete it in its own commit. An atomic swap makes the change unreviewable and unbisectable, which is the same objection this repo raised to the refactor in #7.

Note the per-gameweek rows are the bulk, and the player CARD is the only surface that needs them. That is the natural seam: summary for lists, full row on open. It also fixes a real thing — the card currently renders from data fetched for 623 players to display 1.

4. In-process is sufficient. There is one uvicorn process; set_deps/set_state already hold shared state in module globals and the autorefresh thread mutates them. Adding cross-process caching would mean adding a second process first, which nothing here wants. If that ever changes, the cache is not the hard part — the shared mutable deps are.

One caution. The app already carries a serialisation guard (202b69e) and an mtime-based cache hot-reload. A response cache keyed on anything other than cache.built_at + the deps generation would serve projections from before a rebuild while the header claims the new build time — the exact stale-artefact failure CLAUDE.md is organised around. Key it on built_at, or do not add it.

Answering all four, with the payloads measured on the running app just now. **Measured first, because it changes the issue.** With `Accept-Encoding: gzip`, which every browser sends: ``` /api/players 1,968,717 b -> 198,569 b gzip /api/matches 695,905 b -> 68,515 b gzip /api/fixtures 359,741 b -> 25,256 b gzip ``` `GZipMiddleware` is installed and working. **The wire is already 90% solved**, so "reduce the payload" measured in bytes is largely done and is not where the remaining time goes. What is left is ~2 MB of JSON *parsed* and 623 players × 26 fields turned into objects before anything renders. That reframes every question below. **1. Baseline environment: local uvicorn + Chromium.** Not a choice so much as a fact — there is no deployed host; the app binds 127.0.0.1 and `require_local` enforces it. Measure with the Playwright harness the suite already uses (`tests/web/test_frontend_render.py`), so the baseline is reproducible by anyone running the tests rather than a number from one machine. **2. Primary threshold: time from navigation to the Squad tab being usable** — first paint of the pitch with real numbers — not payload bytes, not CPU, not retained heap. Bytes are already compressed; heap is not what the reader experiences; CPU on a single-user local app is not scarce. "Materially reduced" should be stated as an absolute, not a percentage: **under 1.5s cold on the baseline above**. Take the current figure first and put it in the issue, so the target is a diff and not an aspiration. Secondary, worth recording but not gating: parse time for `/api/players` specifically, since that is the single largest contributor and the one a summary endpoint removes. **3. `/api/players` may change shape, but NOT atomically.** Add `/api/players/summary` carrying only what the tables and pitch need (id, name, team, position, price, ev6, start_prob, badges, ownership), migrate readers one surface at a time, and keep `/api/players` serving the full rows until nothing reads it — then delete it in its own commit. An atomic swap makes the change unreviewable and unbisectable, which is the same objection this repo raised to the refactor in #7. Note the per-gameweek rows are the bulk, and the player CARD is the only surface that needs them. That is the natural seam: summary for lists, full row on open. It also fixes a real thing — the card currently renders from data fetched for 623 players to display 1. **4. In-process is sufficient.** There is one uvicorn process; `set_deps`/`set_state` already hold shared state in module globals and the autorefresh thread mutates them. Adding cross-process caching would mean adding a second process first, which nothing here wants. If that ever changes, the cache is not the hard part — the shared mutable deps are. **One caution.** The app already carries a serialisation guard (`202b69e`) and an mtime-based cache hot-reload. A response cache keyed on anything other than `cache.built_at` + the deps generation would serve projections from before a rebuild while the header claims the new build time — the exact stale-artefact failure CLAUDE.md is organised around. Key it on `built_at`, or do not add it.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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
solvreven/FPL#8
No description provided.