Aug 17, 2025·8 min read

Swift concurrency patterns for sync-heavy mobile apps

Swift concurrency patterns help sync-heavy mobile apps handle retries, offline work, and background limits without duplicate tasks or broken state.

Swift concurrency patterns for sync-heavy mobile apps

Why sync work breaks on phones

Phones interrupt work constantly. A user walks into an elevator, switches apps, locks the screen, or loses signal for ten seconds. That is enough to break sync logic that looked fine in testing.

Swift concurrency helps organize async code, but it does not change the device. Your app still runs on weak networks, under battery limits, and inside an operating system that pauses work whenever it decides to.

A common failure happens in the middle of a multi-step save. The app sends one write, starts the next, and the network drops between them. Now the server has half the change, the phone has the full change, and neither side agrees on what happened.

Users make this worse without trying. If a Save button looks stuck for even a second, many people tap it again. That can send the same job twice, create duplicate records, or start two updates that race each other.

Timing creates another mess. The user taps Save, then the app closes before the work finishes. Sometimes the request reaches the server after the app is gone. Sometimes it does not. If your app cannot tell the difference, it may retry work that already succeeded.

Background behavior adds more trouble. When the app leaves the foreground, iOS can pause or cut short tasks. A sync job that looked simple at a desk can stop halfway through during a real commute.

Late replies are especially nasty. A user edits a customer phone number, then fixes it again a few seconds later. If the older server reply arrives last, it can overwrite the newer local value. The app followed the code. The order of events did not.

That is why sync-heavy apps fail in small, boring moments instead of dramatic crashes. Sending data is not the hard part. The hard part is defining one unit of work when taps repeat, networks vanish, and replies arrive out of order.

Draw task boundaries around durable steps

Start with one real user action, not with threads, actors, or APIs. Pick a flow like "save order" and trace it from the button tap to the last server update.

Most teams make the boundary too wide. They treat the whole save flow as one task, then a retry runs everything again and creates duplicates. Smaller boundaries are easier to reason about and easier to recover.

Write down every side effect in order: each local database write, file upload, API call, and status change you show in the app. If you cannot point to a side effect, it probably is not a boundary yet.

A simple rule works well: split the flow anywhere a second run could cause damage. If repeating one part could create a second order, charge twice, or upload the same file again, that part needs its own step.

For a "save order" flow, the sequence might be:

  1. Write the order to local storage.
  2. Create a sync job with a stable ID.
  3. Ask the server to create or update the order.
  4. Upload any attachments.
  5. Mark the job as complete locally.

Each step needs one clear success rule. "Started" is not success. "Local transaction committed" is. "Server returned an order ID" is. "All attachment uploads finished and the app stored their remote IDs" is.

After each durable step, save a checkpoint. In practice, that is usually a local record with a state like draft, queued, remoteCreated, filesUploaded, or done. When the app wakes up again, it should read that state and continue from there instead of guessing.

This keeps async code cleaner too. One task does one durable piece of work, commits the result, and stops. The next task reads the checkpoint and handles the next piece.

If the boundary feels vague, it probably is. A solid boundary lets you answer two questions fast: "What already succeeded?" and "What can run again safely?"

Separate user actions from background sync

When someone taps "Save", finish the user action first. Write the change to local storage, update the screen, and mark the record as pending sync. That feels fast and keeps the app useful when the network drops.

A practical pattern is simple: the screen handles the local change, and a separate queue handles server work. If the request takes ten seconds, fails twice, or has to wait until the phone is online again, the user should not sit on a loading screen the whole time.

A common mistake is letting a view model own a long network task. Screens come and go. People close the app, switch tabs, or lock the phone. If the sync logic lives inside a short-lived screen model, retries and recovery become hard to control.

Keep the screen layer thin. Let it collect input, save locally, and show state such as "pending", "syncing", or "failed". Put the real sync job in a queue that lives outside the screen, usually near your persistence layer.

That queue should store jobs durably so they survive restarts, retry with the same job ID instead of creating new work, resume when the app opens again or the system grants background time, and publish status changes so the UI can reflect them.

