All Articles
CategoryArchitecture
Reading Time
8 min read
Published
2026-10-21
Word Count
1,626words

Grab a coffee — this one is a deep dive!

OfflineFirstFramework 2.1: fixing data integrity in offline sync

Summary

What two audits of OfflineFirstFramework 2.0.0 found, how 2.1.0 fixed it in fifteen rounds, why two rounds were dropped, and what is still open.

  • A Swift package that keeps records in a file on the device, queues every change and syncs with any server when there is a connection.
  • Version 2.1.0 fixes the data-integrity findings of two audits of 2.0.0 and of their later rounds, each with a test that failed before the fix.
  • Only a push answer the store has read settles a change; after a lost or unreadable answer the change stays in doubt until its next push.
  • A new server epoch counts only when two requests of one sync report it, and the store never goes back to an earlier epoch.
  • Answers from another deployment behind the same URL are only partly covered; eight open issues describe each case and the way back.
OfflineFirstFramework 2.1: fixing data integrity in offline sync

Table of Contents


The problem

iOS-Offline-First-Framework is a Swift package that keeps an app's records in a file on the device, queues every change and syncs with a server whenever there is a connection. Saving never waits for the network. Version 2.0.0, released on 15 October 2026, rebuilt it around one OfflineStore actor, a two-endpoint HTTP protocol and a Node reference server.

The hard part is not the queue but what the store believes about the server. A push can be applied while its answer is lost. A page can come from a cache, a lagging replica or a server that restarted and lost its data. A device clock can run a year ahead. A wrong guess about which version is newer loses a write without an error. Data integrity here means one thing: an edit the user made, or a write the server accepted, is never silently replaced by an older copy.

What the audits of 2.0.0 found

On the day 2.0.0 shipped, two audits read it, one for correctness and one for adversarial server behaviour. Together they listed eleven must-fix findings. A server restart could hand out a revision again, so an older offline edit was accepted over a newer write. The conflict resolver was asked about versions older than the stored one, so .serverWins rolled records back and brought deleted ones back. One timestamp years ahead won every last-write-wins conflict. A server that answered every push with a conflict kept the store pushing forever. One record the app could not decode stopped every pull.

The first round of fixes landed a few hours later, each with a test that failed before the fix: an epoch in every revision (9314feb), only real local changes meet the resolver (6f6c823), future timestamps count only up to the drift limit (b3f0cfc), conflicts count as failed attempts with backoff (e903299), and unreadable changes go to quarantinedChanges() instead of stopping the pull (0130fd3). Each round of fixes was checked again, and the checks kept finding something new. Version 2.1.0, merged in PR #34 on 21 October, is the result of fifteen rounds; its changelog lists 51 fixed problems.

Four design decisions

An answer nobody read settles nothing

A push answer can be lost, cut off, replaced by a proxy's 504 page, or be longer than the 16 MiB the transport reads. Earlier rounds treated some of these as rejected, which means not applied, yet the server may have applied the change. With .clientWins, a later retryFailedChanges() could then put the old edit back over another device's newer one. Since f7833d0, only an answer that was read settles a change. Anything else leaves it in doubt: it keeps its change ID, a pull keeps the server's newer version back for it, and the next push under the same ID learns what happened.

Epochs go one way

Every revision the reference servers hand out carries an epoch, the name of the server's history. A new epoch makes the store pull everything again and keep its pending changes. Earlier rounds let the store return to an epoch it had left, for example after resetChangeToken(). A lagging replica or a shared cache can answer exactly the two requests a reset makes, and the store then rolled every record back. Since f804126 and 1799c1f, epochs have an order (a longer one is later; of two as long, the greater byte by byte), the store retires each epoch it leaves, keeps the last 32 in its file and refuses an earlier epoch in every sync.

Two requests before a new epoch counts

One page is weak evidence, because a cache, a replica or another deployment behind the same URL can send it. Another epoch therefore counts only when two requests of one sync report it: the pull with the token and the pull from the beginning. This took three rounds. 0450f54 put both requests into one step of the pull, so a sync limited to one page still follows a restart; 073bc67 applied the check to the first epoch a store hears of; 2499c5e applied it to a new store's very first page. When the two disagree, the sync fails and changes nothing instead of reporting success.

Pages have a byte budget

