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_previousthe 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 differentsnapshot_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 acontact_updatedwas about (Contacts §3.1.5); - invitations, notifications (last 90 days), sync services and sync links, and the
sync_decisionsqueue of reconciliation decisions waiting to be replayed; - a
sync_cursorstable with a keyset cursor per table: theupdated_atandidof 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
| Trigger | Action |
|---|---|
| App start and foreground | Delta 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 notifications | Delta fetch immediately |
| Push notification (background) | Silent push triggers a delta fetch so the tap lands on fresh data |
| Pull to refresh / header refresh | Same 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.