OfflineFirstFramework

2.1.0

Offline-first store and sync engine for Swift: actor-based storage, conflict resolution, CRDTs and HTTP sync.
muhittincamdali/iOS-Offline-First-Framework

What's New

2.1.0

2026-10-21T18:17:27Z

Fixes for the data-integrity findings of two audits of 2.0.0 and of their second, third,
fourth, fifth, sixth, seventh, eighth, ninth, tenth, eleventh, twelfth and thirteenth rounds, and of a review of this release, each with a test
that failed before the fix. The documentation article "Guarantees and limits" and a section of the README
now state what the store promises and what it needs from the server.

Fixed

  • A server restart or a restore from a backup could reuse a revision. A device's older offline
    edit was then accepted over a newer write without a conflict, and a full pull skipped the
    changed record as an echo. The reference servers now put an epoch in every revision; the
    store pulls everything again when the epoch or the server changes, keeping pending changes.
  • The conflict resolver was asked about server versions older than the stored one and about
    records without a local change: .serverWins rolled records back and brought deleted ones
    back, .clientWins made passive devices overwrite accepted writes. Older versions are now
    skipped, ordered by the server's sequence, and only a real local change meets the resolver.
  • The deletion of a record the store never held was not kept, so an older page could create
    the record. Two deletions now go to the resolver, so the newer one stays.
  • A timestamp years ahead won every last-write-wins conflict on every device. Timestamps more
    than maximumClockDrift ahead now count as at that limit when the store weighs them, and the
    reference servers move a pushed timestamp more than five minutes ahead of their clock back to
    that limit. They send back the change ID that wrote each version, by which a device whose
    clock runs ahead still recognises its own write whose answer was lost.
  • discardChange(for:) during a pull lost its token reset, and the server's copy never came
    back.
  • A change whose push answer was lost could be resolved again by a pull and pushed over a newer
    write. It now stays in doubt until its next push shows whether the server applied it.
  • After its own accepted write, the store could skip the server's newer version when that
    version had an older timestamp, and keep its own copy for good.
  • A server that answered every push with a conflict kept the store pushing forever. Conflicts
    now count as failed attempts with backoff and parking; a conflict answer for another record
    counts too.
  • One record the app could not decode stopped every pull, and one unknown push outcome failed
    the whole push. Such changes are now set aside in quarantinedChanges() and the rest goes on.
  • A server could grow the store file past the size it reads, after which the store did not
    open again. FileStore now refuses such writes, and .moveAsideAndStartEmpty recovers a file
    that is too large.
  • Change tokens that went round in a circle cost 100 requests per sync, a page whose token did
    not move was stored before the check, and + in a token reached form-decoding servers as a
    space.
  • sync() called while a sync was pulling returned without pushing a change saved just before.
  • Two store instances on one file silently dropped each other's saved records. With
    FileStore, InMemoryStorage and a custom backend that reports
    StorageBackend.claimIdentity (a backend that wraps a built-in one can forward its
    identity), the second instance's write now throws
    OfflineFirstError.storeChangedElsewhere(name:); a FileStore reached through a symbolic
    link shares the protection. A custom backend without an identity, the default, is not
    protected: the instance that writes last still drops the other's records.
  • A transport that reported its cancellation as URLError(.cancelled) counted a failed attempt,
    and a full disk while counting an attempt hid the transport's error.
  • Right after the server accepted the store's own write, a page from a lagging replica rolled
    the record back or brought back the store's own deletion, with every policy, and an edit of
    the rolled-back copy replaced the acknowledged write. Until the write's echo arrives, the store
    now knows the write is newer than every version up to the one it replaced, its own earlier
    accepted writes included, and keeps that in its file.
  • A change made on top of a change whose answer was lost met the device's own earlier write as
    a conflict: .serverWins dropped the new edit or brought a deleted record back, and a union
    merge brought back a removed item. A change now remembers the timestamps of its earlier
    versions that were in doubt, and a server version with one of them is recognised as the
    device's own write: the newer change goes out on top of it without the resolver, and a
    pending change the server already holds is settled as accepted. Node IDs alone are not
    trusted for this, since a store file restored onto a second device shares its node ID.
  • On a server without sequences, a version without a timestamp was ordered by the time the
    store received it, so another device's newer version could be skipped for good. The store no
    longer orders the versions of such a server by any time.
  • On a device whose clock was set back by more than maximumClockDrift, newer edits from other
    devices were moved below the device's own older edit, and last write wins pushed the older
    edit over them. The limit now counts from the newest timestamp the store holds when that is
    later than the device clock.
  • After a server restart, a page from a stale replica or cache could roll records back. Epochs
    now have an order (a longer one is later; of two as long, the greater byte by byte): a page of
    another epoch than the store's counts only when two requests of one sync report that epoch, so
    one page from another deployment behind the same URL or a stale replica does not move the
    store. An earlier epoch is never taken. What a push answer tells about a record (an accepted
    write, or the version a conflict answer brought) moves into a later epoch until a pull brings
    that version: the push may have been answered after a restart the store had not seen, and a
    page of the new epoch from before it no longer rolls the write back or makes the next edit
    meet the device's own write as a conflict.
  • A push that failed while the device clock was far ahead waited for a retry date that far
    ahead after the clock was set right, unreported; the circuit breaker's pause and the wait
    after a failed file write did the same. A wait longer than the policy allows now counts as
    passed.
  • While the store file could not be written (a full disk, file protection, another instance), a
    change in doubt went out again on every sync, and automatic sync sent it every second. After a
    failed write, syncs now wait with the retry policy's backoff, and automatic sync waits after
    failed syncs.
  • With maximumPullPages set to 1, a server restart stopped the pull for good: the page that
    reported the new epoch used the sync's only page, so the pull from the beginning that confirms
    the epoch never came, and every sync reported success with hasMoreChanges. A shared cache
    that answered the pull from the beginning with an older page made each sync spend all its
    pages without progress, and a cached page of an even earlier epoch was taken after eight
    syncs, rolling every record back. Both requests that confirm an epoch now belong to one step
    of the pull; when they disagree, the sync fails without a change to the store.
  • After resetChangeToken() a stale page of a later epoch rolled the device's accepted write
    back with every policy, and one page of an earlier epoch from a lagging replica was taken at
    once, bringing back deleted records. Epochs keep their order after a reset, and a later one
    keeps what push answers told.
  • A device whose clock ran ahead won last write wins over edits other devices made up to as much
    later as its clock was ahead (ten minutes, a day), since the servers kept pushed timestamps as
    sent; one moment of a clock a year ahead stamped every later write a year ahead. The servers bound timestamps by
    their clock again, and the store's clock goes on from the drift limit once the device clock is
    back.
  • One change nested deeper than 512 levels, in its record or in a field the app ignores, made
    JSONDecoder refuse the whole page, so the token never moved and no later change arrived; one
    such conflict answer made the whole push answer unreadable. HTTPSyncTransport now reads each
    change and each outcome on its own and sets such a change aside. Reading them on its own also
    made a page or push answer that starts with a UTF-8 byte order mark fail as a whole; the mark
    is skipped again, and a body in UTF-16 or UTF-32 fails with an error that names the encoding.
  • A record nested a few hundred levels deep, such as an outline 180 blocks deep in a page of
    5 KB, ended the process with a stack overflow when the store file was written, on every sync
    and in debug and release builds alike, whatever server or transport delivered it. Records are
    now limited to 64 levels (OfflineStore.maximumRecordDepth), as the reference server limits
    them: a deeper one from a page or a push answer is set aside before it is decoded or stored,
    a merge that makes one keeps the server's version, and save(_:) refuses one.
  • A 16 MiB page of elements that are not changes, {"changes":[0,0,...]}, took gigabytes of
    memory and seconds of work on every sync, which iOS ends, so every launch crashed again. A page
    now lists at most 100 changes it sets aside, is refused beyond 1,000 that cannot be read, and
    stops being read when the sync is cancelled or passes its deadline.
  • Records within the reference server's limits added up to a first page larger than the 16 MiB
    the transport reads (68 records of 250 KiB made 17 MB), so every device that pulled from the
    beginning, a new one, one after resetChangeToken() and all of them after a server restart,
    failed on that page for good. The reference server cuts its pages at 8 MiB; the transport sends
    limit, asks for half as many changes while a page is too large, and sets aside a single
    change that is larger than the limit, moving past it.
  • A few large records made a push larger than the reference server's 1 MiB body limit, so every
    change of the batch failed with status 413 until it was parked. The transport splits a push
    into requests of at most maximumRequestSize and halves a request the server refuses with
    413; a single change the server refuses so stays in doubt and is retried, then parked.
  • After one failed write of its data file (a full disk, a missing permission), the reference
    server never wrote the file again, and pages showed a push whose write had failed: a device
    that pulled it took its own write for applied, and a restart lost the write on the server
    only. Writes now go on after a failure, and pages show a push only once the file holds it.
  • A server that ignored limit could send a 16 MiB page of half a million small, valid changes:
    the transport read them all and the store applied them in one step of seconds and more than a
    gigabyte that no deadline stopped, on every launch. A page with more changes than its pull
    asked for is now refused, by HTTPSyncTransport as soon as the change after the limit starts,
    and by the store from any transport beyond 1,000.
  • Setting aside a change larger than the response limit read the page without a limit: a change
    that never ended was read until the sync's deadline, and a page that ignored limit=1 was
    read to its end. That read now stops when a second change starts and after four times
    maximumResponseSize, or before it starts when the page announces more.
  • Conflict answers bring the server's version of each record, so a device that pushed 70
    offline edits of records another device had grown to 250 KiB, within the reference server's
    limits, got an answer of about 17.5 MiB, more than the 16 MiB the transport reads. Every
    push failed with responseTooLarge, the edits were parked, retryFailedChanges() met the
    same answer, and the device never got the other device's versions while the server never
    got its edits. The transport now sends a request whose answer is too long again as two
    halves, which the server answers from its memory of change IDs; a single change whose
    answer alone is longer than the limit stays in doubt and is retried with backoff, and the
    rest of the push and the sync go on.
  • A 503 or 504 of an overloaded proxy that announced a body of 17 MiB, or a 502 error page that
    streamed without end, was taken for a push answer too long to read: the transport sent the
    request again in halves down to single changes, 2n - 1 requests to a server that was already
    failing, read up to 16 MiB of each error page, and parked every change of the sync as
    rejected. Of an answer outside 2xx the transport now reads the first 4 KiB and throws
    unexpectedStatus: one failed attempt with backoff. Halving adds at most 16 requests to a
    push, and only 413 or a 2xx answer longer than the limit halves a request.
  • A change whose answer nobody read (a 504 or a long 200 from a proxy after the server had
    applied the push, or a single conflict answer longer than the limit) was rejected as not
    applied, which dropped its in-doubt mark. Another device's later edit then met it as a
    conflict, and with .clientWins retryFailedChanges() put the old edit back over that edit
    on the server and every device. Only an answer that was read settles a change now: such a
    change stays in doubt, keeps the newer version a pull brings, and learns its fate from its
    next push under the same change ID.
  • After resetChangeToken(), a lagging replica or a shared cache that answered both requests
    of the epoch check with the epoch the store had left at a server restart was taken at once:
    every record went back to the replica's copy, and a pending edit the server never saw met
    the device's own older write in the resolver, so .serverWins dropped it on the device and
    the server. Epochs now go one way: the store retires an epoch it leaves for a later one,
    keeps the last 32 in its file through resets and refuses them in every sync.
  • A replica that answered eight syncs in a row with an epoch the store never held decided
    pending edits from its stale pages. A change made on an earlier server history now meets the
    new one through its push's answer only.
  • That replica's epoch, the one between two server restarts, was still taken after eight syncs
    in a row: every record without a pending change went back to the replica's copy, also the
    device's own accepted write, and the user's next edit met that write in the resolver, so
    .serverWins dropped the edit on the server and the device. An earlier epoch is now refused
    in every sync, whether the store left it or never held it.
  • The store forgot a write once the server accepted it. When a page the store could not order
    took the record back and the user edited on top of it, the server's answer, or a later page,
    that named the device's accepted write met the edit as another device's version. Each record
    now keeps the change IDs and timestamps of its last 16 writes the server holds or held, and a
    version one of them wrote is the device's own, never a conflict.
  • A page that listed an older own write of the device before a version whose answer was lost,
    as the first page after a server restart can, made the store forget the versions in doubt of
    the user's next edit, and that version met the resolver as another device's write:
    .serverWins dropped the edit on the device and the server, a merge merged it with the
    device's own write. Only a push answer settles versions in doubt now, and they then stay known
    as the device's own writes.
  • On a server without sequences, a device clock that ran ahead, for a moment or for good, hid
    every later version of a record the device wrote, and its later edits of that record were
    parked for good: the store ordered versions by timestamps and took the timestamp it pushed as
    the server's. Server versions are now ordered only by the server's sequences and by the
    revisions the device's own writes replaced, never by timestamps, and a pull leaves a pending
    change that meets a version without a sequence to its push, whose conflict answer names the
    server's current version.
  • On a server without sequences, a stale page that brought back an older write of the device
    while the user's edit of a newer version waited made the store base the edit on that write:
    the push then met the newer version as a conflict, and .serverWins dropped the edit. The
    store now bases a change on its own write only when the server's sequences show that write is
    newer than the version the change is based on; otherwise the version waits for the push.
  • A version a pull kept back for a change in doubt replaced the push's conflict answer whenever
    the server sent no sequences, so the resolver met a stale page instead of the version the
    server named: an answer that named the device's own write still asked the resolver,
    .serverWins dropped the user's last edit, and a merge brought back an item another device
    had removed. The answer now decides: the device's own write is never a conflict, a kept-back
    version never takes the answer's place, and when it may be newer, the change goes out once
    more under a new change ID, so the server answers with its current version.
  • A page without an epoch was taken from a single request while the store held an epoch: one
    page from another deployment behind the same URL could give a record a sequence that made
    every later conflict answer of the real server look older, so each edit of the record was
    parked, also after retryFailedChanges() and resetChangeToken(). Once the store holds an
    epoch, a page without one is checked by a second request like another epoch and never taken.
  • The first epoch was taken from a single page. One page with an epoch from another deployment
    behind the same URL therefore locked a store of a server without epochs out for good: every
    later page of the server named no epoch and was refused, also after resetChangeToken() and
    retryFailedChanges(), other devices' records never arrived, and with sequences the device's
    edit was parked. A store now takes its first epoch only when a second request reports it
    too, and goes on after its token.
  • A new store took its first page from a single request. From another deployment behind the
    same URL that page brought records the server never held, which stayed on the device and
    were created on the server once edited; a token the pull went on after and sequences that hid
    the server's versions, so the device showed the other copy, other devices' edits never
    arrived and its own edit never reached the server, while every sync succeeded; and with a
    later epoch than the server's, every later sync failed, also after a server restart and
    resetChangeToken(). A new store's first page, with an epoch or without, is now checked by a
    pull after its token; when the two disagree, a third pull from the beginning decides, and the
    other page is dropped. A store file that holds an epoch only one request reported checks it on
    its next sync, and an earlier epoch or none that two requests report instead starts a new
    history, in which the records the unchecked history brought go unless they have a pending
    change; after such a history without an epoch, the server's epoch starts a new history
    instead of going on after the other deployment's token.
  • When a page, or a conflict answer from a server that does not remember change IDs, showed
    that the device's change had been applied although its answer was lost, the store did not
    record the version the change replaced as older, as it does after an accepted answer. From a
    server without sequences a stale page (a lagging replica after resetChangeToken()) then
    brought that version back, and the user's edit of it replaced the applied write without a
    conflict. All three ways now follow one rule.
  • An accepted push answer took a version a pull had kept back for the change whenever it was
    another one, while a conflict answer already followed the server's sequences. A stale page (a
    cache after resetChangeToken(), a replica that sticks to the device) thus replaced the
    device's accepted write, and an edit of what the app showed dropped what that write held,
    without a conflict. Every push answer now follows one rule: the kept-back version never takes
    the answer's place. When it may be newer, the answer stands and the next sync pulls from the
    beginning, which brings a newer version the server holds.
  • After a server restart that lost the data, or a restore from a backup, a push answered from
    the later history before a pull showed the store that history lost to a version a pull had
    kept back from the earlier history, by its higher sequence: the device showed that version
    instead of its accepted write, with every policy, and the next edit replaced the write on the
    server without the resolver. A push answer names no epoch, so a kept-back version no longer
    outranks it by a sequence; a version that is newer in the same history shows one sync later.
  • A schema migration rewrote the stored records but not a version a pull had kept back for a
    change in doubt. After an app update with a RecordMigration, a file holding such a version
    failed to open with corruptStore, and .moveAsideAndStartEmpty set the file aside with its
    pending changes. The kept-back version is now migrated with the records.
  • A store file of 2.0.0 does not say whether a pending change is in doubt, and the store read it
    as not in doubt. An edit whose push the server applied while its answer was lost then met a
    later edit made on top of it, by another device, as a conflict in the first pull after the
    update, and .clientWins and the merge put the device's edit back over it on the server. A
    pending change of such a file now counts as in doubt when its record was sent or the server
    holds it, so its push decides. A value of the sync state that the file holds but that cannot
    be read fails the open instead of counting as missing.

