Ferry
Client

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.

Client
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

FieldTypeMeaning
phasesee belowWhat the client is doing
pendingnumberWrites not yet confirmed by the server
oldestPendingAtnumber | nullEpoch ms of the oldest unconfirmed write, for "not synced since…"
lastSyncIdnumberHow far into the server's log this device has caught up
lastErrorstring | nullWhy the last attempt failed
lastSyncedAtnumber | nullEpoch ms of the last round the server answered in full, for "synced 5 min ago"
onlinebooleanphase !== 'offline'
syncedbooleanpending === 0 && phase === 'idle': everything is saved on the server
phaseMeaning
'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:

Client
import type { ClientStatus } from 'ferrysync';

const label = (status: ClientStatus) =>
  status.synced ? 'Saved' : status.online ? 'Saving…' : 'Offline: changes will sync later';
InteractiveOffline, and retrying

The network

Outbox, on the device

1 waiting

sync.status

phase: 'offline'· pending: 1

Next attempt in 1 s

Attempts, newest last

  1. 0 sFailed, waits 1 s
Time runs 4x faster here. The wait doubles after each failure (1 s, 2 s, 4 s…) up to 60 s, and resets after a success. Bringing the network back does not cut the wait; syncNow() does.

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.

Client
window.addEventListener('online', () => void sync.syncNow());
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    void sync.syncNow();
  }
});

On React Native:

Client
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.

Client
// 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();
CallResolves onceRejects
whenSynced({ signal? })status.synced; or signal aborted; or the client closednever
flush()every write made so far is stored on the device (not on the server)never
  • whenSynced can wait forever while the device is offline: pass a signal to give up, then read sync.status.synced to know which way it ended.
  • Both follow reset. whenSynced waits for the new start; flush waits for the writes of the current one (a reset wipes the others anyway).
  • flush is 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.

Client
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.

Client
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:

Client
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:

Client
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

  • pending counts writes, not rows. A transaction of ten rows is one.
  • synced is about this device. Another device's unsynced writes are invisible until it syncs.
  • lastSyncedAt starts at null on 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: pending goes back to 0 on the next tick.

Next steps

On this page