Status and offline
Ferry is offline-first: the app reads and writes the device's data whether or not there is a network, and syncs when it can. sync.status says where syncing is, for the few places the UI cares.
const { phase, pending, online, synced } = sync.status;
const stop = sync.onStatus((status) => {
console.log(status.synced ? 'All changes saved' : `${status.pending} changes not synced yet`);
});The status
| Field | Type | Meaning |
|---|---|---|
phase | see below | What the client is doing |
pending | number | Writes not yet confirmed by the server |
oldestPendingAt | number | null | Epoch ms of the oldest unconfirmed write, for "not synced since…" |
lastSyncId | number | How far into the server's log this device has caught up |
lastError | string | null | Why the last attempt failed |
lastSyncedAt | number | null | Epoch ms of the last round the server answered in full, for "synced 5 min ago" |
online | boolean | phase !== 'offline' |
synced | boolean | pending === 0 && phase === 'idle': everything is saved on the server |
phase | Meaning |
|---|---|
'starting' | Loading storage; nothing is readable yet |
'idle' | Nothing to send, and caught up as far as the client knows |
'syncing' | Sending or receiving |
'offline' | The last attempt failed (no network, server down); retrying with backoff |
'updateNeeded' | The server refused this schema version; writes keep queueing on the device |
sync.status is the same object until something in it changes, so it can be compared by identity
and fed to useSyncExternalStore as is. In React, use
useSyncStatus().
What offline means
Nothing changes for the app. Writes apply on the device, are stored in an outbox that survives
restarts, and wait. The client retries with exponential backoff (1 s, doubling, up to 60 s: the
retry option of createSync), and sends the outbox in order once the server answers again.
Most apps need one indicator, if any:
import type { ClientStatus } from 'ferrysync';
const label = (status: ClientStatus) =>
status.synced ? 'Saved' : status.online ? 'Saving…' : 'Offline: changes will sync later';The network
Outbox, on the device
1 waiting
sync.status
phase: 'offline'· pending: 1
Next attempt in 1 s
Attempts, newest last
- 0 sFailed, waits 1 s
syncNow()
Pushes and pulls now, skipping the backoff wait. Call it when the app knows the network is back, or when it comes to the foreground.
window.addEventListener('online', () => void sync.syncNow());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
void sync.syncNow();
}
});On React Native:
import { AppState } from 'react-native';
AppState.addEventListener('change', (state) => {
if (state === 'active') {
void sync.syncNow();
}
});syncNow() resolves when that round ends and never rejects: read sync.status to see how it
went. It also retries a client that was told 'updateNeeded'.
Waiting: whenSynced() and flush()
Most code never waits: writes apply at once and sync by themselves. Two promises cover the few places that must.
// Everything the user did reached the server (or the wait timed out).
await sync.whenSynced({ signal: AbortSignal.timeout(5_000) });
if (!sync.status.synced) {
console.warn(`${sync.status.pending} changes are not on the server yet`);
}
// Every write so far is stored on the device: safe to let the process end.
await sync.flush();| Call | Resolves once | Rejects |
|---|---|---|
whenSynced({ signal? }) | status.synced; or signal aborted; or the client closed | never |
flush() | every write made so far is stored on the device (not on the server) | never |
whenSyncedcan wait forever while the device is offline: pass asignalto give up, then readsync.status.syncedto know which way it ended.- Both follow
reset.whenSyncedwaits for the new start;flushwaits for the writes of the current one (a reset wipes the others anyway). flushis for processes that end: a CLI, a test, a background task. In an app, storage keeps up by itself.
refetch()
Downloads everything again from the server and replaces the device's copy, keeping the writes that are still pending. Use it for recovery (a "Reload data" button in a support screen), not in normal operation: the client already catches up by itself.
try {
await sync.refetch();
} catch {
// The round ended without a download: offline, or an app update is needed.
}Unlike syncNow, refetch rejects when the download did not happen.
Realtime
With an http() backend, the client hears about other devices' changes as they
happen: it holds a long-poll request open on the server, which answers as soon as there is something
new for this user, and the client pulls. That is the default.
import { createSync, http } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({
schema,
backend: http({ url: 'https://api.example.com/sync', realtime: false }),
});With realtime: false, the client syncs only when it starts, after each local write, and when the
app calls syncNow(). To hear other devices' changes without holding a request open (a server
behind a proxy that cuts long requests, a battery budget), poll instead:
import { createSync, http } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({
schema,
backend: http({ url: 'https://api.example.com/sync', realtime: false, pollIntervalMs: 30_000 }),
});Each poll pulls through the client's own loop: while offline it waits out the backoff, and it stops
with the client. pollIntervalMs without realtime: false throws, since the two do the same job.
When the app must update
After a breaking schema change, the server may stop accepting an old version. That client keeps working on the device, queues its writes, and tells you once:
import { createSync } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({
schema,
onUpdateNeeded: (message) => promptToUpdate(message ?? 'Please update the app to keep syncing.'),
});Its phase stays 'updateNeeded' until the app is updated, which sends the queued writes, or until
syncNow() tries again.
Gotchas
pendingcounts writes, not rows. A transaction of ten rows is one.syncedis about this device. Another device's unsynced writes are invisible until it syncs.lastSyncedAtstarts atnullon every launch: it says when this client last reached the server, not when the data on the device was last fresh.- Local-only writes never count as pending: they are never sent.
- With
local()(no server), the device confirms its own writes a moment after they are made:pendinggoes back to 0 on the next tick.