Added

  • RemoteChange.sequence, PullResponse.epoch, PullResponse.quarantined,
    SyncTransport.serverIdentity (default nil) and QuarantinedChange.
  • OfflineStore.resetChangeToken(), quarantinedChanges(), SyncReport.quarantined,
    Configuration.syncTimeout (5 minutes) and Configuration.maximumTombstones (10,000).
  • InMemorySyncServer.restart() and InMemorySyncServer.maximumClockDrift.
  • RemoteChange.changeID: the ID of the pushed change that wrote a version, by which the store
    recognises its own writes; without it, the store compares the timestamps it pushed.
  • OfflineStore.maximumRecordDepth, OfflineFirstError.recordNestedTooDeeply(limit:),
    PullResponse.quarantinedCount, and the pageSize and maximumRequestSize parameters of
    HTTPSyncTransport.
  • OfflineStore.maximumChangesPerPage (1,000).
  • StorageBackend.claimIdentity (default nil), HTTPSyncTransport.errorBodyLimit (4 KiB)
    and HTTPSyncTransport.maximumSplitRequests (16).

Changed

  • The circuit breaker now pauses pulls only; changes that are due are still pushed, and
    SyncReport.skipped means the pull was skipped.
  • OfflineFirstError has the new cases storeChangedElsewhere(name:) and
    recordNestedTooDeeply(limit:); a switch over it without default needs them.
  • HTTPSyncTransport.Failure.unknownOutcome is no longer thrown.
  • While a sync waits after a failed write of the store file, sync() throws that error without
    a request; the next write that succeeds ends the wait.
  • SyncReport.conflicts no longer counts a conflict with the device's own earlier write.
  • Without RemoteChange.sequence the store no longer orders a server's versions at all: a stale
    page can roll a record back until it changes again, and a pull leaves a pending change to its
    push. Servers that want stale pages skipped and older versions of deleted records ignored
    must send sequences; the reference servers do.
  • The reference server starts a new epoch on every start, also with --data, so each client
    pulls everything once after a restart, and it saves the answers to change IDs with its data.
  • Servers must report epochs that only move to later ones: longer, or as long and greater byte
    by byte, and keep counting sequences across a restart that keeps the data. Both reference
    servers make each epoch from the time in microseconds (16 hexadecimal digits), and at least
    one more than the last epoch they handed out in the process or, with --data, saved.
  • Servers that report an epoch must report it on every page: while the store holds an epoch that
    two requests reported, it refuses a page without one, and a server that stops reporting epochs
    is refused until it reports one again. A store checks the first epoch it hears of with a
    second request, a new store too: the pull after its first page's token, and when the two
    disagree a third one from the beginning, so a new store's first sync makes one or two pulls
    more, and a server should answer a token it did not hand out as the pull from the beginning.
  • Servers should send back the change ID that wrote each version and may bound pushed
    timestamps by their clock; a server without change IDs has to keep timestamps as they were
    pushed. The reference servers do the first and bound timestamps to five minutes ahead.
  • A single page of an earlier epoch than the store's no longer fails the sync when the pull from
    the beginning names the store's epoch; two requests that name different epochs fail it with
    OfflineFirstError.invalidServerResponse.
  • The Node reference server rejects a record nested deeper than 64 levels or larger than 256 KiB
    of JSON, with a reason, and applies the other changes of the push.
  • HTTPSyncTransport sends limit with every pull and pushes in requests of at most 1 MiB.
    Servers must keep every page within the client's response limit: at most limit changes, cut
    by bytes, at least one change per page. The reference server cuts pages at 8 MiB.
  • The answer to a single pushed change must fit in the client's response limit; with a longer
    one the change stays in doubt and is retried, then parked. A push answer that is too long as
    a whole no longer fails the push.
  • HTTPSyncTransport throws Failure.unexpectedStatus for every status outside 2xx, whatever
    length the answer announces, with at most its first 4 KiB as body (500 bytes before), and
    Failure.responseTooLarge for 2xx answers only. Only a 2xx answer that was read settles a
    pushed change.
  • resetChangeToken() forgets the token only; it no longer takes an earlier epoch at once. A
    store never takes an earlier epoch than its own, one it left or one it never held, so servers
    must never report an earlier epoch, nor an epoch again once they reported a later one. A store
    that followed another deployment behind the same URL with a later epoch returns to the first
    server once that server reports an epoch later still.
  • A page may hold at most the limit its pull asked for and, from any transport, 1,000 changes;
    InMemorySyncServer.pageSize is at most 1,000. A page beyond limit fails with a
    DecodingError instead of HTTPSyncTransport.Failure.responseTooLarge, and a single change
    larger than four times maximumResponseSize is no longer set aside.
  • The Node reference server's Collection.push takes { publish: false } and publish(), and
    createSyncServer runs the saves one after another itself: a push is answered and shown in
    pages once its save succeeded.

