Skip to content

Replaying the queue

With autoSync on, the default, the library sends queued writes by itself when the network comes back and when a user's session settles; syncPendingMutations() sends them now and says what happened. A pass sends one user's writes one at a time, in the order they were made, across tables, and stops at the first failure that may pass, so no write overtakes an earlier one.

From the samples: OfflineTaskRepository.kt · LibraryMappings.kt · TaskRepository.swift · QueueWording.swift

/** Sync now: sends what waits, in order, and reports every count of what happened. */
override suspend fun syncPendingMutations(): TaskSyncReport =
    offline.syncPendingMutations().toTaskSyncReport() // returns promptly offline


/** What one replay did, every count of it. */
internal fun SyncReport.toTaskSyncReport(): TaskSyncReport =
    TaskSyncReport(
        syncedCount = syncedCount,
        rejected = rejected.map { it.toRejectedTaskMutation() },
        remainingCount = remainingCount,
        alreadyOnServerCount = alreadyOnServerCount,
        heldBehindOtherTablesCount = heldBehindOtherTablesCount,
    )
/// Sends the queued writes now, in the order they were made, and says what happened; returns
/// at once offline.
@discardableResult
func syncPendingMutations() async throws -> SyncReport {
    try await client.syncPendingMutations()
}

/// What one Sync now did, every count of its report spelled out.
static func syncReport(_ report: SyncReport) -> String {
    [
        "Sent \(report.syncedCount)",
        "refused \(report.rejected.count)",
        "still waiting \(report.remainingCount)",
        "already on the server \(report.alreadyOnServerCount)",
        "held behind other tables \(report.heldBehindOtherTablesCount)",
    ].joined(separator: " · ")
}

When the library sends by itself

Passes are coalesced: at most one runs and one waits, however many triggers arrive.

Trigger Turned off by autoSync = false
The session settles into a user with writes queued Yes
The network comes back: the monitor or your networkAvailability turns online, or enableNetwork() is called Yes
A client without Auth opens with writes an earlier run left queued (its session is settled from the start, so no other trigger fires for them) Yes
A write is queued behind an earlier one; a write sent, refused or discarded leaves writes behind it; a rejected write is retried No

The last row runs whatever autoSync says: a write made online that returned Queued would otherwise wait for the app with nothing to show for it. With autoSync = false, call syncPendingMutations() yourself when the network comes back and when a user signs in.

The connectivity monitor is a hint, never a gate

The library watches the platform's connectivity (Android's ConnectivityManager, counting a network with internet that Android has validated; Network's path monitor on iOS), or your own networkAvailability flow in its place. It never stops a request you make because the monitor says offline: a monitor can be wrong either way (a captive portal, a VPN, a network that cannot reach your project), so every read, write and sync you call is tried. What the monitor decides:

  • while it says offline, the library's own passes stop after one fails for want of a connection, until the monitor stops saying offline, the session settles anew or you call syncPendingMutations(): one attempt per offline period, so a wrong monitor costs one failed attempt;
  • when it turns online, waiting refreshes try again at once and, with autoSync, queued writes are sent;
  • whether a waiting refresh reads Paused or Stalled, and what syncStatus.value.network says.

A monitor that fails or ends is no longer watched, and the network is then UNKNOWN, which counts as online. To stop every request, use disableNetwork() (below): that switch is a gate.

Waiting between passes

After an automatic pass stopped on a failure that may pass, the next automatic pass waits 1 s, doubling up to 60 s, and at least as long as a Retry-After the server sent, up to an hour. The network coming back and the session settling end that wait at once and start it again from 1 s, but never before a Retry-After has passed. A pass you start with syncPendingMutations() goes at once and moves no wait.

When to call sync

Nothing fires when the app comes to the foreground, and after a failed pass the library's own next one may wait a minute, or as long as a Retry-After. Call syncPendingMutations() then, as the samples do (Android from the activity's onStart, iOS when the scene's phase becomes .active):

From the samples: OfflineTasksApplication.kt · AppModel.swift

/**
 * Sends what waits in the mutation queue as the app comes to the foreground: the library starts
 * a replay by itself on a reconnection or a sign-in, and on nothing else.
 */
