iosNudge

Deep Dives

Chapter 6 of 8 5 min read

How to run a deep dive

Use the same four steps for every topic:

  1. Problem: what goes wrong without a plan.
  2. Options: two or three ways to solve it.
  3. Choice: what you pick for this app, and why.
  4. Trade-off: what you give up.

Pick 2 or 3 topics from this chapter. Choose the ones the product needs most.

Caching

Problem: the network is slow and costs battery. Users should not wait for data they saw a minute ago.

There are three levels:

LevelToolLives until
MemoryNSCache or a dictionary inside an actorThe app is killed
DiskFiles or a databaseYou delete it
HTTPURLCache with ETag headersThe server says it changed

A small memory cache with a time limit (TTL, "time to live"):

actor TTLCache<Key: Hashable & Sendable, Value: Sendable> {
    private var storage: [Key: (value: Value, expires: Date)] = [:]
    private let ttl: TimeInterval

    init(ttl: TimeInterval) {
        self.ttl = ttl
    }

    func value(for key: Key, now: Date = .now) -> Value? {
        guard let entry = storage[key], entry.expires > now else {
            storage[key] = nil      // expired: remove it
            return nil
        }
        return entry.value
    }

    func insert(_ value: Value, for key: Key, now: Date = .now) {
        storage[key] = (value, now.addingTimeInterval(ttl))
    }
}

The best user experience is stale-while-revalidate: show the saved copy at once, then load fresh data in the background.

func load() async {
    posts = await repository.cachedPosts()             // 1. instant: the copy on disk
    do {
        posts = try await repository.refreshPosts()    // 2. replace with fresh data
        banner = nil
    } catch {
        // 3. keep the old data, but tell the user
        banner = posts.isEmpty ? nil : "You are offline. Showing saved posts."
        if posts.isEmpty { state = .failed }
    }
}

Also say when the cache is cleared: after a TTL, after the user changes data, and always at logout. Clearing on logout is a privacy point. Interviewers like it.

Offline and sync

Problem: the user does something while offline. It must not be lost.

The standard answer is the outbox pattern:

  1. Save the change locally and show it at once (with a "sending" state).
  2. Put an operation in a queue that is saved on disk.
  3. Send queued operations when the network is back.
  4. Mark each one as done when the server confirms.
struct PendingOperation: Codable, Sendable, Identifiable {
    let id: UUID              // also sent as the Idempotency-Key
    let conversationID: String
    let text: String
    var attempts = 0
}

actor Outbox {
    private var queue: [PendingOperation] = []    // real app: also saved on disk
    private var isFlushing = false
    private let send: @Sendable (PendingOperation) async throws -> Void

    init(send: @escaping @Sendable (PendingOperation) async throws -> Void) {
        self.send = send
    }

    func enqueue(_ operation: PendingOperation) async {
        queue.append(operation)
        await flush()
    }

    /// Call on enqueue, when the network comes back, and when the app opens.
    func flush() async {
        guard !isFlushing else { return }
        isFlushing = true
        defer { isFlushing = false }
        while let operation = queue.first {
            do {
                try await send(operation)
                queue.removeFirst()
            } catch {
                queue[0].attempts += 1
                return                 // try again later
            }
        }
    }
}

Why isFlushing? Actors are reentrant: while one call waits at await send, another call can start. The flag stops two flushes from sending the same item twice.

Conflicts

Two devices edit the same note. Who wins?

StrategyHowTrade-off
Last write winsThe newest change replaces the old oneSimple, but can lose data
Version checkSend baseVersion. The server returns 409 if it changed.Safe, but the app must merge or ask the user
Field-level mergeMerge each field on its ownMore work, fewer conflicts
CRDTsData types that always mergeGreat for live collaboration, complex
struct Note: Codable {
    let id: String
    var text: String
    var version: Int    // the server adds 1 on every change
}
// PUT /v1/notes/42  { "text": "...", "base_version": 7 }
// 200 -> saved as version 8
// 409 -> someone else saved version 8 first: fetch it, merge, or ask the user

For most apps, "version check plus ask the user" is a strong answer. Only bring up CRDTs for real-time collaboration.

Real-time updates

Problem: new messages must appear in about a second.

  • App open: a WebSocket.
  • App closed: push notifications.
  • After a reconnect: fetch what you missed with REST, using the last sequence number you saw.

Reconnect with exponential backoff and jitter. Backoff means waiting longer after each failure. Jitter adds randomness, so a million phones do not reconnect at the same second.

func reconnectDelay(attempt: Int) -> Duration {
    let base = min(30.0, pow(2.0, Double(attempt)))   // 1, 2, 4, 8 ... max 30 seconds
    let jitter = Double.random(in: 0...0.5) * base
    return .seconds(base + jitter)
}

Events can arrive twice or out of order. Sort by the server's sequence number, never by the device clock. Drop duplicates by ID.

struct MessageList {
    private(set) var messages: [Message] = []   // sorted by sequence
    private var ids: Set<String> = []

    mutating func insert(_ message: Message) {
        guard ids.insert(message.id).inserted else { return }   // seen it already
        let index = messages.firstIndex { $0.sequence > message.sequence } ?? messages.endIndex
        messages.insert(message, at: index)
    }
}

Image handling

Problems: big images use a lot of memory. Decoding on the main thread makes scrolling stutter. Reused cells can show the wrong image.

