Performance and Observability
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
initorapplication(_: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
ListorLazyVStackwith 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:
| Cause | Fix |
|---|---|
| Full-size images in memory | Downsample to the view size |
| Caches that only grow | NSCache 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 forever | Keep 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-turnHow 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:
| Metric | Why |
|---|---|
| Launch time (p50, p90) | First impression |
| Hitch rate while scrolling | Smoothness |
| Screen load time | "Tap to content" speed |
| API latency and error rate | Server and network health |
| Crash-free users | Stability |
| 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 usersDo 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 numbersWhat 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."