Skip to Content
Course content

127: Practice Exercise: Building an Offline-First Data Sync Layer

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen a lot of developers approach offline-first architecture with a dangerous assumption: that "offline-first" is just a fancy way of saying "cache the data and upload it when the connection returns." It sounds simple, right? You save a record to a local database, and once the NWPathMonitor tells you the internet is back, you fire off a POST request. But if you build it this way, you aren't building a sync layer—you're building a data-loss engine.

The "Last Write Wins" Fallacy

Here is exactly where that logic falls apart. Imagine you're building a collaborative Travel Itinerary app. You and a partner are both editing the same "Day 1: Paris" itinerary. You go into a subway tunnel (offline) and change the hotel name to "Hotel Ritz." Simultaneously, your partner (online) changes the hotel name to "Hotel Lumière."

If your logic is simply "upload when connected," the moment you exit that tunnel, your app will send the "Hotel Ritz" update to the server. The server, seeing a newer request, overwrites the partner's "Hotel Lumière" change. Your partner's work is gone, and they have no idea why. This is the "Last Write Wins" problem, and in a professional production environment, it's a bug that will get you a lot of angry Jira tickets.

Tracking Intent with Sync States and Timestamps

To do this right, we have to stop thinking about "saving data" and start thinking about "tracking intent." We don't just save the current state of an object; we save the fact that a change was requested. I usually implement this by adding a syncStatus enum and a lastModified timestamp to every synchronizable entity.

enum SyncStatus: String, Codable {
    case synced      // Matches the server
    case pendingCreate // New record, not yet on server
    case pendingUpdate // Modified locally, needs upload
    case deleted      // Marked for deletion locally
}

By tagging your data this way, your sync layer doesn't just blindly upload everything. It queries for anything that isn't .synced. More importantly, when the server responds, you compare timestamps. If the server's updatedAt is newer than the local timestamp from when the user started their edit, you've found a conflict. Now you can actually handle it—either by merging the changes, prompting the user, or using a deterministic rule—instead of just wiping out data.

The Synchronization Loop

The actual architecture I recommend is a unidirectional loop. You never let your UI talk directly to the API; the UI talks to the Local Store, and a separate SyncManager manages the bridge to the API. This keeps your app snappy because the UI is always reading from the local disk, never waiting on a network request.

  • Pull: Fetch changes from the server. If a remote record is newer than the local one, update the local store.
  • Resolve: Identify local records marked as .pendingUpdate that also have newer versions on the server. This is where your conflict logic lives.
  • Push: Send all .pendingCreate and .pendingUpdate records to the server.
  • Acknowledge: Once the server confirms receipt, flip the local status back to .synced.

It's a bit more boilerplate up front, but it's the only way to ensure that your users don't lose their data the moment they walk into an elevator.




📋 Practical Task

Exercise: Implementing a Conflict-Aware Sync Manager for a Recipe Book

You are tasked with building the sync logic for a "Recipe Book" app. The app allows users to edit ingredients offline. Your goal is to implement a SyncManager that prevents accidental data overwrites using timestamps.

Requirements:

  • Create a Recipe struct that includes an id, title, lastModified (Date), and a SyncStatus (synced, pendingUpdate, pendingCreate).
  • Implement a SyncManager class with a method processSync(localRecipe: Recipe, serverRecipe: Recipe) -> Recipe.
  • Conflict Logic:
    • If the localRecipe is .synced, always take the serverRecipe.
    • If the localRecipe is .pendingUpdate but the serverRecipe.lastModified is newer than the localRecipe.lastModified, the server wins (simulate a conflict resolution by returning the server version).
    • If the localRecipe is .pendingUpdate and is newer than the server version, return the local version and mark it as ready for upload.
  • Write a small test suite in a main.swift file that simulates three scenarios: a clean update, a server-wins conflict, and a local-wins conflict.

Tip: Use Date().addingTimeInterval() to easily create timestamps that are precisely in the past or future for your test cases.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.