Known limitations

Answers that come from another deployment than the server (a load balancer, DNS or proxy that
sends a request to staging instead of production or to a region with separate data, or a
replica that still serves the history from before a data loss) are only partly covered: the
epoch check keeps out one page, not these cases. Details and the way back are in
"Guarantees and limits". Each item has an issue with an example, the way back and the plan:
#26,
#27,
#28,
#29,
#30,
#31,
#32,
#33.

  • A new store whose first sync reaches only another deployment takes that deployment's state:
    without an epoch it goes on after that deployment's token, which a server whose tokens are
    positions never corrects (resetChangeToken() does); with a later epoch every later sync
    fails until the server reports a later epoch still; a copy with a sequence beyond the server's
    stays, and an edit of it parks.
  • When the pulls of a sync reach a replica of the history from before a data loss while the push
    reaches the server, in a new store's first sync or in a store that holds the epoch the replica
    reports, the device's own accepted write can be replaced on the device and then by its next
    edit on the server (random runs: 2 of 24,000 for seeds 200 to 699, 8 of 24,000 for seeds 5000
    to 5499, all with a server that lost its data).
  • One push answer from another deployment, a conflict or rejected answer with a sequence beyond
    the server's, makes the device show that deployment's copy and skip the server's later
    versions of the record without an error; with .lastWriteWins and .serverWins the edit can
    go, with .clientWins and a merge it parks. An accepted answer from another deployment, after
    a first sync it answered too, leaves the write off the server while the device keeps it.
  • These cases do not end by themselves, also not after a server restart: move the store file
    aside and start a new store, saving again what the old one held that the user still needs.
  • A server restored from a backup that keeps its answers to change IDs from after the backup
    holds a retried change back until it parks. Servers should restore that memory with the data;
    on the device, discardChange(for:), a sync and the edit saved again get a fresh answer.

