Skip to content

perf(sync): decrypt and apply the session list in chunks, newest first - #1772

Open
f-liva wants to merge 1 commit into
slopus:mainfrom
f-liva:perf/chunked-session-decrypt
Open

f-liva wants to merge 1 commit into
slopus:mainfrom
f-liva:perf/chunked-session-decrypt

Conversation

@f-liva

@f-liva f-liva commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

What

Decrypt and apply the session list in chunks (newest first) instead of decrypting every session up front and applying once.

Why

fetchSessions (sources/sync/sync.ts) decrypts every session's metadata + agentState in one sequential await loop, accumulates them, and calls applySessions a single time at the end. So nothing is applied to the store until the whole dataset is decrypted, and the main thread is held for the duration.

Change

applySessions (sources/sync/storage.ts) merges into the existing session map rather than replacing it, so applying in chunks accumulates correctly. Using that:

  • Sort sessions newest-first (updatedAt desc) and decrypt them in chunks of 24.
  • Apply each chunk incrementally.
  • await a setTimeout(0) between chunks to yield the main thread to paint and input.

The thinking-state snapshot (storage.getState().sessions, preserving live per-session thinking/thinkingAt across a full refetch) is taken synchronously right before each chunk's apply — same invariant as before, still not taken inside the awaiting decrypt loop.

Single-file change (sync.ts), pnpm typecheck clean.

Measured (updated — the original claim was wrong)

This PR was originally argued from the code path as a time-to-first-paint win. I have now benchmarked it, and that claim does not hold. Keeping the honest numbers here rather than the original reasoning.

Setup: production expo export build served locally, dev server, 150 sessions with production-sized metadata (12.5 KB plaintext / 16.7 KB encrypted-base64 each). Chrome driven by Playwright on a clean profile with no extensions. Both variants selected at runtime on the same bundle via a temporary CHUNK_SIZE override (24 vs one huge chunk = the pre-PR behaviour). firstRow = first session row present in the DOM, ingestDone = end of fetchSessions, long tasks via PerformanceObserver, totalBlocking = sum of max(0, duration - 50ms). Median of 3 runs.

scenario variant firstRow ingestDone maxLongTask totalBlocking
desktop, no throttle chunked(24) 1061 ms 910 ms 158 ms 158 ms
desktop, no throttle single-shot 1074 ms 909 ms 156 ms 267 ms
Pixel 7, 4x CPU throttle chunked(24) 3910 ms 3403 ms 732 ms 2079 ms
Pixel 7, 4x CPU throttle single-shot 3708 ms 3217 ms 676 ms 2295 ms

Time to first paint is unchanged — the differences are inside run-to-run variance. The one real, repeatable effect is total blocking time: 158 vs 267 ms on desktop, 2079 vs 2295 ms throttled (2011 vs 2783 ms in an earlier run of the same setup). The work is not reduced, it is split up: the page stays responsive to paint and input during ingest, but it does not get there sooner.

Why the first-paint argument fails

In every single run, firstRow is greater than ingestDone — the first row paints only after the whole ingest has finished, in all four combinations and on both a light and a production-sized dataset. So the premise behind chunking ("paint the first chunk while the rest decrypt") never materialises: paint is not gated on the decrypt loop, it is gated on something downstream (React/FlashList commit, navigation boot) that starts once ingest is done anyway.

Also worth noting for #1765: AES256Encryption.decrypt (sources/sync/encryption/encryptor.ts) already batches through Promise.all, so the AES-GCM work is largely off the main thread. What the main thread actually spends the ingest on is JSON.parse, zod safeParse and rendering.

So: merge this if a lower blocking time during startup is worth a few extra applySessions merges — it is a responsiveness change, not a load-time change. But the real startup win for #1765 is in the window between ingestDone and first paint, which this PR does not touch.

Deployed and running on a self-hosted web instance without regressions (session list, live thinking dots, ordering all correct).

Related

Note

CHUNK_SIZE = 24 is a plain paint-vs-merge-overhead pick, not tuned.

🤖 Generated with Claude Code

@f-liva
f-liva marked this pull request as ready for review September 8, 2026 08:34
@f-liva

f-liva commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Updated the description with a benchmark, because the original time-to-first-paint claim in this PR was wrong and I would rather say so than leave it standing.

Clean profile (Playwright-driven Chrome, no extensions), production expo export build, 150 sessions with production-sized metadata (12.5 KB plaintext each), both variants toggled at runtime on the same bundle. Median of 3:

scenario variant firstRow ingestDone totalBlocking
desktop chunked(24) 1061 ms 910 ms 158 ms
desktop single-shot 1074 ms 909 ms 267 ms
Pixel 7, 4x throttle chunked(24) 3910 ms 3403 ms 2079 ms
Pixel 7, 4x throttle single-shot 3708 ms 3217 ms 2295 ms

First paint: no measurable difference. Total blocking time: consistently lower with chunking. So this is a responsiveness change, not a load-time change.

The reason the first-paint argument fails is worth recording for #1765: in every run firstRow > ingestDone — the list paints only after the whole ingest finishes, so paint was never gated on the decrypt loop. It is gated on something downstream (React/FlashList commit, navigation boot). And AES256Encryption.decrypt already goes through Promise.all, so the AES-GCM work is largely off the main thread to begin with; the main thread spends the ingest on JSON.parse, zod safeParse and render.

Happy to close this if a lower blocking time alone is not worth the extra merges.

fetchSessions decrypted every session's metadata + agentState up front
and called applySessions once. On web (JS crypto, no native offload) that
blocks the main thread for seconds before anything paints, and the freeze
grows with the account size.

applySessions merges rather than replaces the list, so applying in chunks
accumulates safely. Decrypt newest-first in chunks of 24, applying each
chunk incrementally and yielding to the event loop between chunks: the
viewport sessions paint after the first chunk while the rest fill in with
the thread responsive to paint and input.

The thinking-state snapshot is taken synchronously right before each
chunk's apply (unchanged semantics), so live sessions keep their pulsing
state across a full refetch.

Refs slopus#1765

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@f-liva
f-liva force-pushed the perf/chunked-session-decrypt branch from e96a0fa to 11fb5e7 Compare September 16, 2026 08:56

This branch has not been deployed

No deployments
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.

1 participant