◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 27 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 27 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-06-17Advanced

Offline-First Sync for SwiftData in Rork Max — Merging Changes Without Conflicts

How to make the SwiftData apps Rork Max generates sync offline-first so edits survive a flaky connection. Instead of overwriting whole rows, we merge per change, propagate deletes with tombstones, and queue writes that can be resent — shown in code, with the production trade-offs that actually mattered.

Rork Max235SwiftDataOffline SyncConflict ResolutionMergeArchitecture23

✦ Premium Article

I was reordering a wallpaper collection on my iPad, walked into a subway station, and when I reopened the app after the gate the order had snapped back to where it started — and it was my own app. The save had succeeded locally, but the moment I came back online the server's stale state overwrote it.

The apps Rork Max generates are native Swift, so local persistence is straightforward with SwiftData. But "saved locally" and "won't be lost after sync" are two different guarantees. Here is how I structure offline-first sync that survives a flaky connection — by treating sync as merging changes, not overwriting rows.

Why "it saved" can't be trusted

Many sync implementations send the device's entire current state to the server and write the server's entire state back. That works while one device touches the data in one direction. It breaks the instant a second device appears: a favorite you set on iPhone and an order you changed on iPad clobber each other through whichever "whole" syncs last.

The root issue is choosing the row — the record's final state — as the unit of sync. Final state only tells you which side is newer. What you actually need is the history of changes: who touched which field, and when. Move the unit from row to change, and edits to different fields can coexist without conflict.

Give the SwiftData model sync metadata

Start by adding the metadata each model needs for sync decisions: an update timestamp, a logical-delete flag (a tombstone), and a local-only flag for whether it is synced.

import SwiftData
import Foundation
 
@Model
final class WallpaperItem {
    @Attribute(.unique) var id: UUID
    var title: String
    var sortIndex: Int
    var isFavorite: Bool
 
    // --- sync metadata ---
    var updatedAt: Date          // when the "content" last changed
    var isDeleted: Bool          // tombstone (never hard-delete)
    var dirty: Bool              // are there unsent local changes?
    var revision: Int            // version number assigned by the server
 
    init(id: UUID = UUID(), title: String, sortIndex: Int) {
        self.id = id
        self.title = title
        self.sortIndex = sortIndex
        self.isFavorite = false
        self.updatedAt = .now
        self.isDeleted = false
        self.dirty = true
        self.revision = 0
    }
}

Three things matter here. Make id a UUID shared with the server so identity holds across devices. Make deletion a logical isDeleted so the fact of deletion can itself be synced. And keep dirty so the device remembers which unsent edits it owes the server once the signal returns.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A SwiftData model carrying sync metadata, plus a field-level merge that does not blindly let the last write overwrite everything
✦A delete model that propagates as a tombstone instead of removing the row, so an item can't quietly resurrect on the other device
✦An outbox queue that never discards edits when the signal drops, with an idempotency key that prevents the same change being saved twice
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-08-04
Exactly 20 Characters, Still Rejected: Putting Character Counting Behind One Boundary
Emoji and combining marks make your client and your server disagree about length. I measured four counting methods on Node v22 and found 75.6% of naive truncations split a grapheme and 36.1% produced replacement characters over UTF-8. Here is the shared-module design that fixed it.
⬡ Dev Tools2026-08-01
Every Experiment Kept Landing on the Same Devices: Hash Choice and Salt Position, Measured Across a Million IDs
On-device experiment assignment looked fine in isolation, then collapsed the moment two experiments ran side by side. Here is what one million IDs revealed about four hash functions, why the culprit was the concatenation order rather than hash quality, and the implementation I settled on, with the measured numbers.
⬡ Dev Tools2026-07-24
Collapsing Duplicate Requests Into One: A Reference-Counted Single-Flight Layer
When several components fire the same API call at launch, you get a burst of identical requests. Here is a single-flight layer that shares one in-flight promise instead: how to build the key that decides the folding, the trap of handing out a failed promise forever, the trap of one caller's abort cancelling everyone, and the test that keeps it all from regressing, with the real network numbers alongside.