iosNudge

Performance and Observability

Chapter 7 of 8 5 min read

Why this chapter matters

A design is not done when it works on your phone. It must be fast on old phones, and you must see problems on real users' phones. Most candidates skip this part. Ending with it lifts your answer to a senior level.

Launch time

Users notice a slow launch at once. Apple suggests showing the first frame in about 400 ms.

What slows launch down:

  • heavy work in init or application(_:didFinishLaunchingWithOptions:)
  • starting many SDKs (analytics, ads, crash reporting) before the first frame
  • reading big files or calling the network on the main thread
  • many dynamic frameworks to load

The fix: do the minimum before the first frame. Start everything else after it.

@main
struct PhotoApp: App {
    @State private var container = AppContainer()   // cheap: services are created lazily

    var body: some Scene {
        WindowGroup {
            RootView(container: container)
                .task {
                    // Runs after the first frame, not on the launch path.
                    await container.analytics.start()
                    await container.remoteConfig.refresh()
                }
        }
    }
}

Also show cached content at once (see the caching section in Deep dives). A screen with saved data feels faster than a spinner.

How to measure: the Instruments App Launch template while you develop, XCTApplicationLaunchMetric in performance tests, and launch metrics from real users in the Xcode Organizer or MetricKit.

import XCTest

final class LaunchTests: XCTestCase {
    @MainActor
    func testLaunchTime() {
        measure(metrics: [XCTApplicationLaunchMetric()]) {
            XCUIApplication().launch()
        }
    }
}

Smooth scrolling

At 60 frames per second each frame has 16.7 ms. At 120 (ProMotion) it has only 8.3 ms. If the main thread is busy longer, a frame is late. Users see a hitch (a small jump).

Keep the main thread free:

  • Decode and resize images in the background (see Deep dives).
  • Parse JSON in the background.
  • Reuse expensive objects like formatters.
  • In UIKit, reuse cells and use prefetching. In SwiftUI, use List or LazyVStack with stable IDs.

Creating a DateFormatter is slow. Do not create one per cell:

// Slow: a new formatter for every row
func rowTitleSlow(for date: Date) -> String {
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    return formatter.string(from: date)
}

// Fast: a FormatStyle is a cheap value type, and the system caches the heavy parts
func rowTitle(for date: Date) -> String {
    date.formatted(date: .abbreviated, time: .omitted)
}

In SwiftUI, give rows stable identity. Use real IDs, not array positions:

List(posts) { post in        // Post is Identifiable: SwiftUI tracks each row by post.id
    PostRow(post: post)
}

How to measure: Instruments Animation Hitches and Time Profiler, and the hitch rate from real users in MetricKit.

Memory

An iPhone cannot move app memory to disk like a Mac does. When memory runs low, the system kills the app that uses too much. This is called a jetsam event. The user just sees the app vanish. Normal crash reporters often miss it.

Common causes and fixes:

CauseFix
Full-size images in memoryDownsample to the view size
Caches that only growNSCache with a cost limit, or an LRU cache with a max size
Retain cycles[weak self] in closures that an object stores
All pages of a long feed kept foreverKeep a window of pages, reload from disk when needed

NSCache removes items by itself when memory is low. Give it a cost so it knows what is big:

final class ImageMemoryCache {
    private let cache = NSCache<NSURL, UIImage>()

    init() {
        cache.totalCostLimit = 100 * 1024 * 1024   // about 100 MB
    }

    func insert(_ image: UIImage, for url: URL) {
        let pixels = image.size.width * image.scale * image.size.height * image.scale
        cache.setObject(image, forKey: url as NSURL, cost: Int(pixels * 4))   // 4 bytes per pixel
    }

    func image(for url: URL) -> UIImage? {
        cache.object(forKey: url as NSURL)
    }
}

A classic retain cycle and its fix:

final class ChatScreenModel {
    private let socket: ChatSocket
    private(set) var messages: [String] = []

    init(socket: ChatSocket) {
        self.socket = socket
        // Leak: socket keeps the closure, the closure keeps self, self keeps socket.
        // socket.onMessage = { text in self.messages.append(text) }

        // Fix: capture self weakly.
        socket.onMessage = { [weak self] text in
            self?.messages.append(text)
        }
    }
}

How to measure: the Xcode Memory Graph Debugger, Instruments Leaks and Allocations, and exit reasons from MetricKit.

Battery

The biggest battery users are the radio, the GPS and the CPU.

  • Each network request wakes the radio for several seconds. Group requests together.
  • Use push or a WebSocket instead of polling.
  • Ask for the lowest location accuracy that works, and stop updates when you can.
  • Run heavy background work while the phone is charging.
let cleanup = BGProcessingTaskRequest(identifier: "com.example.app.cleanup")
cleanup.requiresExternalPower = true          // only while charging
cleanup.requiresNetworkConnectivity = false
try? BGTaskScheduler.shared.submit(cleanup)

let locationManager = CLLocationManager()
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters   // a city map, not turn-by-turn

