DEV Community

Sebastien Lato
Sebastien Lato

Posted on

Offline-First SwiftUI Architecture

Most apps look simple until the moment the user goes offline.

That’s when everything breaks:

  • API calls fail
  • UI gets stuck
  • cached data becomes stale
  • mutations are lost
  • navigation gets inconsistent
  • errors appear everywhere

A true offline-first architecture solves this by designing the app to work even without an internet connection — then sync when the network returns.

This post covers the production-grade offline patterns used in modern SwiftUI apps:

  • caching
  • persistence
  • optimistic updates
  • mutation queues
  • background sync
  • failure recovery

Let’s build apps that never break offline. 🚀


🧱 1. The Core Offline-First Flow

A modern offline system looks like this:
Load from cache instantly

Show UI immediately

Start background refresh

Merge updates

Write back to cache

Instant UI + continuous sync = the correct pattern.


📦 2. Local Cache Comes First (Always)

Never block the UI waiting for network responses.

Example local cache service:

protocol CacheService {
    func load<T: Decodable>(_ key: String, as type: T.Type) -> T?
    func save<T: Encodable>(_ value: T, key: String)
}
Enter fullscreen mode Exit fullscreen mode

Usage:

let cached = cache.load("feed", as: [Post].self)
self.posts = cached ?? []
Enter fullscreen mode Exit fullscreen mode

Your app becomes instantly responsive.


🌐 3. Network Updates Should Refresh, Not Replace

Fetch new data in the background:

Task {
    do {
        let fresh = try await api.fetchFeed()
        posts = fresh
        cache.save(fresh, key: "feed")
    } catch {
        print("Offline — using cached data")
    }
}
Enter fullscreen mode Exit fullscreen mode

If offline:

  • UI stays intact
  • no red errors
  • no crashes

This is the essence of offline-first behavior.


⚡️ 4. Optimistic Updates (Mutate UI First, Sync Later)

When the user performs an action:

  1. Update the UI immediately
  2. Save to a mutation queue
  3. Let a background task sync it later

Example:

func like(post: Post) {
    // 1. Update UI instantly
    updateLikeState(post)

    // 2. Queue mutation
    queue.enqueue(.like(post.id))

    // 3. Try syncing in background
    Task { await processQueue() }
}
Enter fullscreen mode Exit fullscreen mode

Users get zero latency, even offline.


📮 5. Mutation Queue (Your Offline Outbox)

Represent pending operations:

enum PendingMutation: Codable {
    case like(String)
    case delete(String)
    case create(PostDraft)
}
Enter fullscreen mode Exit fullscreen mode

Store them locally:

@Observable
class MutationQueue {
    private(set) var items: [PendingMutation] = []

    func enqueue(_ m: PendingMutation) {
        items.append(m)
        persist()
    }

    func dequeue() {
        items.removeFirst()
        persist()
    }
}
Enter fullscreen mode Exit fullscreen mode

This is exactly how apps like:

  • Instagram
  • Gmail
  • Notes
  • Slack stay functional offline.

🔄 6. Background Sync Loop

Periodically attempt to sync:

func processQueue() async {
    guard !queue.items.isEmpty else { return }

    for mutation in queue.items {
        do {
            try await api.sync(mutation)
            queue.dequeue()
        } catch {
            // Still offline → retry later
            break
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Trigger on:

  • app launch
  • returning online
  • foreground events
  • background refresh tasks Offline work eventually reaches the server.

📶 7. Detecting Online/Offline Status

Use NWPathMonitor:

class NetworkMonitor: ObservableObject {
    @Published var isOnline = true
    private let monitor = NWPathMonitor()

    init() {
        monitor.pathUpdateHandler = { path in
            Task { @MainActor in
                self.isOnline = path.status == .satisfied
            }
        }
        monitor.start(queue: .main)
    }
}
Enter fullscreen mode Exit fullscreen mode

Use it to retry mutations:

.onChange(of: network.isOnline) { online in
    if online { Task { await processQueue() } }
}
Enter fullscreen mode Exit fullscreen mode

🏎 8. Background Refresh Integration

Perfect for offline-first sync:

BGTaskScheduler.shared.register(forTaskWithIdentifier: "app.sync", using: nil) { task in
    Task {
        await processQueue()
        task.setTaskCompleted(success: true)
    }
}
Enter fullscreen mode Exit fullscreen mode

Your app syncs even when closed.


🧩 9. Conflict Resolution Strategy

When syncing, conflicts WILL happen.

Example:

  • User edits a record offline
  • Server record has changed in the meantime

There are three main strategies:

  1. Last-write-wins (LWW)
    Simplest. Whichever version has the newest timestamp overwrites the other.

  2. Field-level merge
    Merge only the fields the user changed, instead of replacing the entire record.

  3. Ask the user
    Used in note editors, collaborative docs, or any place where data loss is unacceptable.


📱 10. UI Design for Offline-First Apps

Use subtle UI states:
Show cached data silently
→ no spinners unless necessary

Show a tiny footer when syncing

Example:

Text("Syncing…")
    .font(.caption2)
    .foregroundStyle(.secondary)
Enter fullscreen mode Exit fullscreen mode

Mark unsynced items

Use visual hints like:

  • dotted borders
  • faded opacity
  • tiny clock symbol

Never block main flows.


🧪 11. Testing Offline Behavior

Test with no network:

func test_offline_loads_cache() async {
    let mockAPI = OfflineAPI()
    let cache = InMemoryCache()
    cache.save(samplePosts, key: "feed")

    let vm = FeedViewModel(api: mockAPI, cache: cache)
    await vm.load()

    XCTAssertEqual(vm.posts.count, samplePosts.count)
}
Enter fullscreen mode Exit fullscreen mode

Test mutation queue:

func test_mutation_enqueues_when_offline() async {
    let queue = MutationQueue()
    queue.enqueue(.like("123"))
    XCTAssertEqual(queue.items.count, 1)
}
Enter fullscreen mode Exit fullscreen mode

Offline-first architecture becomes fully testable.


🚀 Final Thoughts

Offline-first is not a “nice-to-have”.
It’s a modern app requirement.

With this architecture:

  • your UI becomes instant
  • your app never breaks offline
  • users can always continue workflows
  • sync becomes reliable
  • testing becomes predictable
  • your architecture becomes scalable

This is how real production apps behave.

Top comments (0)