Offline-First Framework: A local store for Swift apps that keeps working offline and syncs with your server.

English · Türkçe · 简体中文 · 日本語 · 한국어 · Español · Deutsch · Français · Português (Brasil) · Русский

CI status Latest version: v2.1.0 Supported platforms: iOS 17+ · macOS 14+ · tvOS 17+ · watchOS 10+ · visionOS 1+ Language version: Swift 6.2+ License: MIT

Get started Documentation Examples

A Swift package that keeps your app's records in a file on the device, queues every change, and syncs with your server whenever there is a connection. Saving never waits for the network, so the app works the same on a plane, in a basement or on a flaky train connection. When the same record changed on two devices, a conflict policy you choose decides what is kept. It works with any backend: a small HTTP protocol with a reference server, your own API through a two-function protocol, or an in-memory server for tests and previews. The package has no dependencies.

Highlights

  • Writes that never wait — save and delete go to an atomically written file and a durable queue of pending changes that survives restarts; failed pushes are retried with exponential backoff and jitter, and parked after the last attempt instead of being lost.
  • Conflicts decided on purpose — last write wins on hybrid logical clock timestamps (they never go backwards and give every device the same order), server wins, client wins, your own merge, or conflict-free fields: LWWRegister, LWWMap, GCounter, PNCounter and ORSet.
  • Any backend — HTTPSyncTransport speaks a documented JSON protocol that the reference server in Examples/SyncServer implements in about 280 lines of JavaScript; any other API fits behind SyncTransport, for example a REST API with ETags.
  • Made for SwiftUI — SyncedCollection is an @Observable model of a store; automatic sync runs when the device comes online and shortly after local edits, and one call syncs from a background refresh task.
  • Data at rest protected — AES-GCM file encryption with a key kept in the Keychain, data protection classes, record schema migrations, and a policy for damaged files.
  • Tested against a real server — 375 Swift Testing tests on macOS and 372 on an iPhone simulator, among them three property tests that run every conflict policy through lost requests, restarts, token resets, overlapping syncs, small page limits, stale pages, operations at once and crashes, also against a server without sequences, scenario tests of a replica that answers a device for many syncs, of a stale page that brings back an older own write or meets an accepted push, of a crash at every write of a sync and of device clocks that jump ahead, and tests that send pages of junk, deeply nested records, pages larger than the response limit and pages with more changes than asked for, also streamed endlessly over a socket; 28 Node tests for the server; and an end-to-end suite in CI that syncs stores through the server over HTTP, also with 68 records of 250 KiB across a server restart, with 70 conflict answers of 250 KiB that together exceed the response limit, with a write whose save failed before a restart, with a push the server applied behind a proxy that answers 504, and with a replica that serves pages from before a restart after a token reset.

