Keep a copy of your tables on the device: reads are instant, writes work offline and sync later, with your row level security and a conflict rule per table.
Offline sync keeps a local copy of chosen tables in your app, in IndexedDB in the browser or in a store you choose on mobile. Reads come from that copy, so screens open at once and keep working without a connection. Writes go to the copy straight away and wait in a queue; when the device is online the SDK sends them, receives everything that changed on the server, and tells you about any conflict. Each user receives only the rows your row level security policies let them read, and every write is checked against them like a REST API call.
#Turn it on for a table
In Database › Offline sync (project owners and admins) choose a table and what happens when a row changed in two places. The table needs a primary key. Existing rows become available to devices at once; after that, a trigger records every insert, update and delete in the same transaction.
#Use it in your app
const db = stitchdb.offline({ tables: ['todos'] })
await db.ready // loads the local copy, then syncs in the background
const todos = db.table('todos')
await todos.upsert({ id: crypto.randomUUID(), title: 'Buy milk', done: false })
await todos.delete(id)
todos.all() // every local row
todos.get(id) // one row by primary key
todos.where((t) => !t.done) // local filter
const stop = todos.subscribe((rows) => render(rows))
db.on('status', (s) => showBadge(s.online, s.pending)) // { online, syncing, pending, lastSyncAt, error }
db.on('conflict', (c) => console.log(c.table, c.status, 'server kept', c.server))
await db.sync() // sync now (also runs every 30 s and on reconnect)| Option | Meaning |
|---|---|
tables | The tables to keep. Every synced table by default. |
store | 'indexeddb' (the default in browsers), 'memory' or your own store with get, set, delete, entries(prefix) and clear. |
name | Name of the local database. Use one per signed-in user, or call db.close({ clear: true }) when a user signs out. |
interval | Milliseconds between syncs while online, 30000 by default; 0 syncs only when you call sync() and when the device comes back online. |
batch | Rows per download request, 500 by default (up to 1000). |
The Flutter, Swift and Kotlin SDKs have the same client as client.offline(...), with streams or flows for the events. The queue and the local rows survive an app restart.
#When a row changed in two places
A conflict is a device change to a row that someone else changed on the server after the device last received it. The rule is set per table:
| Rule | What is kept |
|---|---|
| Latest edit wins | The change made last, by the device clock at the time of the edit (never later than the server time). The default. |
| Server wins | The server row; the device change is dropped and reported as a conflict. |
| Device wins | The device change, always. |
| Your function | What fn(table_name text, server_row jsonb, client_row jsonb) returns jsonb returns: the row to keep, or null to delete it. It runs as the user, so it can only write what they may write. |
create function public.resolve_todos(t text, server_row jsonb, client_row jsonb) returns jsonb
language sql as $$
-- keep the device title, but once done on any device a todo stays done
select client_row || jsonb_build_object('done', (server_row->>'done')::bool or (client_row->>'done')::bool)
$$;When the server keeps its row, or refuses a write (a policy check fails, a constraint is broken), the SDK replaces the local row with the server's and emits conflict with status conflict or rejected, the local and server rows, and the error message. Sending the same change twice is safe: each change has an id and is applied once.
#Over HTTP
Other clients can use the same endpoints at https://<ref>.p.stitchdb.com/sync/v1, with the project API key and the user's access token as for the REST API:
| Request | What it does |
|---|---|
GET /tables | The synced tables with their primary key and conflict rule. |
POST /pull | { tables: { "public.todos": 0 }, limit } → the rows changed after each cursor, oldest first: { changes, cursors, more, reset }. A change is { seq, op, pk, row } with op u (insert or update) or d (deleted, or no longer visible to this user). A table in reset must be downloaded again from 0. |
POST /push | { client, mutations: [{ id, table, op, row | pk, base, at }] }, up to 500 per request; base is the seq the device last saw. Each result is applied, duplicate, conflict or rejected, with the row now on the server. |
#Good to know
- Deleted rows are remembered for 30 days. A device that has been offline longer downloads its tables again; its queued changes are still sent first.
- When a row is deleted or a user loses access to it, devices receive its primary key so they can drop their copy, nothing else.
- The change log lives in your database (schema
_stitch_sync), about 100 bytes per row, and counts toward its size. - With row level security off, every signed-in user receives every row of the table. Studio warns about it.
- Stopping sync for a table removes its trigger and change log; your table is not changed.
| Limit | Free | Pro | Scale | Enterprise |
|---|---|---|---|---|
| Offline-synced tables per project | 2 | 20 | 100 | 500 |