How to measure: the Energy gauge in Xcode, the Instruments Power Profiler, and battery metrics in the Organizer.

Logging

Use Logger from the os framework. It is fast and it protects private data.

import os

extension Logger {
    static let feed = Logger(subsystem: "com.example.app", category: "feed")
}

func logFeedLoaded(count: Int, userID: String) {
    Logger.feed.info("Loaded \(count) posts")                               // numbers are public
    Logger.feed.debug("Feed for user \(userID, privacy: .private(mask: .hash))")
}

By default, strings you put in a log message are hidden (<private>) on devices. Numbers are shown. Never log tokens, emails or message text.

Metrics

Logs explain one problem. Metrics show trends for all users. Track a few that matter:

MetricWhy
Launch time (p50, p90)First impression
Hitch rate while scrollingSmoothness
Screen load time"Tap to content" speed
API latency and error rateServer and network health
Crash-free usersStability
Memory exits (jetsam)Hidden crashes

p90 means "90% of users are faster than this". Averages hide slow phones, so use percentiles.

Measure your own intervals with signposts. They show up in Instruments:

let signposter = OSSignposter(logger: .feed)

func loadFeedMeasured() async throws -> [Post] {
    let state = signposter.beginInterval("loadFeed")
    defer { signposter.endInterval("loadFeed", state) }
    return try await repository.fetchFeed(cursor: nil).posts
}

MetricKit gives you daily reports from real phones: launch time, hangs, memory, battery and crash diagnostics.

import MetricKit

final class MetricsReceiver: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            upload(payload.jsonRepresentation())   // send to your backend
        }
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            upload(payload.jsonRepresentation())   // crashes, hangs, disk write problems
        }
    }
}
// At launch: MXMetricManager.shared.add(receiver)

Crash reporting

  • Use a crash reporter (MetricKit diagnostics, or a third-party SDK) plus the Xcode Organizer.
  • Upload dSYM files. They turn memory addresses into function names (symbolication).
  • Watch crash-free users per version. Alert when it drops after a release.
  • Add breadcrumbs: the last screens and actions before the crash, with no personal data.
  • Record non-fatal errors too, like decoding failures.

Keep the reporter behind a protocol, so you can swap vendors:

protocol CrashReporter: Sendable {
    func breadcrumb(_ message: String)
    func recordNonFatal(_ error: any Error, context: [String: String])
}

Feature flags

A feature flag turns code on or off from the server, without a new app version. You need them on mobile, because you cannot hotfix a shipped app.

Uses: ship code turned off, turn it on later, run A/B tests, and use a kill switch when something breaks.

enum Flag: String, Sendable {
    case newFeedLayout = "new_feed_layout"
    case videoAutoplay = "video_autoplay"

    var defaultValue: Bool {
        switch self {
        case .newFeedLayout: false   // new and risky: off unless the server says on
        case .videoAutoplay: true
        }
    }
}

protocol FeatureFlags: Sendable {
    func isEnabled(_ flag: Flag) -> Bool
}

struct RemoteFeatureFlags: FeatureFlags {
    let values: [String: Bool]   // from the server, cached on disk for the next launch

    func isEnabled(_ flag: Flag) -> Bool {
        values[flag.rawValue] ?? flag.defaultValue
    }
}

Two rules. First, the app must work if the config never loads, so every flag needs a safe default. Second, remove old flags, or the code fills up with dead branches.

Gradual rollout

Never ship a big change to everyone on day one.

App Store phased release. Automatic updates go out over 7 days: 1%, 2%, 5%, 10%, 20%, 50%, then 100%. You can pause it if crashes go up. Users can still update by hand.

Server-side percentage rollout. Turn a flag on for 1% of users, then 10%, then 50%. Each user must always land in the same group. Use a stable hash for that:

import CryptoKit

/// Returns a number from 0 to 99 that is always the same for this user and flag.
func rolloutBucket(userID: String, flag: String) -> Int {
    let digest = SHA256.hash(data: Data((flag + ":" + userID).utf8))
    let number = digest.prefix(4).reduce(0) { $0 << 8 | Int($1) }
    return number % 100
}

let showNewFeed = rolloutBucket(userID: "u_42", flag: "new_feed_layout") < 10   // 10% of users

Do not use hashValue here. Swift changes hash values on every app launch, so users would jump between groups.

Forced update. Sometimes old versions must stop working, for example after a security bug. Ask the server for a minimum version at launch:

func needsForceUpdate(current: String, minimum: String) -> Bool {
    current.compare(minimum, options: .numeric) == .orderedAscending
}

needsForceUpdate(current: "5.9.1", minimum: "5.10.0")   // true: "9" < "10" as numbers

What to say

"I'd keep launch light and start SDKs after the first frame. Images are downsampled and decoded off the main thread, so scrolling stays smooth. I'd track launch time, hitch rate, API errors and crash-free users with MetricKit and signposts. The new feed ships behind a remote flag with a safe default, goes to 1% of users first with stable bucketing, and has a kill switch."