What's included

Area Types
Store OfflineStore, SyncableRecord, SyncStatus, SyncReport, StoreState, PendingChange
Server connection SyncTransport, HTTPSyncTransport, InMemorySyncServer, OutgoingChange, RemoteChange, PushOutcome
Conflicts ConflictResolver, Conflict, ConflictResolution, Mergeable
Conflict-free fields LWWRegister, LWWMap, GCounter, PNCounter, ORSet
Sync control RetryPolicy, CircuitBreaker, ConnectivityMonitor, NetworkConnectivityMonitor, SimulatedConnectivity
Storage FileStore, InMemoryStorage, StorageBackend, StoreEncryption, KeychainStore, RecordMigration, JSONValue
Clocks HybridLogicalClock, HLCTimestamp, VectorClock
SwiftUI SyncedCollection

Quick start

1. Add the package

In Xcode, choose File › Add Package Dependencies… and enter the repository URL:

https://github.com/muhittincamdali/iOS-Offline-First-Framework.git

Paste it into the search field at the top right of the window. Leave Dependency Rule at Up to Next Major Version from 2.1.0, then click Add Package. In the next window, choose your app target next to OfflineFirstFramework and click Add Package again.

2. Import the module

import OfflineFirstFramework

Put this line at the top of the Swift file where you use the store, next to import SwiftUI.

