Skip to content

Offline writes and conflicts

A local TinyBase change first persists to IndexedDB with its coalesced outbox operation. The persister then tries Supabase. If the browser is offline, it retries with capped exponential backoff and again on browser reconnect or tab focus.

Local persistence and synchronization completion

Remote pulls preserve newer local edits, including edits in other tables and row deletions. Snapshot reads, operation generation, and durable content replacement share a local persistence queue. Network requests run outside it, so an in-flight upload does not block local saves.

Before committing a remote replacement, the persister checks whether the local store changed during its IndexedDB work. If it changed, the transaction aborts without advancing the cursor and reconciliation retries against current local content. Accepted remote content applies synchronously to the store. A failed commit restores the previous baseline while preserving subsequent local edits. Hydration also preserves edits made while reading IndexedDB. Saves during loading persist through the same local queue.

A successful syncNow() finishes after remote content has applied and its IndexedDB transaction has committed, including hybrid metadata and open CRDT projections. As with background synchronization, a transport failure is reported through onError and sync status and schedules a retry; resolution alone does not mean every write was accepted. Inspect getSyncStatus() for pending or rejected operations and failures. With automatic loading stopped, pulls update durable content and load() applies it to the store.

Each coalesced row operation has a revision. A remote response acknowledges or rejects only the revision sent in that request. If another local write replaces it while the request is in flight, that newer operation remains pending. This also applies to tombstones. Existing IndexedDB databases retain queued operations without a schema change.

Conflict rule

Ordinary cells synchronized through direct Supabase CRUD use whole-row, server-arrival last-write-wins. When two offline clients change the same row, the write the server accepts last wins; the next complete pull brings the other client to that state.

This ordinary mode does not provide exactly-once delivery or cross-table transactions. For concurrent changes within selected text, map, or array values, configure collaborative CRDT cells.

When updatedAtColumn is configured, incremental pulls use server-managed timestamps as pragmatic polling cursors, not as a commit-ordered change-data-capture log. The default five-minute lookback recovers ordinary short transactions that commit out of timestamp order. Applications with longer transactions, strict commit ordering, or dynamic per-row access changes should provide a durable application-specific change feed and version the affected scopeKey. Omitting updatedAtColumn retains paginated full pulls.

Rejected writes

Validation and RLS failures become rejected operations. The optimistic TinyBase row remains visible so the application can explain the error and let the user repair it. CRDT failures additionally quarantine the affected document's outbound history; later local edits remain optimistic but are held behind the rejected update.

ts
const rejected = await persister.getRejectedOperations();

await persister.retryRejected();
// Or discard. Ordinary optimistic rows remain unchanged; affected CRDT rows
// abandon all unaccepted updates, close, and must be opened again.
await persister.discardRejected();

For CRDT documents, retry merges the rejected update with every held successor before upload. The document stays quarantined through transient retry failures. Discard invalidates existing row handles as well as removing the unaccepted local history, so call openRow() again before editing or reading its live Yjs types.