A sales app is a good example. A rep edits a customer record in an elevator with no signal. The app saves the new phone number locally at once and shows a pending badge. Later, when the device reconnects, the queued job sends the update. The rep does not have to re-enter anything.

This split makes the code calmer. The user action has one job: commit local intent. The background worker has one job: make local intent match the server. Once that line is clear, retries get safer, offline behavior gets easier, and the app feels faster even on a slow network.

Make retries safe

A retry should repeat the same intent, not create a new one. If a user saves a note, creates an order, or uploads a photo, the app needs one request ID that stays the same across every retry. Generate it once, store it locally, and send it again after a timeout, app restart, or network drop. That one choice prevents a lot of duplicate records.

Keep retry units small. Do not rerun the whole sync chain if only one step failed. If the app creates a customer, uploads an attachment, and then posts an activity log, those are three separate steps. When step three fails, retry step three. If you restart from step one, you might create the same customer twice.

Timeouts need extra care. A timeout does not mean the server rejected the request. Often it only means the app stopped waiting before the reply arrived. Treat that state as unknown. Before retrying, check whether the server already finished the work. If you cannot check, only retry when the request ID makes the operation safe.

Save server IDs as soon as the server returns them. Do not wait until the full sync flow ends. If the app gets a server ID for a new record and crashes one second later, that ID still matters. On the next launch, the app can continue updating the same record instead of creating a second one.

Some retries can do real damage. Stop automatic retries when another attempt could charge money again, send the same message twice, submit the same order twice, or trigger a one-time workflow on the server.

In those cases, pause the job, mark it for review, and show a clear status in the app. Users can tolerate a delayed update. Cleaning up duplicate invoices is much worse.

A simple rule holds up well: retry only small, idempotent steps, and store every identity the server gives back right away. Sync should feel boring.

Design for offline first

Handle Background Cuts
Talk through iOS background limits, resume points, and cancellation handling with Oleg.

Phones lose connections in elevators, parking garages, and crowded events. If your app only works when every request reaches the server right away, normal use turns into lost updates and confused users.

A better default is simple: record what the user meant to do, save it locally, and sync it later. In practice, that means you queue intent, not raw button taps. If someone taps "Save" three times on a customer note, the app should keep one clear action like "update note text to this final value", not three separate tap events.

Store a small local change log

Keep local changes in a plain, predictable order. A lightweight queue with timestamps, record IDs, and the intended change is often enough. Order matters because later actions may depend on earlier ones. If a sales rep creates a new contact and then edits the phone number, the app should sync those changes in that same order.

Swift concurrency helps here, but the rule itself is not complicated. One task writes local intent. Another task reads that queue and sends work when conditions allow. Keeping those jobs separate makes the app easier to reason about when the network disappears.

When the connection returns, sync in small batches. Ten small updates are usually safer than one giant push that times out halfway through. Small batches also make retries less painful because you only repeat the unfinished part.

Show sync state clearly

Users should be able to see what is still waiting to sync. A subtle "Pending" label, a sync count, or an "Updated locally" state can prevent a lot of support tickets. Silence makes people tap again, reopen screens, or assume the app failed.

Conflicts need the same clarity. If the server version changed while the phone was offline, do not guess when the decision affects money, names, dates, or customer records. Mark that item as needing review and ask the user which version to keep.

The best offline flow feels uneventful. People make changes, move on, and trust the app to catch up when the network does.

Work within iOS background limits

iOS can pause or kill background work with very little warning. If your sync flow needs five straight minutes to finish, it will fail in the real world, especially when the user locks the phone, loses signal, or opens a heavier app.

Treat background time as short and uncertain. Break work into the smallest chunk that still produces a real result: one uploaded record, one downloaded page, one processed image, one batch of ten small changes. Finish that chunk, save its result, then decide whether there is time for the next one.

Think in small units

One giant sync task looks neat in code, but it creates fragile behavior. If the system stops that task at 80 percent, you often have no clean way to tell what finished and what did not.

A smaller job is easier to retry and easier to resume. Cancellation is less scary too, because you lose seconds of work instead of minutes.

A practical approach looks like this:

  • Start with the smallest useful unit of sync.
  • Ask for background time only when that unit truly needs it.
  • Write progress to storage after each finished unit.
  • Stop cleanly when cancellation arrives.
  • Resume from the last saved point on the next launch.

