Saved locally. Now what?

In the previous post, I covered how Accumoo saves progress locally without waiting for the network. SQLite handles the save, the UI reflects the committed change, and the user goes about their day.

Meanwhile, their iPad sitting at home knows absolutely nothing about it.

Getting that change onto another device is the next challenge. Each device has its own local data, and users can make changes while their other devices are offline. Connections drop, requests get retried, and sometimes both devices edit the same data before the others know about it.

This is where Accumoo’s synchronization architecture picks up: pushing locally saved changes to the backend, pulling these changes down to each device, and handling any conflicts along the way.

iPhone and iPad each use SQLite; Apple Watch uses SwiftData. Each device independently pushes and pulls through the Rails API backed by PostgreSQL.

Open full-size diagram: independent devices and the shared backend

The backend connects the devices

Accumoo’s iPhone, iPad, and Apple Watch apps synchronize through the same Rails API, backed by PostgreSQL. Each client pushes its local changes to the API and pulls changes from the backend for their account. Each device can be online at different times; a direct connection to each other is not necessary.

The iPhone and iPad apps use SQLite for local storage; the Watch app uses SwiftData. Each client manages its own local data and communicates with the API independently. The Watch app is able to synchronize without the iPhone app running.

The backend applies server-side rules before accepting changes and makes accepted changes available for the other devices to pull. A successful local save doesn’t mean the change has synchronized. The backend still needs to accept it before other devices can retrieve it. This distinction matters when a request fails or two devices have competing edits.

The client pushes pending changes. The backend checks them; rejected records remain pending, while accepted records are saved and become available for other devices to pull.

Open full-size diagram: backend acceptance before other devices pull

Getting changes there—and back

On the iPhone and iPad, locally changed data is marked as pending synchronization in SQLite. This status survives app restarts, allowing the client to resume unfinished synchronization later.

For Event data, the client broadly follows this sequence:

  1. Push pending local changes to the API.
  2. Mark accepted records as synchronized; keep rejected records pending.
  3. Pull changed Events, then their history , and merge them into the local database.
  4. Save a server-provided sync timestamp after the synchronization cycle completes successfully. The next pull requests changes since this timestamp.

Pushing first lets the backend process this device’s pending changes before the client pulls the resulting server state. The following pull can therefore include those accepted changes alongside updates from other devices, bringing the client up to date in the same synchronization cycle. Pending local edits still need protection during the merge; request order alone doesn’t prevent conflicts.

Events are pulled before history because each history record belongs to an Event. Pulling the Events first ensures these Event records exist locally when their history arrives.

If the push succeeds but the pull fails, accepted records stay marked as synchronized. The client retains its previous sync timestamp so the next pull still includes changes it may have missed.

A successful HTTP response doesn’t mean every record was accepted. The client checks the response and only marks the accepted records as synchronized.

The iPhone and iPad push pending changes, mark accepted records synchronized, pull and merge Events before history, then save the sync timestamp after success. A failed pull keeps the previous sync timestamp.

Open full-size diagram: the push and pull cycle

When the network leaves you guessing

A failed push doesn’t always mean the backend failed to save the changes. Consider this sequence:

  1. The client pushes its pending changes.
  2. The backend saves them and sends a response.
  3. The connection drops before the client receives the response.

The backend has the changes, but the client has no confirmation. The records remain pending locally and can be included in the next push.

Retries need to be idempotent: pushing the same record again must not create a duplicate. Accumoo preserves the record’s original UUID so the backend can recognize it.

The backend uses this ID to find an existing record or create it if it hasn’t arrived before. Re-pushing the same history record, therefore, targets the same record rather than creating a duplicate.

The client also needs to distinguish a retry from a new user action. Tapping + again creates a new history record with its own ID; retrying the previous push maintains the ID of the history record previously saved locally.

Stable, unique IDs address duplicate records, but they don’t decide which version of an Event should “win” when two devices push a competing edit. This requires conflict handling.

Client and Backend columns show record A staying pending after a lost response. The client retries with the same UUID; the backend finds the existing record without creating a duplicate, then confirms it so the client can mark A synchronized. A new user action gets a new history record ID.

Open full-size diagram: retrying after a lost response

When devices disagree

Two devices can change the same Event, whether online or offline, before either has received the changes of the other. Both local saves can succeed, but the versions they push may disagree. The last device to synchronize doesn’t automatically win.

For example, you change your weekly Walk goal from 3 to 4 on your iPhone. Before your iPad receives this change, you set the same goal to 5 on your iPad. Both changes save locally, but the devices now push different goals for the same Event: 4 and 5.

The backend compares an incoming Event with its saved version, checking:

  • Timestamps: when each version of the Event was last updated. These describe changes to the Event, rather than the sync timestamp used for pulls.
  • Tracking periods : the specific intervals an Event tracks, such as the current week for a weekly Walk goal. Matching counts from different weeks don’t describe the same progress.
  • Lifecycle rules: which changes are allowed when an Event is active, paused , archived, or deleted. For example, a paused Event cannot record new progress.

An older version with different data is reported as a conflict rather than replacing the backend’s version. This prevents an older version from overwriting newer changes already saved by another device. Matching data for the same tracking period can be accepted without reporting a conflict, provided the update passes the backend’s validation for the Event lifecycle and the age of the incoming update.

Choosing which version to keep

On the iPhone and iPad, an Event rejected due to a conflict, remains “pending.” A normal pull preserves these pending local edits rather than overwriting them with the backend’s version. The conflict screen shows both versions so the user can choose between Keep Mine or Use Server Version.

This choice applies to the Event as a whole; it doesn’t merge individual fields from both versions. The Watch app uses a simpler conflict policy: it accepts the backend’s version when its push reports a conflict.

Devices change weekly Walk goal 3 to 4 and 5 before receiving each other’s changes. The backend checks the versions; when an older, differing version produces a conflict, the user chooses Keep Mine or Use Server Version on iPhone or iPad.

Open full-size diagram: competing edits to an Event

Deletions need to reach offline devices, too

Suppose an Event is deleted on the iPhone while the iPad is offline. Removing the Event from the backend entirely would leave the iPad with no record of the deletion. Its next push could recreate the Event!

The backend instead retains a deletion marker, commonly called a tombstone. Other devices pull this marker and apply the deletion locally. The backend also rejects pushes that would restore a deleted Event, even if an offline device has since edited it.

A backend deletion takes precedence over pending local edits to that Event. It is an explicit conflict rule, rather than treating whichever device reconnects last, as the authority.

Connected steps in iPhone, Backend and iPad columns show a local deletion pushed to the backend, which retains a deletion marker. An older iPad push is rejected; the iPad then pulls the marker and deletes the Event locally, including pending edits.

Open full-size diagram: deletions reaching other devices

The tradeoffs

This design lets each device keep working while synchronization waits for connectivity. The shared backend gives the devices a place to exchange updates; and, the push/pull flow gives each client a way remain synchronized.

Devices may temporarily show different data. A successful local save doesn’t instantly update every other device, and competing edits may require a choice about which version to keep. These are behaviors the app needs to explain clearly to the user.

Owning the synchronization behavior also means maintaining it. I need to test failed pushes, failed pulls, app restarts, competing edits, and deletions—not just the case where every request succeeds. Client and API changes also need to remain compatible with older supported app versions and preserve pending local changes during upgrades.

The architecture gives Accumoo responsive local saves and control over how these saves are replicated to other devices. Reliability depends on the details: which changes were accepted, what remains pending, and what happens on the next attempt.

Owning the Rails API gives me the freedom to make these choices, along with the responsibility of keeping them working as Accumoo evolves.