fun syncInForeground() {
    if (!isConfigured) return
    applicationScope.launchReporting(failures) { repository.syncPendingMutations() }
}
/// Sends the queue as the app comes back to the foreground: the library sends it by itself
/// when the network comes back and as the session settles, but not on this.
func syncOnForeground() {
    guard repository.canReachNetwork, session.userID != nil else { return }
    Task { await perform { try await repository.syncPendingMutations() } }
}

Call it too for a "Sync now" button (the listing at the top), and before a user signs out for good, to send what can still be sent (Signing out). It returns promptly offline, throws nothing for a failure that may pass, and sends nothing while network access is off.

Order and the session

Writes are sent in the order they were made, the order of their ids, across every table of the user's queue, so a child row queued after its parent in another table is sent after it. syncPendingMutations(table) sends one table's writes but never ahead of an earlier write of the user still queued in another table: it stops at the first write that has one and counts what it held back in SyncReport.heldBehindOtherTablesCount. OnlineOnly writes, writes the queue cannot hold, rpc and Storage writes are never queued, so they go around the queue.

A pass first waits, for at most 10 seconds, for the session to settle (signed in, or signed out), except when the network is known to be down. A PostgREST plugin configured with its own jwtToken sends that token whoever is signed in, so the owner check of Signing out and switching users does not apply to it.

What happens to each write

The server answers The write
It accepts it Leaves the queue; it stays in the overlay until the cached lists have been fetched again after it
No connection, a 401, 408, 429 or any 5xx, or a failure on the device Stays queued, and the pass stops; syncStatus.value.lastSyncError says why
PostgREST's schema cache does not know the table or a column yet (PGRST204, PGRST205) Stays queued, and the pass stops; the tenth such refusal, each at least 30 seconds after the last, rejects it
Any other 4xx Moves to the rejected list, and the pass goes on
(The library can no longer replay it exactly as it was made) Moves to the rejected list with no serverError, and the pass goes on

What a sync returns

SyncReport field Meaning
syncedCount The writes this call sent and the server accepted
rejected The RejectedMutations refused during this call, now on rejectedMutations()
remainingCount The writes still queued when the call returned
alreadyOnServerCount Of syncedCount, the inserts whose rows the server already held, because an earlier send whose answer was lost had reached it
heldBehindOtherTablesCount Of remainingCount, the writes a call for one table held back behind an earlier write to another table; 0 for a call over every table

The counts are this call's own: a pass the library ran by itself just before is counted nowhere here, and a refusal it made is on rejectedMutations(). Calling sync more than once at a time is safe: a call that meets a pass already sending the same writes waits for it to let go, then sends what is left, so it returns only once nothing more can be sent now. A pass in progress when local data is cleared is canceled first, and its report counts what it sent before.

A sync throws only IllegalStateException, when the client is closed or PostgREST is not installed. A failure that stops the pass is not thrown: syncStatus.value.lastSyncError holds it as a SyncFailedException, with the HTTP status and the PostgREST or Postgres code and nothing of the original message (Conflicts and failures has the rejected list).

The status

syncStatus is one StateFlow<SyncStatus> to draw a sync banner from:

From the samples: LibraryMappings.kt · SyncHeadline.swift

/** The library's status in the app's terms; the banner, the badges and the Sync tab read it. */
internal fun SyncStatus.toAppSyncStatus(): AppSyncStatus =
    AppSyncStatus(
        network =
            when (network) {
                NetworkStatus.UNKNOWN -> AppNetworkStatus.Unknown // treated as online
                NetworkStatus.ONLINE -> AppNetworkStatus.Online
                NetworkStatus.OFFLINE -> AppNetworkStatus.Offline
                NetworkStatus.DISABLED -> AppNetworkStatus.Disabled // Simulate offline
            },
        pendingMutationCount = pendingMutationCount,
        rejectedMutationCount = rejectedMutationCount,
        isSyncing = isSyncing,
        lastSyncedAt = lastSyncedAt,
        lastSyncError = lastSyncError?.toAppFailure(LibraryCall.Sync),
    )
