Measure with Instruments, then fix the main thread
To profile: connect a real iPhone (ideally not the newest one), choose Product → Profile in Xcode, and pick a template:
- Time Profiler: shows which functions use the CPU, on which thread. Look at the main thread first.
- Hangs: marks every moment the main thread was blocked long enough for the user to notice.
- SwiftUI: shows which views’ bodies ran, how often and how long.
- Allocations / Leaks: memory use and objects that are never freed.
The usual SwiftUI problems are:
- Expensive work in
body: creating aDateFormatter, sorting a big array or decoding an image every time the view updates. - Unstable identity: giving list rows ids that change every update (like a new
UUID()), so SwiftUI thinks every row is new, throws the old ones away and builds new ones. - One huge view reading lots of state, so any change rebuilds everything.
Profile Release builds on a physical device; simulators run on your Mac’s CPU and are meaningless for performance. Time Profiler with “Hide System Libraries” and inverted call trees finds hot code; Hangs (and the Hang Risks in Xcode’s thread performance checker) attributes main-thread stalls of 250 ms and more; the SwiftUI instrument (rebuilt in Xcode 26 with update causes) shows body evaluation counts, durations and what triggered them; Self._printChanges() in debug answers “why did this body run?”. SwiftUI performance comes down to: cheap, side-effect-free body; stable identity (ForEach over Identifiable models with persistent ids; never id: \.self on non-unique values or ids generated in body); narrow dependencies (Observation tracks per property, so split large views into subviews that read only what they need, and pass values rather than whole models where it helps); and lazy containers for long content. Concurrency pitfall: an async function on a @MainActor type runs on the main actor between suspension points, so CPU-heavy work inside it still blocks the UI; move it to a nonisolated async function (which, since Swift 6.2 with approachable concurrency enabled, needs @concurrent to actually leave the caller’s actor) or a dedicated actor.
struct JournalEntry: Identifiable, Hashable {
let id: UUID // persistent: stored with the entry
let date: Date
let text: String
let thumbnailURL: URL
}
@Observable @MainActor
final class TimelineModel {
private(set) var rows: [TimelineRow] = [] // sorted and formatted once, when data changes
private static let dateFormat = Date.FormatStyle(date: .abbreviated, time: .shortened)
func update(with entries: [JournalEntry]) async {
let rows = await Self.makeRows(entries) // off the main actor
self.rows = rows
}
@concurrent
nonisolated private static func makeRows(_ entries: [JournalEntry]) async -> [TimelineRow] {
entries
.sorted { $0.date > $1.date }
.map { TimelineRow(id: $0.id, dateText: $0.date.formatted(dateFormat),
preview: String($0.text.prefix(140)), thumbnailURL: $0.thumbnailURL) }
}
}
struct TimelineView: View {
let model: TimelineModel
var body: some View {
List(model.rows) { row in // stable ids: rows are reused, not rebuilt
TimelineRowView(row: row)
}
}
}
struct TimelineRowView: View {
let row: TimelineRow // a value: unchanged rows skip body
var body: some View {
HStack {
Thumbnail(url: row.thumbnailURL, size: 60) // downsampled, cached loader
VStack(alignment: .leading) {
Text(row.dateText).font(.caption).foregroundStyle(.secondary)
Text(row.preview).lineLimit(2)
}
}
}
}- Sorting and formatting happen once, when the data changes, in the model, not every time a row’s
bodyruns. makeRowsruns off the main actor, so sorting thousands of entries doesn’t freeze scrolling.- Rows have persistent ids from the stored entries, so SwiftUI keeps existing rows when the list changes.
- Each row receives a small value. If it didn’t change, SwiftUI can skip running that row’s
bodyat all.
Tap the line with the bug.
struct TimelineView: View {
let entries: [JournalEntry]
struct Row: Identifiable {
let id = UUID()
let entry: JournalEntry
}
var body: some View {
let formatter = DateFormatter()
formatter.dateStyle = .medium
let rows = entries.sorted { $0.date > $1.date }.map { Row(entry: $0) }
List(rows) { row in
Text(formatter.string(from: row.entry.date))
Text(row.entry.text).lineLimit(2)
}
}
}Hint: What are the rows' ids the second time body runs?
Three problems, each run on every body evaluation:
let id = UUID()creates new ids each timerowsis built. To SwiftUI, every row is a new row: it destroys all existing rows (and their state, like an expanded section), creates new ones, and can’t animate changes. Scroll position can jump.DateFormatter()is expensive to create (it loads locale data) and is created on every update.- Sorting the whole array runs on the main thread every time anything this view reads changes.
Use the entry’s persistent id and do the work once, outside body:
struct TimelineView: View {
let rows: [TimelineRow] // built once by the model, off the main actor, with entry ids
var body: some View {
List(rows) { row in
Text(row.dateText) // formatted once with a shared FormatStyle
Text(row.preview).lineLimit(2)
}
}
}Predict the output
A @MainActor view model has func export() async { let pdf = renderPDF(entries) /* 2 s of CPU work */; await upload(pdf) }. It's called from a button with Task { await model.export() }. Does the UI stay responsive during rendering?
async means the function can suspend, not that it runs elsewhere. Isolation decides where: export belongs to a @MainActor class, so everything up to the first suspension, including renderPDF, runs on the main actor, and the app hangs for 2 seconds. Move the CPU work into a nonisolated function marked @concurrent (Swift 6.2) or into an actor, and await it. The Hangs instrument shows exactly this pattern.
- Release builds on physical devices; Time Profiler (inverted, hide system libraries), Hangs (250 ms or more), SwiftUI instrument (update counts, durations, causes), Allocations/Leaks.
- SwiftUI: side-effect-free cheap
body; stable persistent identity (noUUID()in body, noid: \.selfon non-unique values); split views to narrow Observation dependencies; pass values to rows. Self._printChanges()to diagnose body invalidations in debug.async≠ background:@MainActorasync functions run synchronous work on the main actor; use@concurrent nonisolatedfunctions (Swift 6.2) or actors for CPU work.
Check this part
Where should you profile an app's performance?
Why is let id = UUID() in a row struct built inside body a problem?
A @MainActor async method does 2 seconds of CPU work before its first await. Result?