Deep Dives
How to run a deep dive
Use the same four steps for every topic:
- Problem: what goes wrong without a plan.
- Options: two or three ways to solve it.
- Choice: what you pick for this app, and why.
- 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:
| Level | Tool | Lives until |
|---|---|---|
| Memory | NSCache or a dictionary inside an actor | The app is killed |
| Disk | Files or a database | You delete it |
| HTTP | URLCache with ETag headers | The 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:
- Save the change locally and show it at once (with a "sending" state).
- Put an operation in a queue that is saved on disk.
- Send queued operations when the network is back.
- 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?
| Strategy | How | Trade-off |
|---|---|---|
| Last write wins | The newest change replaces the old one | Simple, but can lose data |
| Version check | Send baseVersion. The server returns 409 if it changed. | Safe, but the app must merge or ask the user |
| Field-level merge | Merge each field on its own | More work, fewer conflicts |
| CRDTs | Data types that always merge | Great 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 userFor 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:
| Need | API |
|---|---|
| Finish a short save or upload | beginBackgroundTask (about 30 seconds) |
| Large uploads or downloads | Background URLSession (the system does the transfer) |
| Refresh content now and then | BGAppRefreshTask (the system picks the time) |
| Heavy work like database cleanup | BGProcessingTask (often at night, while charging) |
| A long task the user started (iOS 26+) | BGContinuedProcessingTask (shows progress to the user) |
| The server has new data | Silent 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 letor 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:
| Error | What the user sees |
|---|---|
| Offline | Saved 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. |
| Decoding | Same as server. Log the details, they point to a bug. |
Never show raw error text to users. Always log the real error for yourself.