Skip to content
Browse documentation
Decided

Offline behaviour and data sync

How the app keeps a local copy of everything the user may see, how it learns about changes, and what happens without a network.

Updated: 29 October 2026

Local cache

Each app keeps a local database (Room on Android, SQLite document store on iOS) with:

  • the account and its profiles, circles, detail types and all details (owner data);
  • all connections with their snapshots and hashes, and in connections_previous the snapshot each connection had before its last change (A94). That table is local only: the delta fetch writes it when a connection arrives with a different snapshot_hash, a row re-sent with the same hash leaves it alone, a connection that leaves takes it along, and nothing sends it anywhere. The contact card reads it to mark the rows a contact_updated was about (Contacts §3.1.5);
  • invitations, notifications (last 90 days), sync services and sync links, and the sync_decisions queue of reconciliation decisions waiting to be replayed;
  • a sync_cursors table with a keyset cursor per table: the updated_at and id of the last row seen (cursor_updated_at, cursor_last_id), not just a timestamp.

The cache is the read model for every screen. Screens never wait for the network to render.

Refresh

TriggerAction
App start and foregroundDelta fetch: for each cached table, page by keyset — select * where (updated_at, id) > (cursor_updated_at, cursor_last_id) order by updated_at, id limit 500 under RLS — apply each page, then advance the cursor to the last row’s (updated_at, id). Throttled to once per minute (Saymail’s ResumeThrottle).
Realtime event on notificationsDelta fetch immediately
Push notification (background)Silent push triggers a delta fetch so the tap lands on fresh data
Pull to refresh / header refreshSame delta fetch, with a visible spinner
Every 15 minutes in background (WorkManager / BGAppRefreshTask)The same delta fetch and nothing else (A65). The external-service sync of app/15 is a second job with constraints of its own (A75): on Android a WorkManager request that needs an unmetered network and a battery that is not low, on iOS a BGProcessingTaskRequest

Deletions are not announced (since A102 no notification says a connection ended). Only accounts.deleted_at is a soft delete in this schema; connections and sync links are hard-deleted, like the owner’s own detail tables, and are reconciled by comparing id sets during the delta fetch rather than by a deleted_ids query.

Editing

Edits are applied to the local cache first and sent immediately; the row shows a subtle “saving” state until the server confirms. On failure the local change is rolled back and a banner explains (“Couldn’t save. Check your connection.”). There is no offline queue: a contacts app rarely needs to edit in flight mode, and a queue would complicate conflict handling for multi-device accounts. The exception is the sync-service reconciliation decisions (hide, link, unhide), which are queued in a local sync_decisions table and replayed in order when the network returns, then reflected in sync_links (A73): they are made in bulk, on a list somebody may work through with no signal, and losing half of that list would cost more than the conflict handling a queue costs.

Conflicts

Owner data is last-writer-wins per row; the server’s updated_at is authoritative and the delta fetch overwrites the local row. Snapshots are server-computed and never edited locally.

A keyset delta only re-sends rows whose updated_at moved. alter table … add column does not move it, so a client that cached a row before a migration never sees the new column until that row changes. Two rules follow (learned when accounts.telemetry_consent arrived in phase 3): every column added to a mirrored table decodes with a default on both apps, and a migration that adds a column the apps need at once also touches the rows (update … set updated_at = now()).

Offline indicators

  • A persistent thin banner “Offline. Showing what was saved at 9:12. Editing needs a connection.” at the top of lists (round 3, state e3); the time is when this session last brought the cache up to date, and before the first sync of a session the sentence leaves it out. The header’s Find, Add and Connect stay, dimmed, and Profile’s ”+” reads “Add · offline”. On Android the signal is the default network’s internet capability (ConnectivityManager), read once at start and followed by a callback; while offline this strip wins over a sync-error strip.
  • Editors open but the Save button is disabled with the reason under it: “You’re offline. Saving needs a connection.” (editors.offline, a polite live region). The same signal gates every action that writes to the server with a button of its own: the profile detail editors and the onboarding steps’ Continue, the contact card’s nickname Save, the Move sheet (circles listed but not choosable), circle rename and Add circle, Send invitation, Accept and Decline, and the e-mail change sheet. Editing needs a connection and says so beforehand; nothing is queued, and nothing fails only after the tap. Immediate toggles inside an editor (visibility dots, searchable) still roll back with “Couldn’t save” if the write fails.
  • Search of Incico users is disabled offline; local search of cached contacts (FTS5 over names, nicknames, e-mails, phones) keeps working.
  • Sync to external services requires network for Google and works offline for device contacts (the address book is local), but the app still refuses to run a sync whose Incico-side data is older than 24 hours, to avoid writing stale cards.

External services and cursors

Google Contacts uses the People API syncToken for incremental reads; device contacts use the platform change tokens (CNChangeHistory on iOS, CONTACT_LAST_UPDATED_TIMESTAMP on Android). Both tokens live in the local cache, not on the server, because they are meaningless on another device.

Multi-device

Signing in on a second device performs a full fetch (cursor at epoch). Both devices see the same connections and settings; only external-service tokens and sync cursors are per device, so a service connected on the phone shows on the tablet as “connected on another device” with a button to connect it here as well.

Sign-out and deletion

Sign-out wipes the local cache and secure store entries for the account (secrets store clearAll() is abstract, as in Saymail, so no implementation can forget). Deletion does the same after the server confirms.