Downsample to the size you show. ImageIO creates a small image without decoding the full one:

import ImageIO
import UIKit

func downsample(_ data: Data, to size: CGSize, scale: CGFloat) -> UIImage? {
    let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil }
    let options = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,      // decode now, not on the main thread later
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) * scale,
    ] as CFDictionary
    guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { return nil }
    return UIImage(cgImage: image)
}

An image loader with a memory cache and request coalescing (ten cells asking for the same URL share one download):

actor ImageLoader {
    private let cache = NSCache<NSURL, UIImage>()
    private var inFlight: [URL: Task<UIImage, any Error>] = [:]

    func image(for url: URL, size: CGSize, scale: CGFloat) async throws -> UIImage {
        if let cached = cache.object(forKey: url as NSURL) { return cached }
        if let running = inFlight[url] { return try await running.value }

        let task = Task.detached(priority: .userInitiated) {   // download and decode off the actor
            let (data, _) = try await URLSession.shared.data(from: url)
            guard let image = downsample(data, to: size, scale: scale) else {
                throw URLError(.cannotDecodeContentData)
            }
            return image
        }
        inFlight[url] = task
        defer { inFlight[url] = nil }
        let image = try await task.value
        cache.setObject(image, forKey: url as NSURL)
        return image
    }
}

Cancel work for cells that scroll away. In SwiftUI, .task(id: url) cancels for you. In UIKit, cancel in prepareForReuse:

final class PhotoCell: UICollectionViewCell {
    let photoView = UIImageView()
    private var loadTask: Task<Void, Never>?

    func show(_ url: URL, loader: ImageLoader) {
        let scale = traitCollection.displayScale
        loadTask = Task {
            let image = try? await loader.image(for: url, size: bounds.size, scale: scale)
            if !Task.isCancelled { photoView.image = image }
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        loadTask?.cancel()    // this cell now shows another post
        photoView.image = nil
    }
}

Add prefetching (start loading rows just before they appear) and a disk cache for repeat visits.

Background work

Problem: iOS suspends your app soon after the user leaves it. Pick the right tool:

NeedAPI
Finish a short save or uploadbeginBackgroundTask (about 30 seconds)
Large uploads or downloadsBackground URLSession (the system does the transfer)
Refresh content now and thenBGAppRefreshTask (the system picks the time)
Heavy work like database cleanupBGProcessingTask (often at night, while charging)
A long task the user started (iOS 26+)BGContinuedProcessingTask (shows progress to the user)
The server has new dataSilent push (limited by the system, not guaranteed)

In SwiftUI, handle a refresh task with a scene modifier:

@main
struct NewsApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
            .backgroundTask(.appRefresh("com.example.news.refresh")) {
                await NewsSync().pullLatest()
                scheduleNextRefresh()
            }
    }
}

func scheduleNextRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.example.news.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)   // not before 30 minutes
    try? BGTaskScheduler.shared.submit(request)
}

Key points: the system decides when, so never promise exact times. Keep the work short. List the task ID in Info.plist.

Concurrency

Say these rules:

  • UI state lives on the @MainActor.
  • Shared mutable state lives in an actor.
  • Parallel work uses async let or a task group.
  • Long work checks for cancellation.

Two requests in parallel:

func loadProfileScreen(userID: String) async throws -> ProfileScreen {
    async let profile = api.profile(id: userID)
    async let posts = api.posts(userID: userID)
    return try await ProfileScreen(profile: profile, posts: posts)   // waits for both
}

Search as you type, with debounce and cancellation:

@MainActor
@Observable
final class SearchViewModel {
    private(set) var results: [SearchResult] = []
    private var searchTask: Task<Void, Never>?

    func queryChanged(_ text: String) {
        searchTask?.cancel()                                    // the old search is not needed
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))      // wait until typing stops
            guard !Task.isCancelled else { return }
            let found = (try? await api.search(text)) ?? []
            guard !Task.isCancelled else { return }             // a newer query won
            results = found
        }
    }
}

Interviewers may still ask about GCD. DispatchQueue.main.async is the old way to reach the main thread. In new code, use @MainActor. Swift 6 checks data races at compile time.

Error handling

Problem: many things fail. Each failure needs a different reaction.

enum AppError: Error, Equatable {
    case offline
    case unauthorized
    case server(status: Int)
    case decoding

    var isRetryable: Bool {
        switch self {
        case .offline, .server: true
        case .unauthorized, .decoding: false
        }
    }
}

Retry only errors that can heal, with backoff:

func withRetry<T: Sendable>(
    maxAttempts: Int = 3,
    _ operation: @Sendable () async throws -> T
) async throws -> T {
    var attempt = 1
    while true {
        do {
            return try await operation()
        } catch let error as AppError where error.isRetryable && attempt < maxAttempts {
            try await Task.sleep(for: .seconds(pow(2.0, Double(attempt))))   // 2 s, then 4 s
            attempt += 1
        }
    }
}

Then map each error to what the user sees:

ErrorWhat the user sees
OfflineSaved data and an "offline" banner. Retry when the network is back.
Unauthorized (401)Refresh the token without asking. If that fails, show login.
Server (5xx)"Something went wrong" with a Retry button.
DecodingSame as server. Log the details, they point to a bug.

Never show raw error text to users. Always log the real error for yourself.