Open Finapp
Reference

Offline-first

How PowerSync enables offline writes, upload queue, and error surfacing.

Finapp is offline-first via PowerSync. Local SQLite is the single source of truth. All reads come from SQLite; all writes go to SQLite first and are queued for upload to Supabase Postgres.

How It Works

user action (create/update/delete)
  → optimistic: items.value updated in store immediately
  → upsertRow / deleteRow writes to local SQLite (sync)
    → success: watchTable fires → store refreshes from SQLite
    → failure: rollback prev snapshot + showErrorToast (local write error)
  → PowerSync queues the op for upload
    → online: connector drains queue → Supabase upsert/update/delete
    → offline: queue persists in SQLite; drained on reconnect

There is no manual queue management, no collapsing rules, and no frontend-local ID system. PowerSync owns the upload queue.

Offline Writes

When the device is offline:

  • Reads continue to work - all data is already in local SQLite.
  • Writes go to local SQLite immediately (optimistic). The user sees the change right away.
  • The upload op is added to PowerSync's internal queue.
  • On reconnect, PowerSync drains the queue automatically.

No user action is needed to trigger sync after reconnect.

Session Loss While Offline

Going offline does not sign the user out. A failed token refresh while offline is retryable - supabase-js keeps the persisted session (localStorage) and emits no SIGNED_OUT, so the route guard (hasPersistedSession()) keeps the user in the app and the unsynced queue keeps filling. Two cases are handled so offline writes survive:

  • Reactive session not resolved yet (offline cold start). After the access token expires, a fresh getSession() resolves to null, so the reactive uid is null until reconnect. Writes stamp userId via resolveWriteUid(uid.value) (app/app/composables/useAuthSession.ts), which falls back to the synchronously-persisted uid. Without it the row would carry an empty userId, which RLS rejects (42501) on upload - silently dropping the offline write.
  • Session lost involuntarily (token revoked / expired server-side). Surfaces on reconnect as a genuine SIGNED_OUT. The auth watcher then pauses sync (pausePowerSync - db.disconnect(), no clear) instead of wiping, keeping local SQLite and the unsynced queue. When pending writes exist, a re-auth toast is shown (sync.errors.sessionLostPending); re-authenticating as the same user reconnects and drains the queue. Only an explicit sign-out (useUserStore.signOut()) wipes local data via disconnectPowerSyncdisconnectAndClear.

Upload Error Handling

SupabaseConnector.uploadData() distinguishes fatal errors from retryable ones:

Fatal errors (discarded)

Postgres error classes 22xxx (data exception), 23xxx (integrity constraint violation), and 42501 (RLS - insufficient privilege) are treated as fatal. The failing op and every op queued after it are discarded so the queue is not blocked, then auto-reconciled by the plugin (planDivergence, app/services/powersync/uploadReconcile.ts):

  • Rejected INSERTs: the local rows are deleted to converge (the server never accepted them). A reverted wallet/category cascades to local trns referencing it, so no orphans survive. Toast: sync.errors.uploadReverted.
  • Rejected UPDATE/DELETE: the server still holds the prior version and PowerSync can't re-pull a single row, so the user is offered a destructive full re-sync (forceResync: wipe local SQLite + re-pull server truth). Toast: sync.errors.uploadDiverged.

Retryable errors

Network errors, timeouts, and all other non-fatal errors cause the error to be rethrown. PowerSync catches the rethrow and retries with backoff when connectivity is restored.

Cascade Deletes

deleteWallet and deleteCategory also delete the entity's trns from local SQLite before returning. This is required because the watchTable subscription on trns would otherwise re-add the orphaned rows to the store after the parent entity is removed from SQLite. Rollback on a local write failure restores both the entity and its trns.

The server enforces no foreign key constraints (PowerSync upload order is not guaranteed), so referential integrity lives in app logic.

Optimistic Rollback

Every store write snapshots prev = items.value before mutating. If the local SQLite write throws, items.value = prev is restored and an error toast is shown. This covers local write errors (e.g., SQLite constraint, disk full). Server-rejection errors (RLS, constraint) are handled separately by the upload-error handler.

No Manual Queue

PowerSync owns the upload queue natively - there is no hand-written queue, no replay step, no op-merging, and no frontend ID remapping.

Client IDs are regular UUIDs (generated client-side via crypto.randomUUID() or equivalent). They are stable and serve as the permanent primary key on both device and server, so nothing needs remapping after upload.

Key Files

FileRole
app/services/powersync/connector.tsSupabaseConnector.uploadData() - drains queue, fatal/retryable split
app/services/powersync/mutations.tsupsertRow, deleteRow - write to local SQLite
app/services/powersync/db.tswatchTable - subscribe to SQLite changes; pausePowerSync - stop sync, keep local data + queue
app/app/composables/useAuthSession.tsresolveWriteUid - persisted-uid fallback for row writes; hasPersistedSession route gate
app/app/plugins/powersync.client.tsRegisters upload-error handler (toast); pauses sync (keeps data) on involuntary session loss
app/app/components/trns/useTrnsStore.tsOptimistic write + rollback pattern, cascade delete trns
app/app/components/wallets/useWalletsStore.tsOptimistic write + rollback + cascade delete trns
app/app/components/categories/useCategoriesStore.tsOptimistic write + rollback + cascade delete trns
app/app/composables/useStoreSync.tsshowErrorToast, showSuccessToast, demo-only createDebouncedPersist

Next Steps

  • Sync - PowerSync sync streams, upload queue, and deletion handling
  • Architecture - app initialization and store pattern