The reference server accepted records of 256 KiB but cut pages by count only. In one scenario 68 records of 250 KiB made a first page of about 17 MB, more than the transport reads, so every device that pulled from the beginning failed on that page for good. Now the server cuts pages at 8 MiB (7a6eee1), the transport sends a limit and asks for half as many changes while a page is too large (191d6c9), and a push is split into requests the server's body limit takes (44e16f3). A fixed bound that the server promises and the client checks is easier to reason about than adaptive tuning.

Fifteen rounds, and two that were dropped

Round one followed the audits on 15 October, rounds two and three started on 19 October, and the rest ran on 21 October. Each round ended with code, tests, documentation and two independent checks. Rounds 14 and 15 went differently.

Round 14 changed how a store that had never seen an epoch starts a new history. It closed a case the round-13 check had found, but the next check found a regression: a store file written by 2.0.0, updated and then cut off during its first sync, lost records; in the probe, 500 records became 100 or 200. Round 15 fixed that, plus two findings about push answers from another deployment. Running both builds through the same random-run harness (500 seeds of 80 steps in 16 configurations and three option sets) then showed the cost: 42 failing runs for round 15 against 4 for the last commit of round 13, and four scenario tests that passed on round 13 failed.

So the work stopped piling fixes on top. The release branch went back to dc17dd3, the last commit of round 13, and took only two small fixes with their own tests: 67564b5 migrates a version a pull kept back along with the records, and deca659 treats a sent change in a 2.0.0 file as in doubt. The test aFileOf200With500RecordsKeepsThemThroughTheFirstSync now pins the upgrade that round 14 broke. What rounds 14 and 15 had fixed about other deployments became documented limitations instead of code. A last review of the release branch found that a version a pull kept back could replace a push answer, fixed in 6a2a863. Because the release adds public API and new cases to OfflineFirstError, it shipped as 2.1.0 rather than 2.0.1 (60843d6).

The lesson: in a sync engine every fix changes what the other rules rely on, so a green suite proves little. What helped was running every build through the same harness and probes, comparing the numbers, and dropping two rounds of work when they got worse.

Known limits

Version 2.1.0 keeps out single pages from another deployment, not every answer from one. When a load balancer, DNS or a proxy sends a request to staging or to a region with separate data, or a replica serves the history from before a data loss, the store can still be misled. Each case has an open issue with an example, the way back and a plan:

  • #26: a new store whose first sync reaches only that deployment follows its token.
  • #27: a new store whose first sync reaches only a deployment with a later epoch locks itself out.
  • #28: a copy with a higher sequence from such a first sync stays, and its edit parks.
  • #29: a replica of the history from before a data loss can replace the device's own accepted write (2 and 8 of 24,000 random runs in two seed ranges).
  • #30: one conflict or rejected answer from that deployment hides the server's later versions.
  • #31: an accepted answer from it counts as the server accepting the write.
  • #32: recovery means moving the store file aside; no API starts over yet.
  • #33: a server restored from a backup that keeps newer change-ID answers parks a retried edit.

Other limits are deliberate: a server without a sequence gets no order guarantee, one store instance per file, the whole file is rewritten on every change, and records nest at most 64 levels. The Guarantees and limits article has the full list and what a server must do.

Using it

Add the package with from: "2.1.0" and connect it to your server through HTTPSyncTransport or your own SyncTransport. For tests and previews, InMemorySyncServer behaves like a server, so two stores can play two devices:

swift
1/// Two devices, one server: the phone writes, the laptop receives the to-do.
2static func twoDevices() async throws -> (phone: [Todo], laptop: [Todo]) {
3 let server = InMemorySyncServer<Todo>()
4 let phone = try OfflineStore<Todo>(name: "phone", storage: InMemoryStorage(), transport: server)
5 let laptop = try OfflineStore<Todo>(name: "laptop", storage: InMemoryStorage(), transport: server)
6 
7 try await phone.save(Todo(title: "Buy milk"))
8 try await phone.sync()
9 try await laptop.sync()
10 return (await phone.records(), await laptop.records())
11}

The sample is copied from the README, whose code lives in ReadmeSamples.swift; the example app builds that file and the test suite runs it. On the release tag, swift test runs 375 Swift Testing tests on macOS, and the reference server has 28 Node tests.

Source code: github.com/muhittincamdali/iOS-Offline-First-Framework

Tags

#swift#offline-first#sync#data-integrity#swift-package
Muhittin Camdali

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share