Open Finapp
Reference

Performance

Cold-start budget, lazy chunks, icon bundling, and the built-in instrumentation marks.

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:

PathData 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_settingsratescategorieswalletstrns), 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/web is 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.ts exists so the plugin's static import doesn't reach connector.ts, and uploadReconcile.ts compares op strings instead of importing the UpdateType enum.
  • swiper is lazy. The transaction form (LazyTrnFormBottom / LazyTrnFormSidebar in 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. StatPageSkeleton reserves 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.icons in nuxt.config.ts, including the hugeicons collection) - icons fetched from api.iconify.design at 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 an encryptionKey we 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`)
MarkMeaning
cache:primeBlob cache read + store prime (cold-start paint path)
ps:watch:<table>Subscribe → first SQLite emission per boot watch
trns:first-transformRow → 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 by db.init + query serialization, not page I/O.
  • Date-windowed trns watch - breaks wallet totals, which need full history.
  • Splitting vendor chunks via manualChunks - fragmenting reka-ui breaks inject/createContext in production builds.
  • Map-based store items - saves single-digit ms per write at 8k rows, but breaks every reader of the plain-object items shape.