3. Use it

In a new Xcode app (File › New › Project… › App, iOS 17 or later), replace everything in ContentView.swift with:

import OfflineFirstFramework
import SwiftUI

/// A to-do item. Any `Codable` struct with an `id` can be a record.
struct Todo: SyncableRecord, Hashable {
    var id = UUID()
    var title: String
    var isDone = false
}

enum TodoStore {
    // InMemorySyncServer stands in for a backend; swap in HTTPSyncTransport or your own
    // SyncTransport when you have one. The initializer throws only for an invalid name or a
    // store file it cannot read.
    static let shared = try! OfflineStore<Todo>(name: "todos", transport: InMemorySyncServer<Todo>())
}

struct ContentView: View {
    @State private var todos = SyncedCollection(store: TodoStore.shared)
    @State private var newTitle = ""

    var body: some View {
        NavigationStack {
            List {
                TextField("New to-do", text: $newTitle)
                    .onSubmit {
                        let todo = Todo(title: newTitle)
                        newTitle = ""
                        Task { await todos.save(todo) }
                    }
                ForEach(todos.records) { todo in
                    Text(todo.title)
                }
            }
            .navigationTitle("To-dos")
            .toolbar {
                Text("\(todos.pendingCount) to sync")
            }
            .refreshable { await todos.sync() }
        }
    }
}

Press Run (⌘R). Type a to-do and press Return: it appears at once and is saved on the device, and the toolbar shows 1 to sync. Pull the list down to sync; the counter goes back to 0. Quit and reopen the app: the to-dos are still there. InMemorySyncServer stands in for your backend until you connect one (see Usage).

Installation

Swift Package Manager

In Xcode, choose File › Add Package Dependencies… and enter the repository URL:

https://github.com/muhittincamdali/iOS-Offline-First-Framework.git

Or add it to the dependencies in your Package.swift:

dependencies: [
    .package(url: "https://github.com/muhittincamdali/iOS-Offline-First-Framework.git", from: "2.1.0")
],
targets: [
    .target(name: "YourApp", dependencies: [
        .product(name: "OfflineFirstFramework", package: "iOS-Offline-First-Framework")
    ])
]

Version 2.0 replaces the API of 1.0.0, which could not be resolved as a dependency; the changelog lists what changed.

Usage

Every sample below is copied from ReadmeSamples.swift, which the example app builds and the test suite runs.

Sync with your server over HTTP

If your server implements the two endpoints of the server protocol (POST <base>/push and GET <base>/changes?since=<token>), use the HTTP transport. The headers closure runs before every request, so it can fetch a fresh access token:

let transport = HTTPSyncTransport<Todo>(
    baseURL: URL(string: "http://localhost:8080/sync/todos")!,
    headers: { ["Authorization": "Bearer \(try await token())"] }
)
let store = try OfflineStore<Todo>(name: "todos", transport: transport)

http://localhost:8080 is where the reference server listens when you start it with node Examples/SyncServer/server.mjs; replace it with your server's URL.

Connect an existing API

Any API fits behind SyncTransport: one function sends local changes and reports an outcome for each, the other returns server changes after a change token. This one maps the store onto a REST API that versions records with ETags:

/// A transport for an existing REST API that versions records with ETags:
/// `PUT /todos/<id>` and `DELETE /todos/<id>` with `If-Match`, `GET /todos?since=<token>`.
struct TodoAPI: SyncTransport {
    var baseURL: URL
    var session = URLSession.shared

    func push(_ changes: [OutgoingChange<Todo>]) async throws -> [UUID: PushOutcome<Todo>] {
        var outcomes: [UUID: PushOutcome<Todo>] = [:]
        for change in changes {
            var request = URLRequest(url: baseURL.appending(path: "todos/\(change.recordID)"))
            request.httpMethod = change.record == nil ? "DELETE" : "PUT"
            request.httpBody = try change.record.map { try JSONEncoder().encode($0) }
            request.setValue(change.baseRevision ?? "*", forHTTPHeaderField: change.baseRevision == nil ? "If-None-Match" : "If-Match")
            let (data, response) = try await session.data(for: request)
            let http = response as? HTTPURLResponse
            let etag = http?.value(forHTTPHeaderField: "ETag") ?? ""
            switch http?.statusCode ?? 0 {
            case 200...204:
                outcomes[change.id] = .accepted(revision: etag)
            case 412:
                // The server sends its current version with the failed precondition.
                let current = data.isEmpty ? nil : try JSONDecoder().decode(Todo.self, from: data)
                outcomes[change.id] = .conflict(current: RemoteChange(recordID: change.recordID, record: current, revision: etag, timestamp: nil))
            case 422:
                outcomes[change.id] = .rejected(reason: String(decoding: data, as: UTF8.self), current: nil)
            default:
                // No outcome: the store counts a failed attempt and retries later.
                continue
            }
        }
        return outcomes
    }

    func pull(since token: String?) async throws -> PullResponse<Todo> {
        struct Page: Decodable {
            struct Item: Decodable { var id: UUID; var etag: String; var todo: Todo? }
            var items: [Item]
            var next: String
            var more: Bool
        }
        var url = baseURL.appending(path: "todos")
        if let token { url.append(queryItems: [URLQueryItem(name: "since", value: token)]) }
        let (data, _) = try await session.data(from: url)
        let page = try JSONDecoder().decode(Page.self, from: data)
        let changes = page.items.map { RemoteChange(recordID: $0.id, record: $0.todo, revision: $0.etag, timestamp: nil) }
        return PullResponse(changes: changes, token: page.next, hasMore: page.more)
    }
}