That works better than one long Task that tries to do everything before the system notices.

Save before the cutoff

Do not wait until the end of a sync run to save state. Save after each chunk finishes. Store a cursor, the last synced change token, record IDs already uploaded, or the next page to fetch. When iOS cuts you off, the app should know exactly where to continue.

Cancellation needs the same care. When Task.isCancelled turns true, stop starting new work. Finish any tiny write you already began, persist the resume point, and exit. That gives you a clean handoff instead of a half-finished transaction.

If the app has 50 local edits to send, do not build one task that posts all 50 and updates local state at the very end. Send one change or a small batch, mark it done, save progress, and move on. If the app gets 20 seconds in the background, you might finish 12 changes. On the next run, you start at 13, not 1.

A realistic example: sales rep updates a customer record

Clean Up Retry Logic
Work with Oleg to split risky sync chains into smaller steps your team can retry.

A sales rep finishes a client visit, boards a train, and opens the customer record before the signal drops again. She adds a few notes from the meeting and attaches a photo of a signed document. The mistake is to treat that whole save as one large operation.

A better design splits it into small jobs with clear boundaries.

When she taps Save, the app writes the note to the device right away. That local write should finish quickly and update the screen at once, even with no network. If the app waits for the photo upload before saving the text, the whole action feels broken.

The photo follows a different path. The app creates one upload job, gives it a stable upload ID, and puts it in a queue. If the train goes through a tunnel, iOS pauses the app, or the request times out, the app does not create a fresh job each time. It retries the same job with the same upload ID.

That detail prevents a common mess. Without a stable ID, three retries can leave three copies of the same photo on the server. With the same ID, the server can say, "I already have this file" and keep one copy.

The record can move through a few plain states: saved locally, waiting for upload, syncing, and synced.

Those states matter because "saved" and "synced" are not the same thing. The rep should never lose her note, so the app saves it on the device first. But the app should mark the customer record as fully synced only after the server has the updated note and the photo upload has finished.

This also survives app restarts. If she closes the app at one station and opens it later, the queue still knows what is pending. The screen can show the note immediately, show that the photo still needs to upload, and finish the job when the network returns.

That is the shape to aim for: fast local writes, separate background work, stable IDs for retries, and a sync badge that means the server really has everything.

Mistakes that create duplicate work

A lot of duplicate work starts with one oversized async task. The app creates a record, uploads three files, updates the remote status, and marks everything done in one block. That looks tidy in code, but it breaks the moment the network drops after step two. On retry, the app often sends the whole payload again and creates a second record or uploads the same files twice.

The safer approach is to split work into smaller jobs with stable IDs, so each one can retry on its own.

Another common mistake is storing sync state inside a screen object. A view model knows that a draft is "uploading", but the user closes the screen, the app gets suspended, or the process dies in the background. The state disappears with the screen. When the app opens again, it no longer knows what already ran, so it starts over.

This gets worse when two queues touch the same record at the same time. A manual "save now" action runs in one path while background sync wakes up and runs another. Both jobs read the same stale version, both think they should upload, and both write new results. Now you have a race instead of a sync system.

The symptoms are familiar: duplicate photos or attachments, the same customer record appearing twice on the server, a failed sync that later "succeeds" by sending old data again, or a clean screen even though work is still waiting locally.

That last one matters more than teams expect. If the app hides pending work, users try again. They tap Save twice, reattach the same file, or edit the record again because nothing on screen tells them, "we stored your change and will send it later."

A steadier design is simple. Store the user action locally first. Give the record and each attachment their own sync job. Keep sync state in durable storage, not in the screen. Let one coordinator own writes for one record at a time. Then retries repeat only the unfinished piece.

Checks to run before release

Trace Sync Failures Faster
Go through logs, job IDs, and checkpoints with Oleg so every retry makes sense.

Sync bugs usually fail in boring, repeatable ways. A short release checklist catches most of them faster than another round of code review.

