●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Building Resilient Offline-First Apps with Rork Max — Local-First Databases, Sync Engines & Conflict Resolution
Master offline-first architecture with Rork Max. Learn local-first databases, CRDT sync engines, conflict resolution strategies, and production-ready patterns for building apps that work seamlessly online and offline.
Building Resilient Offline-First Apps with Rork Max
The thirty minutes spent on a subway, the multi-hour international flight, the rural valley where signal drops out without warning — mobile users spend far more time disconnected than developers tend to imagine. As an indie developer, I've spent years operating a series of wallpaper apps on my own, and across all that time the single most consistent complaint in store reviews has been offline-related: "the app freezes when I'm on the train," "favorites disappeared in airplane mode," "it stopped loading in flight."
Offline-first architecture is the design philosophy that fixes those quiet, easy-to-overlook user pains. It also directly impacts the business side — AdMob fill rates, in-app purchase restoration timing, and server cost — none of which most offline-first tutorials touch. With Rork Max now generating native Swift code directly, the kind of precise sync engines that used to require a dedicated infrastructure team are within reach of solo developers. The scope of this article is the actual implementation patterns I've arrived at after years of running these apps in production: the pitfalls I've stepped into, the recovery patterns I've shipped, and the decision criteria I now apply when designing offline behavior.
Why Offline-First Matters
Most mobile apps today treat the network as a dependency. Users experience frustrating spinners, failed requests, and lost data when connection drops. Offline-first flips this model: your app works locally first, syncs when connected, and gracefully handles disconnections.
💡
Offline-first apps don't just improve user experience—they reduce server load by batching syncs, improve privacy by processing data locally, and enable use cases like field work, aviation, and international travel where connectivity is unreliable.
The Business Case
Reduced latency: Local reads and writes are instant
Lower server costs: Fewer API calls through intelligent batching and deduplication
Better reliability: App remains functional during outages or poor connectivity
Enhanced privacy: Sensitive data processed locally before sync
Global reach: Works in regions with patchy or expensive connectivity
Local-First Architecture Patterns
The Layered Approach
Building offline-first requires three coordinated layers:
Local Storage Layer: SQLite or RealmDB for persistent local state
Sync Engine: Handles change tracking, batching, and network communication
Conflict Resolution: Strategies for merging divergent changes
Implementation with Rork Max
Rork Max generates native Swift code, giving you direct access to high-performance local storage options. Here's the foundational architecture:
// Local storage abstractionprotocol LocalDataStore { func saveChanges(_ changes: [EntityChange]) async throws func fetchEntity<T: Identifiable>(_ id: T.ID) -> T? func queryEntities<T>(matching predicate: NSPredicate) -> [T]}// Change trackingstruct EntityChange: Codable { let entityId: String let entityType: String let operation: Operation // .create, .update, .delete let timestamp: Date let data: AnyCodable let clientId: String // Identifies which client made the change}// Sync engine stateactor SyncEngine { private let localStore: LocalDataStore private let networkService: NetworkService private var pendingChanges: [EntityChange] = [] private var lastSyncTimestamp: Date? func recordChange(_ change: EntityChange) async throws { pendingChanges.append(change) try await localStore.saveChanges([change]) await triggerSyncIfNeeded() } func sync() async throws { guard !pendingChanges.isEmpty else { return } // Batch pending changes let batch = pendingChanges // Send to server let response = try await networkService.syncChanges(batch) // Apply server changes and resolve conflicts let resolved = try resolveConflicts(batch, with: response.serverChanges) // Update local store with resolved state try await localStore.saveChanges(resolved) // Clear pending changes pendingChanges.removeAll() lastSyncTimestamp = Date() }}
⚠️
Every offline-first system needs a unique client identifier to track which device made which changes. This is critical for conflict resolution. Use a stable identifier that persists across app reinstalls when possible.
✦
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
✦Lessons from years of operating a wallpaper app series on my own — concrete design decisions that prevent crashes on subways, international flights, and rural areas with unstable connectivity
✦How to choose between Last-Write-Wins, Vector Clocks, and Yjs-based CRDTs based on actual data characteristics, with working Swift code and decision criteria for each strategy
✦Real-world AdMob behavior under offline conditions, sync queue design, and purchase restoration patterns — monetization-adjacent offline pitfalls that most tutorials skip
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.
SQLite is lightweight and battle-tested, but for offline-first applications, WCDB (WeChat's fork of SQLite) adds critical features:
// WCDB setup for offline-first appsimport WCDBclass WCDBOfflineStore: LocalDataStore { let db: Database init(path: String) throws { db = try Database(at: path) try setupSchema() } private func setupSchema() throws { // Changes table for sync tracking try db.create(table: "entity_changes", of: EntityChange.self) try db.create(index: "idx_pending_sync", on: "entity_changes", indexesBy: ["timestamp", "isSynced"]) // Version vectors for causality tracking try db.create(table: "version_vectors", of: VersionVector.self) try db.create(index: "idx_entity_version", on: "version_vectors", indexesBy: ["entityId"]) } func saveChanges(_ changes: [EntityChange]) async throws { try db.inTransaction { for change in changes { try db.insert(change, intoTable: "entity_changes") try updateVersionVector(for: change) } } } private func updateVersionVector(for change: EntityChange) throws { let vector = try db.selectOne( on: VersionVector.self, from: "version_vectors", where: "entityId" == change.entityId ) ?? VersionVector(entityId: change.entityId, clock: [:]) var updatedVector = vector updatedVector.clock[change.clientId, default: 0] += 1 try db.update(table: "version_vectors", on: VersionVector.self, with: updatedVector, where: "entityId" == change.entityId) }}
RealmDB for Complex Relationships
For apps with intricate data relationships, RealmDB excels:
import RealmSwiftclass Task: Object, ObjectKeyIdentifiable { @Persisted(primaryKey: true) var id: ObjectId @Persisted var title: String = "" @Persisted var description: String = "" @Persisted var status: String = "pending" @Persisted var assignedUsers: MutableSet<User> @Persisted var lastModified: Date = Date() @Persisted var clientId: String = "" @Persisted var syncedAt: Date? @Persisted var isSynced: Bool = false}class RealmOfflineStore: LocalDataStore { let realm: Realm init() throws { // Configure Realm for offline-first var config = Realm.Configuration.defaultConfiguration config.schemaVersion = 1 // Add migration for schema updates without data loss config.migrationBlock = { migration, oldVersion in if oldVersion < 1 { // Add new columns for offline tracking migration.renameProperty(onType: "Task", from: "modified", to: "lastModified") } } Realm.Configuration.defaultConfiguration = config realm = try Realm() } func saveChanges(_ changes: [EntityChange]) async throws { try await realm.asyncWrite { for change in changes { switch change.operation { case .create, .update: let task = Task() task.id = ObjectId(timestamp: UInt32(Date().timeIntervalSince1970), machineIdentifier: [0, 0, 0], processIdentifier: UInt16.random(in: 0..<65536), counter: UInt32.random(in: 0..<16777215)) task.title = change.data["title"] as? String ?? "" task.lastModified = change.timestamp task.isSynced = false self.realm.add(task, update: .modified) case .delete: let predicate = NSPredicate(format: "id == %@", change.entityId as CVarArg) let toDelete = self.realm.objects(Task.self).where(predicate) self.realm.delete(toDelete) } } } } func getPendingChanges() throws -> [EntityChange] { let unsyncedTasks = realm.objects(Task.self).where { !$0.isSynced } return unsyncedTasks.map { task in EntityChange( entityId: task.id.stringValue, entityType: "Task", operation: .update, timestamp: task.lastModified, data: AnyCodable(["title": task.title]), clientId: task.clientId ) } }}
CRDT Sync Engines for Conflict-Free Merging
Conflict-free Replicated Data Types (CRDTs) guarantee that concurrent changes merge correctly without explicit conflict resolution logic. This is the gold standard for offline-first systems.
💡
CRDTs work by assigning each change a unique identifier that includes both timestamp and client ID. When changes arrive out of order, the CRDT algorithm deterministically merges them into the same final state on all devices.
Last-Write-Wins (LWW) CRDT
The simplest CRDT strategy for most applications:
struct LastWriteWinsElement<Value: Codable>: Codable { let value: Value let timestamp: UInt64 // Logical clock or physical time let clientId: String // For comparison in conflict resolution func isNewerThan(_ other: LastWriteWinsElement<Value>) -> Bool { if timestamp != other.timestamp { return timestamp > other.timestamp } return clientId > other.clientId // Tie-breaker: lexicographic order }}class LWWCRDTSyncEngine { func merge<Value: Codable>( local: LastWriteWinsElement<Value>, remote: LastWriteWinsElement<Value> ) -> LastWriteWinsElement<Value> { // Always keep the version with the higher timestamp remote.isNewerThan(local) ? remote : local } func mergeConflictingChanges( localChanges: [EntityChange], remoteChanges: [EntityChange] ) -> [EntityChange] { var merged: [String: EntityChange] = [:] // Process both sets of changes for change in localChanges + remoteChanges { let key = change.entityId if let existing = merged[key] { // Keep the change with higher timestamp let existingTime = UInt64(existing.timestamp.timeIntervalSince1970 * 1000) let newTime = UInt64(change.timestamp.timeIntervalSince1970 * 1000) if newTime > existingTime || (newTime == existingTime && change.clientId > existing.clientId) { merged[key] = change } } else { merged[key] = change } } return Array(merged.values) }}
Vector Clock CRDT
For detecting causality between changes:
struct VectorClockElement: Codable, Equatable { let value: String let vectorClock: [String: UInt64] // clientId -> logical clock let clientId: String func happensBefore(_ other: VectorClockElement) -> Bool { var atLeastOneLess = false for (clientId, time) in vectorClock { let otherTime = other.vectorClock[clientId] ?? 0 if time > otherTime { return false // This happened after, not before } if time < otherTime { atLeastOneLess = true } } return atLeastOneLess } mutating func increment(by clientId: String) { vectorClock[clientId, default: 0] += 1 }}class VectorClockSyncEngine { func detectConcurrentChanges( change1: VectorClockElement, change2: VectorClockElement ) -> Bool { // Changes are concurrent if neither happened before the other !change1.happensBefore(change2) && !change2.happensBefore(change1) } func mergeConcurrentChanges( local: VectorClockElement, remote: VectorClockElement ) -> VectorClockElement { // For concurrent changes, implement application-specific merge logic // Example: merge by choosing alphabetically first value let mergedValue = [local.value, remote.value].sorted().first ?? local.value var mergedClock = local.vectorClock for (clientId, time) in remote.vectorClock { mergedClock[clientId] = max(mergedClock[clientId] ?? 0, time) } return VectorClockElement( value: mergedValue, vectorClock: mergedClock, clientId: local.clientId ) }}
Conflict Resolution Strategies
Not all conflicts can be resolved automatically. Here are proven strategies for different scenarios:
Semantic Conflict Resolution
class SemanticConflictResolver { // For document-like data (text, lists) func resolveTextConflict( local: String, remote: String, original: String ) -> String { // Three-way merge approach // Check if only one side changed if local == original { return remote // Remote side won } if remote == original { return local // Local side won } // Both sides changed - need operational transformation or CRDTs // Fallback: prefer longer content (less data loss) return local.count >= remote.count ? local : remote } // For list items func resolveListConflict( local: [String], remote: [String], original: [String] ) -> [String] { // Use longest common subsequence to detect what actually changed let lcs = longestCommonSubsequence(original, local) let remoteChanges = detectChanges(original, remote) // Apply remote changes to local result var merged = local for change in remoteChanges { if !merged.contains(change.newValue) { merged.append(change.newValue) } } return merged } // For numeric fields func resolveNumericConflict( local: Double, remote: Double, original: Double ) -> Double { let localChange = local - original let remoteChange = remote - original // Apply both changes (addition is commutative) return original + localChange + remoteChange }}
User-Driven Conflict Resolution
Sometimes only the user can resolve conflicts:
class ConflictPrompt { struct Conflict { let entityId: String let fieldName: String let localValue: Any let remoteValue: Any let timestamp: Date } func presentConflictUI( conflicts: [Conflict], onResolve: @escaping ([String: Any]) -> Void ) { // Build resolution UI with side-by-side comparison // User selects which version to keep or manually edits var resolutions: [String: Any] = [:] for conflict in conflicts { // Present UI for user to choose let userChoice = showResolutionDialog(conflict) resolutions[conflict.entityId] = userChoice } onResolve(resolutions) } private func showResolutionDialog(_ conflict: Conflict) -> Any { // In real implementation, present SwiftUI modal // For now, return a resolved value conflict.remoteValue // Example: prefer remote }}
Implementing a Production Sync Engine
Here's a complete, production-ready sync engine combining all pieces:
class ProductionSyncEngine { private let localStore: LocalDataStore private let networkService: NetworkService private let conflictResolver: SemanticConflictResolver private var syncState = SyncState() struct SyncState { var lastSyncTime: Date? var isSyncing = false var pendingChangeCount = 0 var lastSyncError: Error? } // Automatic sync on network availability func setupNetworkObserver() { NotificationCenter.default.addObserver( forName: NSNotification.Name.reachabilityChanged, object: nil, queue: .main ) { [weak self] _ in Task { try? await self?.syncWhenNeeded() } } } // Smart sync: batch changes and deduplicate func recordChange(_ change: EntityChange) async throws { try await localStore.saveChanges([change]) syncState.pendingChangeCount += 1 // Trigger sync after a delay (debounce) try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds try await syncWhenNeeded() } // Main sync loop func syncWhenNeeded() async throws { guard !syncState.isSyncing else { return } guard networkAvailable() else { return } syncState.isSyncing = true defer { syncState.isSyncing = false } do { // Step 1: Get pending changes from local store let pendingChanges = try await localStore.getPendingChanges() guard !pendingChanges.isEmpty else { return } // Step 2: Batch and deduplicate let batchedChanges = batchAndDeduplicate(pendingChanges) // Step 3: Send to server let response = try await networkService.sync( clientChanges: batchedChanges, lastSyncTime: syncState.lastSyncTime ) // Step 4: Resolve conflicts let resolvedChanges = try resolveConflicts( local: batchedChanges, remote: response.serverChanges ) // Step 5: Merge remote changes try await applyRemoteChanges(response.serverChanges) // Step 6: Mark changes as synced try await markAsSynced(batchedChanges) syncState.lastSyncTime = Date() syncState.lastSyncError = nil } catch { syncState.lastSyncError = error throw error } } private func batchAndDeduplicate(_ changes: [EntityChange]) -> [EntityChange] { var batchedMap: [String: EntityChange] = [:] for change in changes { // Keep only the latest change per entity batchedMap[change.entityId] = change } return Array(batchedMap.values) } private func resolveConflicts( local: [EntityChange], remote: [EntityChange] ) -> [EntityChange] { var resolved: [String: EntityChange] = [:] // Create map of remote changes by entity ID let remoteMap = Dictionary(grouping: remote) { $0.entityId } // Process all changes for change in local + remote { let key = change.entityId if let remoteChange = remoteMap[key]?.first { // Conflict detected - resolve using strategy let winner = conflictResolver.resolveConflict( local: change, remote: remoteChange ) resolved[key] = winner } else { resolved[key] = change } } return Array(resolved.values) } private func networkAvailable() -> Bool { // Check network status true // Simplified }}
// Mock network service for testingclass MockNetworkService: NetworkService { enum NetworkCondition { case online case offline case slowConnection(delaySeconds: Double) case flaky(successRate: Double) } var condition: NetworkCondition = .online var syncHistory: [SyncRequest] = [] func sync(clientChanges: [EntityChange], lastSyncTime: Date?) async throws -> SyncResponse { syncHistory.append(SyncRequest(changes: clientChanges, time: lastSyncTime ?? Date())) switch condition { case .offline: throw NetworkError.noConnection case .slowConnection(let delay): try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) return SyncResponse(serverChanges: [], conflicts: []) case .flaky(let successRate): if Double.random(in: 0...1) > successRate { throw NetworkError.timeout } return SyncResponse(serverChanges: [], conflicts: []) case .online: return SyncResponse(serverChanges: [], conflicts: []) } }}// Test offline changes are trackedfunc testOfflineChangesArePersisted() async throws { let store = try RealmOfflineStore() let syncEngine = ProductionSyncEngine(localStore: store, networkService: MockNetworkService()) // Record change while offline let change = EntityChange( entityId: "task-1", entityType: "Task", operation: .create, timestamp: Date(), data: AnyCodable(["title": "Offline Task"]), clientId: UIDevice.current.identifierForVendor?.uuidString ?? "" ) try await syncEngine.recordChange(change) // Verify change is in local store let pendingChanges = try await store.getPendingChanges() XCTAssertEqual(pendingChanges.count, 1) XCTAssertEqual(pendingChanges.first?.entityId, "task-1")}// Test conflict resolutionfunc testConflictResolution() async throws { let resolver = SemanticConflictResolver() let local = "Updated text locally" let remote = "Updated text remotely" let original = "Original text" let resolved = resolver.resolveTextConflict( local: local, remote: remote, original: original ) // Verify resolution picked one of the changes XCTAssertTrue([local, remote].contains(resolved))}
Performance Optimization for Offline-First
Indexing and Query Performance
class OptimizedOfflineStore { func setupOptimizedSchema() throws { // Create indices on frequently queried fields try db.create( index: "idx_task_status_date", on: "tasks", indexesBy: ["status", "createdAt"] ) // Separate index for sync-related queries try db.create( index: "idx_pending_sync", on: "tasks", indexesBy: ["isSynced", "lastModified"] ) } func queryPendingChanges(limit: Int = 100) async throws -> [EntityChange] { // Uses index for fast lookups return try db.selectAll( from: EntityChange.self, where: ("isSynced" == false).order(by: "timestamp".asColumn(), .ascending), limit: limit ) }}
Memory Management for Large Datasets
class StreamingOfflineStore { // Stream large result sets to avoid memory overload func streamPendingChanges( batchSize: Int = 500, onBatch: @escaping ([EntityChange]) -> Void ) async throws { var offset = 0 var hasMore = true while hasMore { let batch = try db.selectAll( from: EntityChange.self, where: "isSynced" == false, limit: batchSize, offset: offset ) hasMore = batch.count == batchSize onBatch(batch) offset += batchSize } }}
Common Pitfalls and Solutions
⚠️
**Clock skew issues**: Different devices have different system clocks. For timestamp-based conflict resolution, always use server time when available, and implement clock skew detection to warn users.
Handling Large Files Offline
class LargeFileSync { // Chunk large files for reliable sync func uploadLargeFile( fileURL: URL, chunkSize: Int = 5_000_000 // 5MB chunks ) async throws { let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int ?? 0 let fileId = UUID().uuidString var offset = 0 while offset < fileSize { let remainingSize = fileSize - offset let currentChunkSize = min(chunkSize, remainingSize) let chunk = try readChunk(fileURL, offset: offset, size: currentChunkSize) try await networkService.uploadChunk( fileId: fileId, chunkIndex: offset / chunkSize, totalChunks: (fileSize + chunkSize - 1) / chunkSize, data: chunk ) offset += currentChunkSize } }}
Handling Deletions in Offline Mode
class DeletionTracking { // Mark for deletion instead of immediate removal func markForDeletion(_ entityId: String) async throws { let deleteChange = EntityChange( entityId: entityId, entityType: "Task", operation: .delete, timestamp: Date(), data: AnyCodable([:]), clientId: clientId ) try await localStore.saveChanges([deleteChange]) // Keep record in local store until sync confirms deletion } // Only truly delete after successful sync func purgeDeletedEntities(afterSyncingAt date: Date) async throws { try await localStore.deleteChanges( where: NSPredicate(format: "operation == %@ AND syncedAt < %@", "delete", date as NSDate) ) }}
Implementation Pitfalls & Patterns I Now Recommend — From the Production Trenches
The sections above describe the ideal architecture. Now I want to share the production-grade pitfalls I've hit repeatedly while running my own apps at scale, plus the patterns I currently recommend. These are the quiet, painful lessons that don't appear in academic CRDT papers but determine whether your app survives the App Store review queue.
Pitfall 1: Offline data silently disappears
The earliest issue I hit was users force-quitting the app before SQLite finished its local write. Before I enabled WAL mode, users were favoriting wallpapers on the subway and finding them gone after returning to surface; within the first week post-launch, the store reviews carried over a dozen reports. After the fix, crash reports related to this dropped by roughly 90%, and Day 1 retention improved from 38% to 47%.
What I now recommend: if you're using SQLite, always combine journal_mode=WAL with synchronous=NORMAL, and explicitly trigger a checkpoint after writes. If you choose RealmDB instead, end every transaction with realm.refresh() to keep memory and the persisted layer in sync.
// Recommended: SQLite WAL mode + explicit checkpointlet connection = try Connection(dbPath)try connection.execute("PRAGMA journal_mode=WAL;")try connection.execute("PRAGMA synchronous=NORMAL;")try connection.transaction { try connection.run(favorites.insert(or: .replace, item))}// Transactions auto-commit, but when you want to force the sync explicitly:try connection.execute("PRAGMA wal_checkpoint(PASSIVE);")
Pitfall 2: AdMob banners take over the screen when offline
Offline-first tutorials rarely cover ad behavior under offline conditions, but for indie developers this is critical. AdMob can leave empty GADBannerView instances on screen during connection failures, causing layout breakage and stuck loading states. In my wallpaper app, this caused eCPM to drop over 30% temporarily during a bad rollout.
I now monitor connectivity with Reachability and completely collapse the ad slot when offline, giving the screen real estate back to actual content. Ad retries use exponential backoff (5s initial, doubling each time), capped at 3 attempts — a balance I've found between user experience and eCPM recovery.
Pitfall 3: Applying CRDTs to all data inflates sync cost by 3x
The instinct is "just CRDT everything and you're safe." My first prototype put user settings and view history into Yjs documents, and the initial sync payload ballooned to over 3x its previous size. On 3G connections, first-time sync took more than 20 seconds — unacceptable for a first impression.
The decision criteria I now use:
Data with realistic conflict potential (collaboratively edited notes, multi-device drawings) → CRDT is justified
Last-write-wins is safe (current scroll position, UI preferences, theme selection) → Skip CRDT, use timestamps
Aggregate data (favorite counts, download history) → Compute server-side, treat client as read-only cache
This classification cut the sync payload by roughly 60%, and 3G first-time sync now completes within 7 seconds.
Pitfall 4: Pre-launch offline testing is usually too forgiving
Development environments have stable Wi-Fi, so offline bugs reliably escape into production. My pre-release checklist now mandates three tests:
Airplane mode cold launch — Complete a full navigation loop fully offline. No crashes, no blank screens, no infinite loading states.
Network Link Conditioner: 3G + 5% packet loss — App must remain usable on slow, flaky links.
Sync queue saturation — Generate a large number of writes while offline, then verify the batch syncs correctly on reconnect.
Adding these to my pre-release checklist reduced store reviews containing "doesn't work with bad signal" from roughly 7 per month to under 1. As an indie developer, every review counts; this kind of unglamorous validation builds the long-term reputation that keeps an app afloat.
My current recommended stack
After years of operating these apps in production, what I now recommend to solo developers and small teams is the simpler combination: SQLite (WAL mode) + a custom sync queue + Vector Clock-based conflict resolution. Yjs and Automerge are theoretically beautiful, but considering debuggability and Swift integration maturity, it's more pragmatic to start with this simple stack and migrate specific pieces to CRDT only when an actual collaborative requirement appears.
Closing Thoughts — Apps That Don't Make Users Notice the Network
Offline-first architecture, when you look at it from the technical surface, presents intimidating concepts: CRDTs, Vector Clocks, eventual consistency. But the underlying purpose is simple — reduce the number of moments a user has to think about whether they're connected.
I've spent years wrestling with offline edge cases in my own apps, and the question of how to make connectivity invisible to the user — so they never have to think about whether they're online — is one I keep coming back to. With Rork Max, the kind of precise sync engines that were once out of reach for a solo developer are now realistic to ship.
The concrete next step I'd suggest: run your own app through one full airplane-mode loop today. If you find a single blank screen, infinite spinner, or sudden crash, that's your starting point for offline-first work. Even just flipping SQLite to WAL mode tends to produce a visible retention improvement.
I'm still learning as I go, and I'd be happy to share notes with anyone else walking the indie developer path. Thank you for reading this far.
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.