The rules a transport has to follow (revisions, retries, idempotency, deletions, change tokens) are in the article Connecting to your server.

Decide conflicts

The default is last write wins. To keep parts of both versions, merge them:

/// A to-do counts as done if either device ticked it; the server's title wins.
static let keepTicks = ConflictResolver<Todo>.merge { local, remote in
    var merged = remote
    merged.isDone = local.isDone || remote.isDone
    return merged
}

Pass it as configuration.conflictResolver (see the next sample). The article Resolving conflicts covers the other policies and the conflict-free field types.

Encrypt the file and migrate old records

/// A store with an encrypted file, a custom conflict policy and a schema migration.
static func makeSecureStore(transport: some SyncTransport<Todo>, storage: (any StorageBackend)? = nil) throws -> OfflineStore<Todo> {
    var configuration = OfflineStore<Todo>.Configuration()
    configuration.conflictResolver = keepTicks
    configuration.encryption = try .keychain(account: "todos-key", keychain: KeychainStore(service: "com.example.todos"))
    configuration.schemaVersion = 2
    configuration.migrations = [
        // Version 2 renamed `done` to `isDone`.
        RecordMigration(to: 2) { record in
            record["isDone"] = record["done"] ?? false
            record["done"] = nil
        }
    ]
    return try OfflineStore<Todo>(name: "secure-todos", storage: storage, transport: transport, configuration: configuration)
}

Sync automatically

await TodoStore.shared.startAutomaticSync()

The store then syncs when the device comes online, two seconds after local changes, when a failed change is due again, and every five minutes while online. Errors show up in SyncedCollection.status and lastError. For sync while the app is in the background, see Syncing in the background.

Test without a backend

InMemorySyncServer behaves like a server (revisions, conflicts, paged pulls, an offline switch), so two stores can play two devices in a unit test:

/// Two devices, one server: the phone writes, the laptop receives the to-do.
static func twoDevices() async throws -> (phone: [Todo], laptop: [Todo]) {
    let server = InMemorySyncServer<Todo>()
    let phone = try OfflineStore<Todo>(name: "phone", storage: InMemoryStorage(), transport: server)
    let laptop = try OfflineStore<Todo>(name: "laptop", storage: InMemoryStorage(), transport: server)

    try await phone.save(Todo(title: "Buy milk"))
    try await phone.sync()
    try await laptop.sync()
    return (await phone.records(), await laptop.records())
}

Guarantees and limits

The store keeps these promises for every conflict policy, through lost requests, lost answers, app and server restarts and stale pages, as long as the server follows the protocol:

  • A save or delete is in the file before it returns, and a queued change is pushed until the server answers it. A retry keeps the change ID, so the server applies it once. Only an answer that was read settles a change: after an error status, a lost connection or an answer too long to read, the change stays in doubt until its next push.
  • A stale copy never replaces a write the server accepted: the resolver is asked only when a record changed on the device and on another device, and an older server version is skipped by the server's sequence, also right after the device's own write was accepted, even across a server restart the device has not seen yet, and after resetChangeToken(). A version the device wrote itself is never a conflict, also when a stale page took the record back and the user edited on top of it, or a page lists an older own write before a version whose answer was lost. Without sequences a pull leaves a pending change to its push, also when the page brings an older write of the device itself, and the push's answer decides, accepted or not: a version a pull kept back meanwhile never takes its place, since a push answer names no epoch. When that version may be newer, a conflict sends the change once more for a fresh answer, and any other answer stands while the next sync pulls from the beginning.
  • Another server epoch, and the first one a store hears of, also on a new store's first page, counts only when two requests of one sync report it, the pull with the token and the pull from the beginning, so one page from a stale cache or another deployment behind the same URL cannot roll the store back, bring a new store records the server never held, or lock a store out. A page without an epoch, once two requests reported one, is never taken either. A sync that fetches a single page still follows a server restart, and when the two requests disagree the sync fails instead of reporting success. Epochs go one way: an earlier epoch than the store's, one it left or one it never held, is never taken, also after resetChangeToken(), so a stale replica or cache that answers both requests, however many syncs in a row, cannot take the store back either.
  • Deleted records stay deleted. A server that lost its data, or another server, makes the store pull everything again and keep its pending changes, which meet the new history through their push's answer, not through a page. A record the app cannot decode, or one nested deeper than 64 levels, is set aside in quarantinedChanges() instead of stopping the pull.
  • A server's answers have a bounded cost. A record nested deeper than 64 levels never enters the store, so writing the file cannot overflow the stack; a page of junk, or of more changes than the pull asked for (1,000 at most), is refused within milliseconds instead of taking gigabytes, and its download is cancelled; a page larger than the response limit makes the pull ask for fewer changes, and a single change larger than the limit, up to four times it, is set aside; a push is split into requests the server's body limit takes and sent again in halves, 16 more requests at most, while the server answers 413 or a 2xx answer is longer than the response limit, and a change whose answer alone is longer stays in doubt; of an error status only the first 4 KiB is read, so a gateway in trouble costs one failed attempt; a UTF-8 byte order mark is read.
  • A clock that runs ahead or was set back, a server that answers every push with a conflict, a server that never answers and a file that cannot be written have a bounded effect: the reference servers keep a pushed timestamp at most five minutes past their own clock, a moment of a wrong device clock does not stamp later writes, server versions are never ordered by timestamps (a clock that ran ahead hides no later version and parks no change), retries back off and are parked, a sync ends after syncTimeout, and syncs wait while the file cannot be written.