/// The headline for `status` and the task list's `refresh`, in order of what matters most.
static func of(status: SyncStatus, refresh: RefreshState) -> SyncHeadline {
    let pending = status.pendingMutationCount
    if status.network == .offline || status.network == .disabled { return .offline(pendingCount: pending) }
    if status.isSyncing, pending > 0 { return .syncing(pendingCount: pending) }
    if case .stalled = refresh { return .showingSavedData }
    if pending > 0 { return .waiting(pendingCount: pending) }
    return .upToDate
}
Field Meaning
network UNKNOWN (nothing has said yet; counts as online), ONLINE, OFFLINE or DISABLED (after disableNetwork())
pendingMutationCount, rejectedMutationCount The current user's queued writes and rejected entries. They follow each change to the queue shortly after it, read together, so a write moving between the two is counted in exactly one
isSyncing Whether a pass is sending, started by you or by the library
lastSyncedAt When a pass, or a write sent at once, last left the user's queue empty. Kept in memory only
lastSyncError Why the last pass stopped with writes queued; cleared when a later pass runs to the end, when sending empties the queue, and when local data is cleared

It follows the queue from the moment the client opens, so its counts include what an earlier run left queued. It is the same flow whether or not the client is open, never throws, and after close() holds its last value.

The network switch

disableNetwork() turns the library's network access off, as airplane mode would, for this client only; enableNetwork() turns it back on, and with autoSync queued writes are sent. The switch is not kept: a new client starts with access on.

From the samples: OfflineTaskRepository.kt · TaskRepository.swift

/** Simulate offline: the library's network switch, which nothing else on the device notices. */
override suspend fun setNetworkEnabled(enabled: Boolean) {
    if (enabled) offline.enableNetwork() else offline.disableNetwork()
    // The library's status is current when the call returns; this one follows it.
    syncStatus.first { (it.network == AppNetworkStatus.Disabled) != enabled }
}
/// Switches the app's own network access off (Simulate offline) or back on.
func setNetworkEnabled(_ isEnabled: Bool) async throws {
    if isEnabled {
        try await client.enableNetwork()
    } else {
        try await client.disableNetwork()
    }
}

While access is off nothing is sent: reads are answered from the cache, live reads read Paused, writes that can be queued are queued, passes send nothing, and a Storage download is answered from the file cache. A request that must reach the server (a NetworkOnly read, an OnlineOnly write, an rpc, any Storage call but a download) throws NetworkDisabledException, a connectivity failure. A request already on the wire still answers, and so does a resumable upload created or paused while access was on. syncStatus.value.network reads DISABLED as soon as disableNetwork() returns; a flow you derive from syncStatus catches up a moment later, so wait for the value you expect on it.

Background work

The library sends only while your app's process runs. To send from the background, call syncPendingMutations() from work the platform schedules in the app's own process (a WorkManager worker on Android, a BGTaskScheduler task on iOS), never from a second process (Project setup).

At a glance

  • syncPendingMutations reports what it sent, what the server refused (with its ServerError) and what is still queued.
  • Offline it returns at once, with the writes still queued, and syncStatus.lastSyncError says the network.
  • lastSyncError carries the status and code, never the server's text, a filter value or a token.
  • An insert whose earlier send had in fact arrived is counted in alreadyOnServerCount, not inserted twice.
  • A sync that meets the library sending the same writes waits for it, then returns with what is still queued after both.
  • With autoSync on, the network coming back, network access turned back on, and the session settling anew send queued writes by themselves.
  • With autoSync = false those three send nothing, but an online write queued behind an earlier one is still sent without the app asking.
  • After a pass stopped on a server's Retry-After, the library's own next pass waits at least that long, at most an hour.
  • syncStatus follows the queue, the rejected list and the passes, and says when the queue was last emptied.
  • syncStatus.network is DISABLED as soon as disableNetwork() returns; a new client starts with access on.
  • While access is off, a Storage download is answered from the file cache, and an upload, a delete or a move throws NetworkDisabledException, leaving the cache as it was.
  • A sync for one table stops at a write that has an earlier write of the user queued in another table, and counts what it held back.
  • A failure that may pass, a server's 5xx included, stops the whole pass rather than skipping ahead.
  • While the monitor says offline, the library makes one failed attempt by itself, then waits for the network, the session or the app; when the server answers, it keeps sending.
  • A reconnection sends at once, however long a wait failed passes built; a server's Retry-After still holds.
  • A client without Auth sends what an earlier run left queued when it opens, and only that: a write made as it opens is sent at once, by itself.
  • The status counts what an earlier run left queued from the moment the client opens.