Icarus cloud · Proposal v2 · Read costs
Read the open page in slices, so an edit re-reads a slice instead of the whole page
Measured on our real server code with dev's data, a heavy cloud user-day reads about 14.1 MB from Convex. 83% of that is the editor's live page query re-running in full after every edit (58.5 KB on an 87-element page), and 9% is the desktop offline copy re-reading every strategy once a day. Splitting the page's live query into 8 complete slices by element ID cut the re-read to 10.7 KB in a prototype, with no new sync semantics: every answer is still a complete set, only smaller. That brings the same day to about 4.6 MB, and letting the offline pass skip unchanged strategies takes it to about 3.4 MB. An earlier version proposed per-row change numbers; Astra's review found six ways that design could silently show stale work, so it is set aside.
Where the bytes go
Bytes re-read after one edit on an 87-element page · by number of slices
Source: cost harness E:\w\convex-io\harness, session.mjs (one query) and session-slices.mjs --slices 4|8|16, against the prototype queries on branch t3/page-slices (main 5c7b867 + page:getFrame, page:getElementSlice, index elements.by_pageId_and_publicId); local backend loaded with an export of dev; one text-element drag. Byte counts match Convex cloud exactly.
The page re-read is expensive because Convex re-runs a live query in full whenever any row it read is written, and the editor's page query reads every row on the page. Its cost tracks the size of the page, not the size of the change. A slice re-runs only when a row in its ID range changes, so the re-read shrinks with the slice. What doesn't shrink is a fixed cost of about 2 KB per run (the strategy, user and page rows that every read checks for access), which is why 16 slices save less over 8 than 8 do over 4.
Already shipped today
| PR | What was wasted | Before | After | State |
|---|---|---|---|---|
| #346 | Web re-asked for the trash in a loop (browser timers overflow past 24.8 days) | 1,380 calls / min | 0 / min | merged · verified on beta |
| #347 | ensureCurrentUser rewrote the user row on each call, re-running every live query | 81.5 KB / call | 0.4 KB | merged |
| #348 | Every agent drag re-read every agent in the strategy for the library card | 75.1 KB / drag | 5.3 KB | merged |
Source: #346 counted from websocket frames on beta.icarusstrats.com with a test account (E:\w\trash-gt-live), before and after deploy; #347 and #348 from the cost harness, main vs branch.
Proposal 1: the open page in slices
Recommended decision: build sliced page reads now (server queries first, then the app), and keep the whole-page query for every shipped build and as the app's fallback.
- Server, additive only. A new index on elements by page and public id, and two new queries:
page:getFrame(the page, its settings, its lineups and their images) andpage:getElementSlice(fromId, toId)(the page's elements in that id range, with their images and picture aliases, serialized exactly aspage:getSnapshotdoes today).page:getSnapshotstays as it is for every shipped desktop build and the live web build. - App. When it opens a page, the editor picks a slice count from the page's size (about one slice per 10 elements, between 1 and 16) and subscribes to the frame and each slice. It shows the page only when every part has answered, and from then on rebuilds the whole page from the latest answer of each part. Everything downstream (conflict handling, the "live" rule for queued work, the offline copy, media) keeps receiving a complete page, as it does today.
- Why slices stay correct. Each slice is an ordinary complete query over a fixed id range. A row that moves to another page, gets deleted, restored or purged leaves or enters its range, and Convex re-runs that slice with the row gone or present. No change numbers, no history, nothing to keep in step across write paths.
- Element ids. Element ids are random UUIDs (3,067 of 3,119 dev rows), so fixed hexadecimal boundaries split pages evenly. Copies (
<root>~cp1~<uuid>) sort with their original, which only affects balance, never correctness.
| Page read as | Re-read per edit | Edit total (with applyBatch) | Page open, live queries | Function calls at open |
|---|---|---|---|---|
| 1 query (today) | 58,494 B | 63,132 B | 80,879 B | 5 |
| 4 slices | 17,617 B | 22,263 B | 91,405 B | 9 |
| 8 slices | 10,735 B | 15,381 B | 98,625 B | 13 |
| 16 slices | 7,303 B | 11,949 B | 113,065 B | 21 |
Source: harness results results-base2.json, results-slices4.json, results-slices8.json, results-slices16.json. "Page open, live queries" covers the editor's live queries plus the library's, as the harness holds them. A batch of 10 moves touched 2 slices at 8 and 4 slices at 16.
Function calls per edit stay at one re-run, except when one batch touches rows in several slices. Opening a page costs a few more calls and 10 to 32 KB more, once. For a page someone opens and then edits, the slices pay for themselves after the first or second edit.
Proposal 2: skip unchanged strategies in the offline pass
The desktop offline copy re-reads every strategy in full once a day per device, changed or not: 418 KB for one 20-page strategy. For a light user who opens the app and edits little, this is most of their day. The fix is to know, cheaply, which strategies changed since the copy was taken.
- One small row per strategy holding a content version, bumped once by any mutation that writes the strategy's content, and listed for the whole library by one query. It is its own table, so bumping it never re-runs the library lists.
- The pass re-reads a strategy in full only when its version moved. It records the version for a strategy only after every page and image of that strategy is written to disk, never from one page the editor happened to read.
- Missing a bump would leave a copy stale, so: every write goes through one wrapped database handle with a test that rejects writes around it, a failed bump fails the whole transaction (outside
applyBatch's per-op error handling), and every copy is still re-read in full at least once a week. Opening a strategy online always reads it fresh, as it does today.
This one needs its own design pass (what counts as a content write, how rollback or a data import resets versions) before it's built. It saves about 1.2 MB on an unchanged 4-strategy library per device per day; libraries with more strategies save more.
Set aside: per-row change numbers
Version 1 of this proposal stamped every row with a per-strategy change number and sent clients only the rows changed since the number they held. It would have cut an edit to about 4 KB. Astra's review found six independent ways it could leave a user looking at stale work, each one fixable but together a protocol with many places to be wrong:
| Hole | What the user would see |
|---|---|
| A row moved to another page is never reported as gone from the old page | The item shows on both pages |
| Restoring a trashed page changes only the page row, not its content | The page comes back empty |
| Purged tombstones and reused ids slip past a "purged through" number | Deleted items reappear |
| Image answers are derived (upload attempts, legacy assets, aliases), not rows | Images stuck or wrong |
applyBatch catches per-op errors, so a failed stamp can commit an unstamped write | A change never reaches others |
| The app treats every page answer as complete (deletions, the live rule) | Rows dropped, conflict protection off |
Source: Astra design review round 1 (gpt-6-astra, high), 2026-10-19, against main and the v1 doc. Its recommendation was the sliced design above, then version-based skipping for the offline copy.
Slices give up about 6 KB per edit against that design and keep every answer complete. If slices plus the offline skip aren't enough once real users arrive, change numbers can be revisited with those six holes as the spec.
One heavy user-day, before and after
| Part | Today | 8 slices | 8 slices + offline skip |
|---|---|---|---|
| 200 edits (applyBatch + re-read) | 12.63 MB | 3.08 MB | 3.08 MB |
| Offline pass (4 strategies, unchanged) | 1.26 MB | 1.26 MB | ~0.01 MB |
| 3 opens + library | 0.24 MB | 0.29 MB | 0.29 MB |
| Total | 14.13 MB | 4.63 MB | 3.38 MB |
| Heavy user-days per 1 GB | 71 | 216 | 296 |
Computed from the harness per-action numbers above; the offline skip and the open cost with slices are estimates. A "heavy user-day" is a scripted stand-in (200 single drags on an 87-element page); we have no production numbers for real sessions.
How it ships
| Step | What | Shipped builds | Door |
|---|---|---|---|
| 1 · server | Index + getFrame + getElementSlice, with return validators and tests that a page assembled from its parts equals getSnapshot (moves, deletes, restores, images, aliases, trashed pages) | Untouched | two-way · additive |
| 2 · app | The editor reads pages as frame + slices and rebuilds a complete page; falls back to getSnapshot if a part fails | n/a | sync path · you look first |
| 3 · offline skip | Its own design doc, then server + app | Untouched | copy format · you look first |
Proof for step 2: the harness on the branch, the two-account editing run on a real build (a teammate's move, delete and restore showing up live), and a page with images uploading while open.
Not covered
- Production library sizes and real session lengths: all numbers come from our dev test data and a scripted day. The ratios are what these changes move.
- The hourly token refresh: the app re-runs its whole Convex sign-in setup on each Supabase auth event, which may re-run every live query once an hour per client. Astra is measuring it now.
- The library list reads every page of every strategy to label it Attack or Defend, about 1 KB per strategy per run. A separate small PR.
- The plan. Even after these cuts, a hard cap that stops everyone's saves at once is the real risk. Starter (pay as you go, no hard stop) would cost a few dollars a month at our size.
Appendix
Per-action costs, main with #347 and #348 · cost harness
| Action | Calls | Read per call | Docs |
|---|---|---|---|
| Library open (7 queries) | 7 | 5,259 B | 100 |
| Offline pass: getFullSnapshot, 20-page strategy | 1 | 418,109 B | 603 |
| Open 20-page strategy (getShell + getSnapshot) | 2 | 33,625 B | 117 |
| Move 1 element: applyBatch | 1 | 4,638 B | 10 |
| Move 1 element: page re-read | 1 | 58,494 B | 93 |
| Move 1 agent: applyBatch | 1 | 5,250 B | 11 |
| Move 10 elements in one batch: applyBatch | 1 | 33,482 B | 64 |
| ensureCurrentUser with editor + library open | 1 | 373 B, no re-runs | 1 |
E:\w\convex-io\harness\results-base2.json · 2026-10-19 · local backend, dev export, protocol 6.
Generated 2026-10-19 · Claude (Opus 5.5) for Dara · inputs: main 5c7b867, prototype branch t3/page-slices, cost harness on a dev export, prod and dev function logs of Oct 19, Astra design review round 1 · revision v2