The server must never give the same revision to two versions of a record, should send on every page an epoch that only moves to later ones (longer, or greater byte by byte) and never comes back, a sequence that keeps counting across restarts (the only order the store uses), the pushed timestamps (a wall time far ahead of its clock moved back to a limit) and the changeID that wrote each version, and must remember change IDs across restarts, show a write in pages only once it is stored, and keep every page and the answer to each pushed change within the client's response limit (a page holds at most limit changes, cut by bytes, at least one change per page). Limits: writes a server lost in a restore are not uploaded again, a server without sequence gets no order guarantee (a stale page can roll a record back until it changes again, though no write is lost), a record saved again right after discardChange(for:) meets the server's copy as a conflict, one store instance per file (enforced for FileStore, InMemoryStorage and custom backends that report a claimIdentity), the whole file is written on every change, records nest at most 64 levels, a page holds at most 1,000 changes, a single change larger than four times the response limit stops the pull, a change whose push answer alone is larger than the response limit stays in doubt and is parked after its last attempt, an earlier epoch stays refused until the server reports one later than the store's and a page without an epoch until it reports one again, and deletions beyond 10,000 are forgotten. Guarantees and limits has the full list.

Known limitations. Answers from another deployment behind the same URL (a load balancer or proxy that sends a request to staging or to a region with separate data, or a replica that serves the history from before a data loss) are kept out as single pages only. When a new store's first sync reaches only that deployment, or one push answer comes from it, a record can keep that deployment's copy without an error, an edit can be decided against it or park, and a write the server never got can count as accepted. A replica of the history from before a data loss that answers a store's pulls while its push reaches the server can replace the device's own accepted write. resetChangeToken() brings back the records that deployment's token skipped; otherwise move the store file aside and start a new store. Guarantees and limits has the details.

Demo

Synced Offline, two changes waiting
Synced to-do list, light Offline to-do list, light
Synced to-do list, dark Offline to-do list, dark

The screenshots show the OfflineTodos example app on an iPhone simulator, captured by scripts/capture-example-screens.sh. In the app, switch Online off, edit a few to-dos, and switch it back on to watch the changes sync; Edit on another device changes a to-do from a second store on the same server. Open Examples/OfflineTodos/OfflineTodos.xcodeproj, choose an iPhone simulator or My Mac, and press Run; Examples/README.md has the details.

Documentation

Reference documentation and articles:

  • API reference (DocC, published to GitHub Pages from master)
  • Build it locally in Xcode with Product › Build Documentation, or from the command line:
xcodebuild docbuild -scheme OfflineFirstFramework -destination 'generic/platform=macOS'

Guides

Requirements

Platform Minimum version
iOS 17
macOS 14
tvOS 17
watchOS 10
visionOS 1
Toolchain Swift 6.2 · Xcode 26+

The reference server needs Node 24 or later.

Architecture

  • OfflineStore is an actor that owns one collection. Its state (records, pending changes, change token, clock) is written as one JSON file; every change runs on a copy that becomes current only after the write succeeded, so a crash or a full disk never leaves a half-applied change. Each record carries its server revision, its last timestamp and, while unsynced, one pending change with an ID that changes with every local edit.
  • A sync pushes pending changes in batches (oldest first, with the revision they were based on), applies each outcome, then pulls pages of server changes after the saved change token. A record that changed on the device and on the server goes to the ConflictResolver; an older server version is skipped. Edits made while a push is on its way are kept and pushed next.
  • Sync/ holds the transport protocol, the HTTP and in-memory transports, retry policy, circuit breaker and connectivity; Storage/ the file and Keychain stores, encryption and migrations; Clock/ and CRDT/ the value types for ordering and merging; SwiftUI/ the observable model. Only OfflineStore, InMemorySyncServer and SyncedCollection are reference types.
  • The store guards against misbehaving servers and damaged files: bounded queue, page, response, revision and change token sizes, a change token that must move, at most three pushes per record and sync, ignored records whose ID does not match, timestamps beyond the allowed clock drift, and file names that cannot leave their directory. The tests cover each of these.
  • The whole file is rewritten on every change, which suits collections up to tens of thousands of records per store.

Run the tests locally:

swift test                                          # macOS
xcodebuild test -scheme OfflineFirstFramework \
  -destination 'platform=iOS Simulator,name=iPhone 17'
(cd Examples/SyncServer && npm test)                # reference server
node Examples/SyncServer/server.mjs &               # end to end, against the server
OFFLINE_FIRST_SERVER_URL=http://localhost:8080 swift test --filter EndToEnd
OFFLINE_FIRST_NODE=$(command -v node) swift test --filter ServerSaveTests  # a failed save, a restart

Roadmap

  • A storage backend on SQLite for collections that are too large to rewrite on every change.
  • A transport on CloudKit (CKSyncEngine) for apps without their own server.
  • Pulling only part of a collection (for example one project's records).
  • Listing the package on the Swift Package Index; .spi.yml is already in the repository.

Contributing

Contributions are welcome. Read the contributing guide and the code of conduct before opening a pull request. Report security issues privately as described in the security policy.

License

Released under the MIT license. See LICENSE.

Description

  • Swift Tools 6.2.0
View More Packages from this Author

Dependencies

  • None
Last updated: Sat Nov 07 2026 23:54:41 GMT-1000 (Hawaii-Aleutian Standard Time)