A mobile banking client needs balances, transactions, contacts, and messages available as soon as it opens, including on a poor connection. Fetching each screen separately pushes refresh logic, stale pages, and cache invalidation into the app.
Those reads moved to local SQLite. PostgreSQL remains the source of truth. PowerSync consumes its logical replication stream and sends each user the rows they can access.
The app requests a signed sync token from the backend. PowerSync uses it to select rows, then delivers those rows to the device.
Offline-first mobile sync
Mobile app ── request sync token ──► Backend service
│ ◄── signed JWT ────── │
│ │ writes
│ sync with token ▼
▼ PostgreSQL
PowerSync ◄── logical replication ── publication
│
│ rows allowed by sync rules
▼
Device SQLite ── local reads ──► Mobile UI
The path is PostgreSQL → PowerSync → SQLite. The app reads the last synchronized state. Money movement still goes through the backend; an offline balance is not a fresh balance check.
Decide which rows belong on the device
PowerSync buckets define subsets of data. Typical buckets cover shared reference data, user profiles, bank accounts, merchant roles, and the current messaging session.
A token issued by the backend identifies the user and session. Sync rules use those claims to determine access. The client does not choose its own permissions.
Bucket count also affects performance. A user with several accounts and merchant roles can need many bucket instances. More buckets add sync work; broader buckets can send unnecessary data. The model has to reflect actual account and role relationships.
The example below has two bank accounts and three merchant accounts. Each merchant adds several bucket instances, so account count alone understates the sync work.
Example bucket expansion for one user
user_id = 42
│
├──────────────────────────────► global_data
│ (1 shared bucket)
│
├──────────────────────────────► user_data
│ (1 profile bucket)
│
├─ bank_account_id = BA-1 ─────► banking_data[BA-1]
├─ bank_account_id = BA-2 ─────► banking_data[BA-2]
│
├─ merchant_account_id = M-1 ──► merchant_base_data[M-1]
│ ├► merchant_seller_data[M-1]
│ ├► merchant_sales_as_owner[M-1]
│ └► merchant_sales_aggregated[M-1]
│
├─ merchant_account_id = M-2 ──► merchant_base_data[M-2]
│ ├► merchant_seller_data[M-2]
│ ├► merchant_sales_as_owner[M-2]
│ └► merchant_sales_aggregated[M-2]
│
├─ merchant_account_id = M-3 ──► merchant_base_data[M-3]
│ ├► merchant_seller_data[M-3]
│ ├► merchant_sales_as_owner[M-3]
│ └► merchant_sales_aggregated[M-3]
│
└─ session_id = S-9 ──────────► messaging_session[S-9]
Migrations now include old clients
Some relationships are flattened in PostgreSQL before syncing. Incrementally maintained views often have no primary key, so replication setup uses REPLICA IDENTITY FULL. Without a usable identity, updates and deletes can fail. The extra row data increases replication volume.
Base-table changes also update the derived views. Published changes from both flow through PostgreSQL replication to PowerSync and then to local SQLite.
PostgreSQL change propagation
app write / workflow write
│
▼
┌──────────────────────────────┐
│ base tables │
│ - bank_account │
│ - account_movement │
│ - payment_contact │
│ - message │
└──────────────┬───────────────┘
│
│ triggers IMMV maintenance
▼
┌──────────────────────────────┐
│ IMMVs │
│ - merchant_owner_view │
│ - other denormalized shapes │
└──────────────┬───────────────┘
│
│ publication
▼
┌──────────────────────────────┐
│ logical decoding / WAL │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ PowerSync │
│ - reads publication │
│ - applies sync rules │
│ - emits bucket-scoped rows │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ device SQLite │
│ - offline reads │
│ - instant cold start │
└──────────────────────────────┘
When a synced view changes shape, keep the old and new versions together during rollout. New clients consume the new shape. Remove the old projection after its clients have stopped using it.
That duplicates data temporarily, but avoids requiring a database deployment and a mobile update to happen together.
The local data supports the screen. When the user acts, the backend APIs and workflows check server state and execute the action.
Mobile UI, synced state, and assistant execution
┌──────────────────────┐
│ Mobile app │
│ │
│ reads local SQLite │
│ - balances │
│ - contacts │
│ - conversation rows │
│ - action payloads │
└──────────┬───────────┘
│
│ user taps action / sends message
▼
┌──────────────────────┐
│ Backend APIs / │
│ workflow entrypoints │
└──────────┬───────────┘
│
├──────────────────────► PostgreSQL source state
│ (authoritative balances,
│ contacts, conversation)
│
└──────────────────────► AI + workflow layer
- assistant interprets intent
- workflow validates action
- payment flow executes
Key property:
the client view, assistant context, and workflow validation all converge on the
same underlying business state instead of three loosely synchronized copies.
Monitor three separate things: retained PostgreSQL WAL, PowerSync replication and bucket processing, and the age of the client's last successful sync. A connected client can still show old data.
PowerSync removed much of the refresh logic from the app. The work moved into access rules, replication, and migrations. Those need the same attention as the API they replaced.