Performance
What keeps the cold start fast, and how to measure it. Reference numbers below were taken on a prod build (pnpm generate) with a real account of ~8 000 transactions, desktop hardware, assets served from the service worker - treat them as relative, not absolute.
Cold-Start Budget
The boot is dominated by JS execution, not network (everything is SW-cached). Two paths fill the stores:
| Path | Data on screen |
|---|---|
Blob cache prime (cache:prime, ~70 ms read) | ~280 ms |
First trns watch emission from SQLite | ~730 ms |
That ~450 ms gap is why the blob cache exists: its value is the early paint, not skipping the row transform (transforming 8k rows takes ~3 ms - the cost is the SQLite scan + worker transfer, not JS).
The five boot watches are serialized on the single PowerSync worker connection - their first emissions land stair-stepped (user_settings → rates → categories → wallets → trns), with the full-table trns scan dominating. Merging the four small queries would save little; windowing trns by date was rejected because wallet totals need full history (see Technical Decisions).
Bundle: What Loads When
@powersync/webis lazy.getPowerSyncDb()(app/services/powersync/db.ts) dynamically imports the library, schema, and connector on first use, keeping ~230 KB out of the entry chunk - the login page and demo mode never parse it. Two details guard this:uploadErrorHandler.tsexists so the plugin's static import doesn't reach connector.ts, anduploadReconcile.tscompares op strings instead of importing theUpdateTypeenum.- swiper is lazy. The transaction form (
LazyTrnFormBottom/LazyTrnFormSidebarin the default layout) mounts after its first open; an idle prefetch keeps that first open instant. - echarts mounts on idle. Charts are gated on
useIdleMount()behind an equal-height placeholder (stat/chart/Wrap.vue), so the 450 KB echarts chunk never competes with the first paint. - Dashboard skeleton.
StatPageSkeletonreserves the page shape while stores hydrate, so arriving data doesn't shift the layout. - Icons are bundled. All statically referenced icon names ship in the client bundle (
icon.clientBundle.iconsinnuxt.config.ts, including thehugeiconscollection) - icons fetched fromapi.iconify.designat runtime pop in after first paint and shift layout (CLS). The icon scanner misses names inside dynamic ternaries, hence the explicit list. Only user-data icons (category/wallet icons outside the bundle) still fetch at runtime, cached by the SW (CacheFirst). - SW precache is narrowed. Of the eight emitted wa-sqlite WASM variants, only
wa-sqlite-async.<hash>.wasm(the one the worker actually loads - cipher builds need anencryptionKeywe never pass) is precached, saving ~12 MB per service-worker install.
Instrumentation
Cold-start marks are always on (they cost nothing). In the browser console after a reload:
performance.getEntriesByType('measure')
.filter(m => /^(ps:watch|cache:|trns:)/.test(m.name))
.map(m => `${m.name}: ${Math.round(m.startTime)}ms +${Math.round(m.duration)}ms`)
| Mark | Meaning |
|---|---|
cache:prime | Blob cache read + store prime (cold-start paint path) |
ps:watch:<table> | Subscribe → first SQLite emission per boot watch |
trns:first-transform | Row → item transform cost of the first trns emission |
Rejected Approaches
Documented so they aren't re-attempted without new data (details in Technical Decisions and todo.md):
- OPFS VFS instead of
IDBBatchAtomicVFS- cold start is bound bydb.init+ query serialization, not page I/O. - Date-windowed
trnswatch - breaks wallet totals, which need full history. - Splitting vendor chunks via
manualChunks- fragmenting reka-ui breaksinject/createContextin production builds. Map-based store items - saves single-digit ms per write at 8k rows, but breaks every reader of the plain-objectitemsshape.