Author: Maryan Luchko
So you want to sync some data. How hard can it be? You hit POST /thing, the server says 200, the user sees it on their other device. Job done.
Then a technician opens your app in the basement of a 1962 hospital boiler room, fills out a 40-question fire alarm inspection, taps Submit, and gets a spinner. He goes upstairs. The app, helpfully, retries. The server, helpfully, accepts the request. The ACK, less helpfully, gets eaten by a coffee shop Wi-Fi captive portal three hours later. By Monday there are two copies of the inspection on the server, one of which has different answers, and nobody can tell which one is real. Welcome to data synchronization.
This is the actual job. Not “two devices, one note, shown side by side in a marketing video.” It’s a phone in a pocket, a process iOS killed at 80% upload, a queue that didn’t survive a crash, a schema that shipped to half the install base, and a user who is very sure the bug is yours. That is what we are designing for here.
I have written most of the bugs in this article. Some of them more than once. So this is less a textbook and more a list of the failure modes I wish someone had handed me on day one, with the iOS-specific fixes that actually worked.
We will cover three things: how to pick a sync architecture without over-engineering it (REST polling, WebSockets, or CloudKit), how to build the offline queue and conflict handling so a dropped connection is a non-event, and the operational stuff (background limits, schema evolution, monitoring) that decides whether your sync system holds up in production or quietly corrupts user data while everyone smiles.
One disclaimer upfront. I am going to be opinionated. There is no neutral way to write about sync, only the kind of pretending-to-be-neutral that ends with you shipping the wrong architecture.
Defining Your Synchronization Requirements
Before you write a single line of sync code, sit down for ten minutes and answer five questions about your app. I am completely serious. I have watched two separate teams (one of them mine) build elaborate WebSocket pipelines for products that did not need them, then quietly rip them out a quarter later. That whole adventure could have been a ten-minute conversation.
Here are the five questions. Be honest. The wrong answer is the one that sounds impressive at standup.
Real-time vs. delayed sync
Real-time means a change shows up on the other device in milliseconds, usually over a connection that stays open the whole time the app is running. WebSockets, push, that whole crowd. Delayed sync means the change shows up “soon-ish,” whether that is on the next poll, the next app launch, or the next time the user pulls to refresh. Lag from a few seconds to a few hours is fine.
You need real-time if multiple humans are touching the same data at the same time (chat, collaborative editing, live dashboards) or if the value of the data drops fast (inventory, pricing, an Uber pickup).
You almost certainly do not need it if the user is the only writer (their own notes, journal, task list). This is the most expensive question on the list, because the answer drags every other decision behind it. Persistent connection, battery cost, reconnection logic, conflict-handling complexity. All of that comes from picking real-time.
Conflict resolution
When the same record gets edited in two places before they sync, somebody loses. Your options are: the server wins, the client wins, the newer timestamp wins, you do a clever field-level merge, or you ask the user. We will get into each one further down. For now just know that the right pick depends on how badly a wrong merge hurts. Losing the last edit to a chat message? Annoying. Losing the last edit to a fire-alarm inspection report on which a building’s occupancy permit depends? Yeah. Pick carefully.
Offline functionality
How much of the app should keep working when there is no signal? Read-only offline (the user can look at cached data but cannot change anything) is the easy mode and ships in a few days. Fully offline-capable (create, update, delete all succeed locally and reconcile later) is the hard mode, and it brings a whole army of friends to the party:
A durable local store (Core Data, SQLite, Realm, pick your fighter)
Client-generated IDs so a record can exist before the server has ever heard of it
A queue that survives the app being killed, and a conflict strategy for whatever the user did while disconnected
Most apps land somewhere between. The further toward “fully offline” you go, the bigger the sync engine you are signing up to maintain. Forever.
Data volume
A few thousand records and a few hundred KB? You can re-download the whole world on every connection and nobody will notice. Tens of megabytes or hundreds of thousands of records? Now you need delta sync (only what changed since last time), pagination (do not freeze the UI for two minutes on first launch), and selective sync (only pull the records this user actually needs, not the entire corporate dataset). Volume also picks your storage. Three thousand rows fit in a JSON file on disk. Three hundred thousand does not, at least not if you ever want to query them on an iPhone 11 in under a second.
Resource optimization
How much do you care about battery and cellular data? On a productivity app for office workers on Wi-Fi, not very much. On a field-tools app where the technician’s iPad has to survive a ten-hour shift on LTE in a building where the carrier signal stops at the front desk, a lot. The answer to this one usually maps onto two things you have already half-decided: how often you sync, and whether you sync in the background.
That is the list. If you can answer those five in plain sentences, you will recognize your architecture in the next section before I even name it. If you cannot, do not skip ahead. Whatever you build will be wrong in ways that are not obvious until version 2.
Synchronization Architectures: Options and Trade-offs
A “sync architecture” sounds grand, but it is really three boring questions glued together. Transport (how do bytes get from device to server?). Trigger (what makes a sync run? A timer? The user? A push? An OS background event?). Consistency model (when do both sides agree, and what happens if they do not?). Mix and match those three, and you get every sync system that has ever shipped.
Three combinations cover most iOS apps in production. The diagrams below walk a single user edit through each one. If you only look at one thing on those diagrams, follow the arrows for the edit, and notice where the conflict is possible. That is the moment you are designing for. Everything else is plumbing.
REST-Based Synchronization
REST sync is the boring option, and “boring” is high praise here. Most apps do not need anything cleverer. A typical implementation is exactly what you would write on a napkin:
Store a timestamp (or a cursor token) of your last successful sync.
Ask the server for everything that changed after that point.
Send up the local changes the user has been making.
Resolve any conflicts the server flags.
Update the cursor and go take a nap until the next interval.
That is it. No persistent connection, no socket lifecycle, no reconnection logic, no “wait, who is responsible for sending the heartbeat again?”. Just HTTP, which iOS has loved for over a decade.
This is the right choice for read-heavy catalogs, single-user productivity apps, dashboards that refresh on pull-to-refresh, and field tools where the user explicitly says “sync now” before going into a basement. If your app fits any of those, stop reading this section, ship REST, go home.
REST polling becomes the wrong choice when:
Sub-second latency actually matters. Chat. Live cursors. Collaborative editing. Ride-hailing. Auctions. If you tried to poll fast enough to fake real-time, you would set the user’s phone on fire and get rate-limited by your own server within an hour.
Multiple users hammer the same record at the same time. A polling client only finds out about a conflict at the next poll. The longer that window is, the more divergent writes pile up inside it, and the worse your conflict resolution day gets.
The dataset is enormous and constantly changing. Even with delta sync, if 30% of records change every poll, the “bandwidth-efficient” sales pitch quietly stops being true.
Something on the server needs to grab the user’s attention right now. Notifications, incoming calls, a payment confirmation. That is what push and sockets are for. Do not try to poll your way into a notification system. I have seen it. It is sad.
For those cases, see the next subsection.
WebSocket-Based Real-Time Sync
WebSockets are what you reach for when the user’s experience genuinely depends on seeing changes the instant they happen. The connection stays open. When something on the server changes, the server pushes the change down the pipe, and your app reacts. No polling, no waiting for the next interval.
It feels magical the first time it works. The user types a character on their iPad and you watch it appear on their iPhone. Beautiful.
Then you take the iPhone into an elevator. The socket dies. You re-open it on the way out. The user backgrounds the app to check Slack. iOS suspends the process. The socket dies. You re-open it on your resume. The user gets on a flight, switches to airplane mode, switches back, and your reconnect-with-backoff logic does something embarrassing.
A real-time sync system is mostly a connection-lifecycle system with some sync sprinkled on top. Plan for that before you go shopping for a WebSocket library. Specifically, plan for:
Disconnect detection that does not lie. TCP will happily report a dead socket as alive for minutes. Heartbeats, please.
Reconnect with exponential backoff and jitter. Without jitter, every device on the planet retries at the same second after an outage, and your server dies of love.
A catch-up mechanism. When the socket comes back, the client is behind. Either pull a delta over REST, or have the server replay missed events. Either is fine, neither is free.
An honest power and data budget. A persistent connection on cellular is not a free lunch.
The use cases are: chat, collaboration, live dashboards, anything multiplayer.
The disqualifier is: “we just thought it would feel snappier.” It will not. Your users will not notice the difference between 200 ms push and a 5-second poll. They will notice the battery hit. Trust me on this.
CloudKit Integration
CloudKit is what happens when Apple looks at your sync problem and says: “what if we did it for you?”. For a certain shape of app, it is the right answer and you should take the win.
The pitch:
Authentication is free. It rides on the user’s Apple ID. No login screen, no password reset flow, no JWT refresh. You did not write any of that, and you will not maintain any of that.
Conflict resolution is built in. CloudKit hands you both versions on a conflict and lets you decide, but the wire protocol and the bookkeeping are already done.
Background sync is managed by the OS. Apple has more leverage over BGTaskScheduler than you do. They schedule the wake-ups. They throttle when the battery is low. They handle resume-after-suspend.
Push notifications for free. When data changes in CloudKit, you can get a silent push to the device. No APNs setup, no payload design.
The model splits your data into a public database (everyone can read, the right people can write, good for shared content) and a private database (per-user, syncs across that user’s devices via iCloud). Add shared databases if you want one user’s private record to show up in another user’s app.
The catch is right there in the name. CloudKit. The cloud is Apple’s. If your product needs to work on Android tomorrow, or you have an existing backend that owns the authoritative copy of the data, or you need a server-side cron job that touches user records, CloudKit is the wrong tool. It is iOS-and-friends only, and it really wants to be the source of truth.
For a calm, iOS-exclusive, user-data-stays-with-the-user app, CloudKit replaces an entire backend you were about to write. Take the gift.
Optimizing Sync Performance
Once sync works at all, the next problem is making it cheap. And on a phone, “cheap” has four currencies: battery, cellular data, server load, and the seconds the user spends staring at a spinner. Burn too much of any one of them and you have a working app that nobody likes.
The bill comes due at three predictable moments. The first sync after install (the worst one, because it is the user’s first impression and your dataset is at its largest). Whenever the dataset crosses some threshold (a few thousand records, give or take). And anytime sync runs in the background, because the OS is counting every joule. You can skip this section. Your app will still function. It will also drain batteries, eat cellular plans, and make first-launch take ninety seconds, and your reviews will quietly mention all three.
The two subsections below are techniques, not vibes. Delta sync is a transport-level trick that puts less data on the wire. Background processing is a runtime trick that moves work out of the foreground without making the user wait. They are independent, they are complementary, and most production apps want both.
Delta Sync
Delta sync is the obvious idea that took me embarrassingly long to actually implement: instead of sending the whole object every time, send only the parts that changed.
You track what each object looked like at the last successful sync. When the user edits it, you diff against that snapshot. You ship the diff. The other side applies the diff. Done. The bandwidth saving on a record with a hundred fields where the user touched two of them is, give or take, ninety-eight percent. Multiply that by a few thousand records a day and you start to see why everyone bothers.
The non-obvious part is the bookkeeping. You need something on each record that the server understands as “this is the version you had last time.” A lastSyncedAt timestamp, a version counter, an opaque ETag, a server-issued change token. Without it, the server cannot tell whether your “delta” is against version 4 or version 17, and applying the wrong delta to the wrong base is how you corrupt a database in production. Pick a scheme, write it down once, never deviate.
This pays off most dramatically on large records with small edits (a long inspection report where the user changed one checkbox) and on cellular connections where every kilobyte costs the user real money. Skip it if your records are tiny and your dataset rarely changes. The complexity is not free, and a 90-byte payload is fine to send whole.
Background Processing
Time for some definitions, because “background” means different things on iOS than it does anywhere else.
On a server, “background” is a thread. On a Mac, “background” is a window behind another window. On iOS, “background” is anywhere your app is not currently the foreground process. The user is on the home screen. The user is in another app. The phone is locked. The phone is in a pocket on the train. From your app’s point of view, all of those look the same, and all of them are terrifying.
Here is what is terrifying about them. The instant you stop being foreground, iOS starts a small timer, and within a few seconds it suspends your process. Suspended is not “running slowly,” it is “completely frozen, like an animal in cryosleep.” If memory gets tight, the OS will then terminate you without a goodbye. Your URLSession upload that was at 80%? Gone. Your in-memory operation queue? Gone. Your “I will sync this in just a second” intention? Gone.
Background processing on iOS is the set of OS-managed escape hatches that let you keep doing useful work despite all of that, inside strict budgets that you do not get to argue with. For sync specifically, three of them matter.
Background App Refresh. The OS occasionally wakes your app for a few seconds (Apple does not promise how often, and the answer is “based on how often the user opens you, plus the weather”). Useful for keeping content fresh, useless for anything that takes more than the length of a sneeze.
Background Tasks (
BGTaskScheduler). Introduced in iOS 13 and the modern answer. You register a task identifier, you tell the OS “wake me up when conditions are right,” and the OS gives you a longer window (often while the device is on a charger). There are two flavors. BGAppRefreshTask is for quick refreshes. BGProcessingTask is for the heavy stuff, including the kind of long sync you would not want to run on cellular.Background
URLSession. This is the magic one. You configure a URLSession with URLSessionConfiguration.background(withIdentifier:), hand it an upload or download, and the OS takes responsibility for the transfer. If your app gets suspended or even terminated, the transfer keeps going, and the OS relaunches your app in the background when it finishes to deliver the result. Use this for anything that moves more than a few hundred KB. It is the difference between “the upload survived the user closing the app” and “I have to explain to the user why their fifteen-photo upload restarted from scratch.”
Design every background sync to checkpoint progress on every successful step, because the OS can pull the rug at any moment. Save the cursor. Save the queue. Save the position in the upload. You should be able to die at any line of your sync code and resume next time without losing or duplicating work. Easy to write. Easier to forget.
Common Sync Implementation Pitfalls
Every production sync bug I have ever seen lives in this list. Each one is easy to write, hard to spot, and humiliating to debug at 11pm on a Friday with a customer on the phone. Read it once now and save yourself the Friday.
Underestimating conflict complexity
Picture two technicians, two iPads, one inspection report, both editing different sections offline. Your last-write-wins logic kicks in. Whoever syncs second silently overwrites the other one’s entire morning. Nobody gets an error. The bug is reported as “the app deleted my work.”
Fix: any record that can be edited from more than one place needs field-level versioning. A vector clock, a per-field updatedAt, a CRDT, whatever your codebase can support. For the fields that genuinely cannot be merged automatically, write a domain rule (or ask the user) instead of letting a timestamp decide.
Insufficient error handling
Catching every network error and saying “we’ll try again later” looks robust. It is not. It hides server-side validation failures (which will never get better by retrying), it loses uploads when the server returns a permanent rejection, and it turns a 24-hour outage into a six-hour queue-drain on the other side because every client retries every operation it ever attempted.
Fix: classify failures into three buckets. Transient (5xx, timeouts, DNS) get exponential backoff and jitter. Permanent (4xx, validation errors) stop retrying and surface to the user or your logs. Authentication (401) try the token refresh once, then escalate. Log the counts of each class. The day one of them spikes, you find out before your users do.
Overuse of real-time sync
I have already said this twice. Read it a third time. Most apps do not need WebSockets. Reaching for them when a 30-second poll would do trades a small amount of perceived snappiness (that users do not actually perceive) for a real, measurable battery drain, a real reconnection bug at every elevator ride, and a real on-call rotation explaining the dropped sockets.
Fix: default to REST polling at a sane cadence. Only graduate to push or sockets on the specific screens where sub-second latency is a product requirement, not a feeling.
Neglecting user feedback
When sync happens silently, users do not know whether their last edit is safe, in flight, or stuck. So they tap Save again. Then they force-quit. Then they refile the form. Now you have three copies of the same inspection, two of which are slightly different.
The fix is not technical, it is UI: give every syncable record a state field (local, pending, syncing, synced, failed), and show it. A small dot next to the row. A banner when something is stuck. A “retry” button on failed items. Users do not need to understand sync. They need to be able to tell, in two seconds, whether their work is safe.
Poor queue management
An in-memory operation queue is a queue right up until iOS terminates your app for being idle in the background. Then it is a former queue. Any change the user just made before backgrounding is gone. The user does not know. Support gets a ticket.
Fix: persist pending operations to disk (Core Data, SQLite, whatever) the moment they are created, not the moment they are processed. Reload them on launch. Process them with an idempotency key on every operation, so that if a previous attempt actually succeeded on the server and the ACK got lost, the retry does not create a duplicate.
I am going to be honest. Every one of these pitfalls is something I have shipped to production at least once. Some of them more than once. They feel obvious when you read them in an article. They are extremely not obvious at the desk at 4pm on a Tuesday when you are also being paged about something else. That is the whole reason this list exists.
Handling Synchronization Challenges
Up to this point, we have been picking architectures and transports. Whiteboard work. From here on, we are talking about what happens when things go wrong, which is the part of sync that actually fills your bug tracker.
A small confession. I used to think the failure modes in this section were edge cases. The conflicting edits, the flaky network, the battery-throttling. Things to handle eventually, after the happy path was done. They are not edge cases. They are the steady state of mobile sync. The happy path runs maybe forty percent of the time in the wild. The other sixty is: something is broken, and the question is whether your app handles it gracefully or looks possessed.
Read the next three subsections as a checklist. For every pattern you committed to earlier in the article, ask: how does this degrade when the network drops, when two devices disagree, when iOS decides today is not the day for background tasks? The earlier you make those decisions on purpose, the fewer of them leak into your UI as mysterious error states later.
Conflict Resolution
Here are the five conflict-resolution strategies, plus when to use each and what to watch out for. They are not mutually exclusive. A real app usually mixes two or three of them on different fields of the same record.
Server Wins
The server’s copy overwrites whatever the client had. The client sends its edit along with the version (or updatedAt) it last saw. If the server is on a newer version, it rejects the write, and the client refetches and quietly drops the local change. Use it for read-mostly data the server owns (catalogs, configuration, app-managed content the user does not edit) and for systems where the server runs validation the client cannot replicate.
The trade-off is the part nobody mentions: any offline edit the user made can vanish without warning. If you do this, build a UI that re-presents the dropped change to the user. Silent loss is the worst kind of loss.
Client Wins
The local edit always wins, full stop. The client uploads its version, the server stores it, the server broadcasts to other clients. Use it for genuinely device-local data the user is the sole owner of (a private note they just typed, draft state, local preferences). The trade-off: in any multi-device or multi-user scenario, “client wins” means “the last device to sync erases everyone else.” Reach for this only on fields where that is actually fine.
Last Write Wins (LWW)
Whichever side has the newer timestamp keeps its value. Every write carries a timestamp, ideally a server-issued one because client clocks drift in spectacular ways. On conflict, the bigger timestamp wins. Use it on fields where stale-but-consistent is acceptable. A status flag. A last-seen marker. A chat message body.
The catch: at the whole-record level, LWW is just server-wins or client-wins with extra steps, because the entire losing version is thrown away. And if you rely on client timestamps, you will eventually meet a user whose device clock is set to 2042 because their kid was messing with it, and that user’s edit will dominate every conflict for the next two decades. Use server timestamps. Or a monotonically increasing version counter. I am begging you.
Merge Changes (field-level merge)
Edits to non-overlapping fields are combined into one merged version. You track which fields changed on each side, either with per-field dirty flags, a three-way diff against the last-synced version, or a CRDT data structure. When the field sets do not overlap, you apply both. Use it for records with lots of independently edited fields. A contact card. An inspection report. A settings object.
The trade-off: this is the strategy users love (nobody loses their work) and the strategy you will love writing the least (per-field change tracking is fiddly). And for the case where both sides did edit the same field, you still need a fallback rule. Usually LWW or a user prompt.
Custom Resolution
Domain logic decides. Maybe by computing the right answer, maybe by asking the user. On conflict, you call a resolver that has both versions (and ideally the common ancestor) and returns the merged result. Use it where neither side can be safely auto-merged and getting it wrong has actual cost. A counter that should sum (inventory, view counts). A list that should union (tags, attendees). A long-form text field a human needs to look at (an inspection note, a medical observation).
The trade-off: this is the most correct option and the most expensive to build. Each field type needs its own resolver, and “ask the user” needs an actual UI somebody designed. Reserve it for the small handful of fields where automatic anything is unsafe.
The good news is that you almost never need just one of these. A typical record uses server-wins on its read-only metadata, field-level merge on its main payload, and custom resolution on the one or two fields that are genuinely contested. Document the choice next to the field in the model, not in a forgotten Confluence page from 2019.
Network Unreliability
Mobile networks are bad. You knew that. The question is how to make your sync system survive that fact instead of pretending otherwise.
Exponential Backoff with Jitter
When a request fails, wait a growing interval before retrying. Double the delay each time (1s, 2s, 4s, 8s, ...) up to a cap (60s or 5 min is fine). Add a random jitter of plus-or-minus 25% so a thundering herd of clients does not all retry at the exact same second after an outage. Reset to 1s after a success. Use it on every retryable failure, especially 5xx, timeouts, and DNS.
The trade-off: protects the server and the device’s battery, but the worst-case latency a user sees before success can be long. Always cap with a max retry count or a max total duration. A permanently broken request that retries forever is not resilient. It is a battery vampire.
Operation Prioritization
When connectivity returns and you have a backlog, run the important things first. Assign each queued operation a priority. Something like: user-blocking writes > background uploads > analytics > prefetches. Drain in priority order, not FIFO. Use it for any app where the queue can grow large during an outage.
The trade-off: low-priority items can starve forever if higher-priority work keeps arriving. Mitigate by aging items so they eventually crawl up the list. The analytics ping from yesterday still wants to be sent eventually. Just not before the user’s inspection report.
Connectivity Awareness
Look at the OS network APIs before you fire a request. NWPathMonitor (or the older Reachability library) tells you the current path state, including whether it is Wi-Fi or cellular, whether it is constrained (Low Data Mode is on), and whether it is expensive (cellular, especially roaming). Skip big uploads on expensive or constrained connections, and resume them on unmetered Wi-Fi. Use it always. The overhead is nothing and the savings are real.
The trade-off, and this one bites people: NWPathMonitor only tells you the path looks reachable. It does not tell you any specific server is responding. Use it to skip obviously hopeless attempts. Do not use it to skip error handling on the attempts you do make. Captive-portal Wi-Fi reports as a perfectly good network. Until you try to hit your API and get redirected to a sign-in page selling overpriced airport coffee.
Partial Sync Resumption
A sync interrupted (process suspended, network dropped, app crashed) picks up where it left off rather than restarting from scratch. Persist a sync cursor to disk after every successfully processed batch. A server-issued change token, a last-synced timestamp, a page index.
On the next attempt, resume from that cursor. For large file transfers, use URLSession’s background configuration. The OS handles resume across app launches automatically and you do not have to do anything clever. Use it for any sync that takes longer than a second or moves more than trivial data.
The trade-off: every batch needs to be idempotent. If the cursor was saved after batch 5 finished, replaying batch 5 on resume must not create duplicates. And when the server compacts its change log, your old cursors might become invalid. Plan for the “your cursor is too old, do a full sync” response.
And, please, please, surface sync status in the UI. A subtle indicator next to a row. A banner when something is wrong. A button to manually retry. Users will forgive a lot of network weirdness if the app is honest with them about it.
Battery and Data Usage Optimization
Make sync as cheap as the work allows. The lever set is small and well-worn.
Defer non-critical sync until charging and on Wi-Fi. Analytics, image prefetch, log uploads. None of it is urgent. Sit on it until the device is plugged in and on the home network.
Batch small changes. Ten separate API calls for ten field edits is wasteful. One call with ten edits is not. The HTTP overhead alone often dwarfs the payload.
Compress before transmission. gzip on the request body is one line of code and a real-percentage win on anything text-heavy.
Set
URLSessionConfiguration.isDiscretionary = trueon background sessions where you do not care about timing. iOS will pick a moment that costs the user the least. That moment will not be “right now,” and that is fine.Adjust sync frequency to actual freshness needs. Polling every 30 seconds when the data changes daily is a battery crime.
None of this is glamorous. All of it adds up.
Implementing Offline Capabilities
Your iOS app does not get to choose whether to work offline. Your users do, every time they get on a plane, drive into a parking garage, take the elevator down to floor B2 of a hospital, or hand the iPad to a fire-protection technician who spends seven hours of every workday in places where carrier signal is theoretical.
An app that throws up its hands without a network is fine for somebody reading the news on the couch. It is unusable for actual field work, for transit, for travel, and for the long tail of small dead zones that happen every day to everyone. Offline capability is the feature that lets the user finish their task now, with the system catching up later. It is also the single most-respected behavior in your app, and the one most likely to win you a five-star review from a technician who has been burned by sketchier apps in the past. I have read those reviews. They are touching.
There are exactly two pieces to building offline well. A durable record of what the user did while disconnected (the queue). And a clear way to represent the state of each piece of data as it moves between the device and the server (local sync state). Skip the rest of this section if you only have time for one thing, and adopt the queue. An app that loses an offline edit feels broken in a way no amount of pretty UI will repair. The user will not understand why. They will just stop trusting the app.
The diagram below shows the canonical queue-based offline sync flow. Follow the path of a single edit through it.
Queue-Based Sync Operations
A persistent queue is the spine of every offline-capable app I have ever seen work. The shape is always the same:
The user does something offline (or online, the queue does not care).
The change is written to local storage and added to a queue on disk.
When the device is online, a worker drains the queue against the server.
Successful operations are removed.
Failing operations are retried, or surfaced as errors after a few tries.
A few things that sound obvious but tripped me up the first time.
The queue lives on disk, not in memory. I keep saying this. I keep being right. Core Data, SQLite, a serialized file, your choice. Just not a [Operation] in a singleton. The whole point is to survive the app being killed.
Operations need idempotency keys. Generate a UUID at the moment the operation is created on the device, send it with the request, and have the server use it to de-duplicate. Otherwise, the day an ACK gets dropped after the server already committed the change (this will happen), your retry creates a duplicate. Once you have a duplicated inspection in production, you cannot tell which one is “real.” Ask me how I know.
The queue is FIFO until it isn’t. Most of the time, you want operations processed in the order the user created them, because later edits often depend on earlier ones (you cannot update a record before you create it). Sometimes you want priority, which we already covered. The trick is: when you mix the two, make sure that operations on the same record still go in their original order, even if other operations skip ahead.
A poison-pill operation must not block the queue. One bad request that keeps returning 400 should not stop the queue forever. After N retries, move it to a “failed” bucket, surface it to the user, and let the rest of the queue through. The number of times I have seen a single broken record bring a whole sync system to a standstill, I cannot count.
What you get out of all this is “eventual consistency,” which is a fancy way of saying “the user can do whatever they want offline, and the system will catch up when it can.” That is what the user actually wants. They do not care about your architecture diagram. They care that their work is safe.
Local State Management
The queue from the previous subsection guarantees the user’s edit will eventually reach the server. The user does not know that. From their point of view, the moment they tap Save, the edit either succeeded, failed, or is in some quantum middle state, and an app that does not tell them which feels untrustworthy. After a while it feels actively broken.
Local state management is the bookkeeping that lets you answer one question, anywhere in the app: what is the status of this record right now?. List cell. Detail screen. Error banner. Debug log. Without that answer, a record on the server, a record in the queue, and a record stuck after five failed retries all look identical, and users do what users do when they are not sure: tap Save again, force-quit, re-enter the data, refile the form. Now you have ghost records. Nobody is happy.
Here is the small, deliberate set of steps. Read the why first, then the steps, then the payoff at the end. Skipping ahead is allowed but discouraged.
The why: at any given moment, several different things in the app need to know the sync status of a record. The list cell needs to show a status dot. The detail screen needs to disable a “Submit” button until the upload completes. The error banner needs to count failures. The retry button needs to know whether a retry is even possible. If every screen guesses for itself, they will all guess differently, and the user will see contradictions. Instead, treat sync status as data, stored once, read everywhere.
The steps:
Add a sync state field to every syncable object. An enum is fine. Suggested values: synced, pendingUpload, syncing, failed, conflicted. This is the source of truth for everything else.
Bind that field to the UI. Cells, detail screens, section headers all read from it. A subtle dot, a “syncing...” label, a small badge. Do not block the user from continuing to work just because something is pending; that is the opposite of offline-friendly.
Expose a manual sync trigger for the records the user cares about most. An inspection submission. A payment. A signed form. Give them a button to force the retry. They will use it. They will feel in control. Both things are good.
Surface meaningful error messages for persistent failures. Distinguish “Will retry when online” from “Server rejected this; tap to review.” The first is reassurance. The second is a call to action. Silent failure is the worst category of bug in this whole article.
Persist sync state to the same store as the data. Core Data, SQLite, Realm. Not a transient cache. The status survives crashes, restarts, and OS terminations. If you put it in an in-memory dictionary, you are about to learn a lesson.
The payoff: three things at once, for free. The user always knows whether their work is safe. Support can diagnose a stuck record by reading one column. And the rest of your sync engine has a stable contract to drive its retries and conflict handling against. From here on, “is this record synced?” is a question with a single answer everywhere in your codebase, instead of something each screen has to figure out from context. Worth every minute it takes to set up.
Testing Sync Implementations
Sync is one of the few areas in iOS where you can have green unit tests, a passing build, a clean TestFlight, a happy QA team, and a production fire by the end of the week. The bugs do not live in the components. They live in the interactions between the components, on the network conditions you forgot to simulate.
So testing sync needs more than the usual pyramid. Five flavors, in increasing order of how much they will save you.
Unit tests. Each component in isolation. Your delta computer. Your conflict resolver. Your queue. The boring ones. They catch logic bugs and they prevent regressions during refactors. Necessary. Not sufficient.
Integration tests. Components wired together against a fake or local server. Does an offline edit land in the queue, get drained on reconnect, end up on the server, and update the local sync state? End-to-end, no manual stepping. This is where you catch the “I refactored the queue and forgot to update the state machine” class of bug.
Offline tests. Run the whole thing with the network forcibly down. Background the app, kill it, relaunch it. The edit should still be there. The queue should still drain when you turn the network back on. This is the test that mirrors the actual lived experience of a field technician.
Concurrency tests. Two devices, one record, both editing. Simulate it. Either a fake second client in the test harness, or a second simulator, or a real second device. Watch the conflict resolution actually fire. Most sync bugs hide here.
Stress tests. Throw ten thousand records at the queue. A hundred concurrent operations. A flaky connection that drops one request in five. The metric is not “passes,” it is “stays sane.” A correct sync system under load should slow down. It should not corrupt.
The Network Link Conditioner (built into Xcode and Apple’s Additional Tools download) is your friend. Run a test pass under “Edge,” then “3G,” then “Very Bad Network.” If your sync survives Very Bad Network, you have a sync system. If not, you have an optimist.
Performance Impact of Sync Strategies
A short note before we go on. Each of the architectures and optimizations in this article has a different shape of cost, and it is worth being explicit about which one hurts where. REST polling is cheap on connection setup and expensive on round trips. WebSockets are expensive on connection lifecycle and cheap per message. CloudKit is cheap on developer effort and expensive on cross-platform reach. Delta sync is cheap on bandwidth and expensive on bookkeeping. Background processing is cheap on user-visible latency and expensive on debugging time.
There is no free option. There is only the one whose costs you can afford on your specific app.
Real-World Implementation Considerations
Everything above gets you to a sync system that works on day one. The next three subsections are the things that decide whether it still works on day three hundred.
Versioning and Schema Evolution
Your data model is going to change. New fields, removed fields, renamed fields, restructured relationships. This is not a “maybe.” This is the dictionary definition of “shipping software.” Plan for it on day one or pay for it later in migration agony.
Three things to bake in.
Schema versioning. Embed a version number in every payload ("schemaVersion": 2). Then the client and the server both know what flavor of the model they are looking at. Without it, you have to guess, which is fine until you guess wrong on a single field and silently corrupt a few thousand records.
Migration paths. Write code that converts data from any older version to the current one, both on the device (Core Data lightweight or heavyweight migrations) and on the server (a backfill script that runs on deploy). Test the migrations. Then test them again after lunch.
Backwards compatibility. Some users will not update the app for months. Your server must accept v3 requests from v1 clients without melting. New fields should default to safe values on missing input. Removed fields should be tolerated, not crash the parser. Renamed fields need to accept both names for at least one release cycle. This is the difference between a graceful rollout and a forced upgrade with a five-day support tail.
Security Considerations
Sync data lives in three places: the network, the server, and the device. All three need to be secure. Otherwise you have a leak factory.
Encryption in transit. TLS, always. HTTPS, always. Pin certificates on requests that hit your own backend if your threat model includes someone on the same coffee-shop Wi-Fi (URLSessionDelegate’s authentication challenge is where this happens). And, for the love of everything, do not turn off App Transport Security to make a third-party SDK happy.
Encryption at rest. On iOS, set NSFileProtection to at least complete or completeUntilFirstUserAuthentication for any sensitive local store. On the server, encrypt the columns that hold anything personal or regulated. If you do not know whether a column qualifies, assume it does.
Authentication and authorization. Auth checks who the user is. Authz checks what they are allowed to do. Both happen on every request, not just the first one. A user whose role changed mid-session should lose access on the very next call, not at the next login. Token refresh is fine. Statelessness is fine. A single ten-year session is not fine.
Monitoring and Diagnostics
If you cannot see your sync system in production, you do not have a sync system. You have a wishing well.
Log sync events and errors. Every sync start, every batch processed, every retry, every failure class. Aggregate by user, by app version, by failure type. The day “transient failures on iOS 19.2” spikes from 0.1% to 3% is the day you need to know, not next quarter when complaints reach support.
Track performance metrics. Sync duration. Payload size. Queue depth. Time-to-first-sync after install. Track them per release. A regression here will not crash anything, but the user will feel it.
Use remote logging. Send critical errors to a service you can search (Sentry, Datadog, Crashlytics, your in-house thing). The alternative is asking users to “submit a diagnostic report.” Nobody submits diagnostic reports. They just stop using the app.
Conclusion
Sync is not a feature you add. Sync is a system you design, and the design starts before you write any code. Spend the ten minutes on the five questions in the requirements section. Pick the simplest architecture that satisfies the real needs of your app, not the most impressive one. Plan for the unhappy path on day one, because the unhappy path is the path. Put your queue on disk. Surface state to the user. Test under bad networks, not your office Wi-Fi.
There is no “best” sync architecture. There is the one that fits your app’s latency, conflict, offline, and volume needs, and there is everything else. REST polling will carry more apps than people admit. WebSockets are worth their cost only for the apps that genuinely need real-time. CloudKit is a gift if you are iOS-only. Anything beyond that is a custom build, and that custom build will be 70% queue management and connection-lifecycle code, no matter how clever your conflict algorithm is.
I will leave you with the one rule that has saved me the most pain over the years. Make state explicit. Every record knows what it is. Every operation knows what it is doing. Every error knows what kind of error it is. The user knows what is going on. Logs know what happened. The day you can answer “what is the state of this record right now?” without guessing, you have a sync system that holds up in production.
The rest, as they say, is just engineering. Have fun out there. Be kind to your retry loops.





Top comments (0)