Run these tests on a real device, not only in the simulator. Phones lose signal, apps get killed, and iOS pauses work at awkward times.

  • Start a sync, switch on airplane mode, wait a bit, then reconnect. The app should keep local changes, stop cleanly, and resume without creating extra records.
  • Trigger a retry, kill the app, and reopen it. The pending job should load once and continue from saved state, not come back as two jobs.
  • Begin a long upload, then send the app to the background. Check whether it saves progress, uses background time only when needed, and recovers if iOS cuts the task short.
  • Perform one clear user action, such as saving a customer note once, then inspect the server result. You should see one record change, not two near-identical writes.
  • Read both client and server logs after each test. Look for stuck jobs, repeated request IDs, and the same payload sent again and again.

Timing bugs hide in the small gap between "request sent" and "response stored". If the app closes in that moment, your code still needs enough local state to answer a simple question: should this job retry, reload from the server, or mark itself done?

Teams often get sloppy here. They test the happy path, then assume retries will behave the same way. They do not. A retry is a different path, and it often hits different code.

One rule helps a lot: every sync job should have its own ID, status, and last attempt time. When a tester finds a problem, you should be able to trace that job from tap to server response in under a minute.

If you cannot explain why a request repeated, do not ship yet. Users forgive a short delay. They do not forgive duplicate charges, lost edits, or records that change twice.

Next steps for your app

Do not try to clean up every sync path at once. Pick the messiest flow you have. Maybe it is "edit customer, attach photo, then sync after reconnect". Redraw that flow on paper so each task does one job: save the local change, queue outbound work, send one request, record the server result, then mark the item done. If one step fails, the app should know where to resume.

Then add logs where sync work usually gets fuzzy. Log when a retry starts, when a background task resumes, when the app goes offline, and when the server returns a conflict. You do not need a huge observability project on day one. A clear event name, a task ID, and a checkpoint value often explain why duplicate work appears.

A small plan beats a rewrite. Start with one queue for sync jobs, one checkpoint format for every job, and one stable ID for each user action so retries do not create extra records. Agree on one conflict rule with product and backend teams before release.

That is enough to test your design in a real app. After that, run ugly cases on purpose. Put the phone in airplane mode. Kill the app in the middle of sync. Send the same action twice. Let a background window expire. You want one result and no surprises.

If you want a second set of eyes before changing the architecture, Oleg Sotnikov at oleg.is works as a fractional CTO and startup advisor. He helps teams review product architecture, infrastructure, and AI-first development workflows, which can be useful when sync systems have become hard to reason about.

Frequently Asked Questions

What usually breaks sync on phones?

Phones interrupt work all the time. Users lock the screen, switch apps, lose signal, or hit Save twice, and those small moments break long sync flows. Late server replies can also land in the wrong order and overwrite newer local edits.

Should my Save button wait for the server?

No. Save the change locally right away, update the screen, and mark the record as pending sync. Then let a separate queue send the server work when the network and the system allow it.

How small should a sync task be?

Keep each task to one durable side effect. A good step has one success rule, like "local write committed" or "server returned an ID," and your app saves a checkpoint after it finishes.

Where should sync logic live in the app?

Put long running sync code outside the screen layer. View models come and go, but a durable queue near your database or persistence layer can survive app restarts and resume work cleanly.

How do I stop retries from creating duplicate records?

Use one stable request or job ID for the same user action and reuse it on every retry. Also split the flow into small steps, and save any server ID as soon as the server returns it.

What should I do after a timeout?

Treat a timeout as unknown, not as a failure. The server may have finished the work after your app stopped waiting, so check for the existing result before you send the same request again.

What does offline first look like in practice?

Queue user intent, not raw taps. If someone edits the same note three times while offline, keep the final local state, show that it still needs sync, and send it later in order.

How do I stop old server replies from overwriting newer edits?

Track a local revision or version for each edit, and match server replies to that revision. If an older reply arrives after a newer edit, ignore it. One coordinator should own writes for one record at a time so two jobs do not race.

How should I handle iOS background limits?

Assume iOS will cut background time short. Do work in small chunks, save progress after each chunk, and stop starting new work when cancellation arrives. On the next launch, resume from the last saved point.

What should I test before I release a sync-heavy app?

Run ugly cases on a real device. Turn on airplane mode during sync, kill the app during a retry, send it to the background during an upload, and read client and server logs to confirm you got one result for one user action.