SwiftUI Performance
Audit SwiftUI view performance end-to-end, from instrumentation and baselining to root-cause analysis and concrete remediation steps.
Contents
Workflow Decision Tree
- If the user provides code, start with "Code-First Review."
- If the user only describes symptoms, ask for minimal code/context, then do "Code-First Review."
- If code review is inconclusive, go to "Guide the User to Profile" and ask for a trace or screenshots.
1. Code-First Review
Collect:
- Target view/feature code.
- Data flow: state, environment, observable models.
- Symptoms and reproduction steps.
Focus on:
- View invalidation storms from broad state changes.
- Unstable identity in lists (
id churn, UUID() per render).
- Top-level conditional view swapping (
if/else returning different root branches).
- Heavy work in
body (formatting, sorting, image decoding).
- Layout thrash (deep stacks,
GeometryReader, preference chains).
- Large images without downsampling or resizing.
- Over-animated hierarchies (implicit animations on large trees).
Provide:
- Likely root causes with code references.
- Suggested fixes and refactors.
- If needed, a minimal repro or instrumentation suggestion.
2. Guide the User to Profile
Explain how to collect data with Instruments:
- Use the SwiftUI template in Instruments.
- Profile a Release build on a real device when possible.
- Reproduce the exact interaction (scroll, navigation, animation).
- Capture SwiftUI timeline and Time Profiler.
- Export or screenshot the relevant lanes and the call tree.
Ask for:
- Trace export or screenshots of SwiftUI lanes + Time Profiler call tree.
- Device/OS/build configuration.
3. Analyze and Diagnose
Prioritize likely SwiftUI culprits:
- View invalidation storms from broad state changes.
- Unstable identity in lists (
id churn, UUID() per render).
- Top-level conditional view swapping (
if/else returning different root branches).
- Heavy work in
body (formatting, sorting, image decoding).
- Layout thrash (deep stacks,
GeometryReader, preference chains).
- Large images without downsampling or resizing.
- Over-animated hierarchies (implicit animations on large trees).
Summarize findings with evidence from traces/logs.
4. Remediate
Apply targeted fixes:
- Narrow state scope (
@State/@Observable closer to leaf views).
- Stabilize identities for
ForEach and lists.
- Move heavy work out of
body (precompute, cache, @State).
- Use
equatable() or value wrappers for expensive subtrees.
- Downsample images before rendering.
- Reduce layout complexity or use fixed sizing where possible.
Common Code Smells (and Fixes)
Look for these patterns during code review.
Expensive formatters in body
var body: some View {
let number = NumberFormatter()
let measure = MeasurementFormatter()
Text(measure.string(from: .init(value: meters, unit: .meters)))
}
Prefer cached formatters in a model or a dedicated helper:
final class DistanceFormatter {
static let shared = DistanceFormatter()
let number = NumberFormatter()
let measure = MeasurementFormatter()
}
Computed properties that do heavy work
var filtered: [Item] {
items.filter { $0.isEnabled }
}
Prefer precompute or cache on change:
@State private var filtered: [Item] = []
Sorting/filtering in body or ForEach
ForEach(items.sorted(by: sortRule)) { item in Row(item) }
ForEach(items.filter { $0.isEnabled }) { item in Row(item) }
Prefer precomputed, cached collections with stable identity. Update on input change, not in body.
Unstable identity
ForEach(items, id: \.self) { item in
Row(item)
}
Avoid id: \.self for non-stable values; use a stable ID.
Top-level conditional view swapping
var content: some View {
if isEditing {
editingView
} else {
readOnlyView
}
}
Prefer one stable base view and localize conditions to sections/modifiers (for example inside toolbar, row content, overlay, or disabled). This reduces root identity churn and helps SwiftUI diffing stay efficient.
Image decoding on the main thread
Image(uiImage: UIImage(data: data)!)
Prefer decode/downsample off the main thread and store the result.
Broad dependencies in observable models
@Observable class Model {
var items: [Item] = []
}
var body: some View {
Row(isFavorite: model.items.contains(item))
}
Prefer granular view models or per-item state to reduce update fan-out.
5. Verify
Ask the user to re-run the same capture and compare with baseline metrics.
Summarize the delta (CPU, frame drops, memory peak) if provided.
Outputs
Provide:
- A short metrics table (before/after if available).
- Top issues (ordered by impact).
- Proposed fixes with estimated effort.
Instruments Profiling
SwiftUI Instrument Template
Instruments ships with a dedicated SwiftUI template (available in Xcode 15+ / Instruments 15+). This template provides:
- SwiftUI View Body instrument -- counts how many times each view's
body is evaluated.
- SwiftUI View Properties instrument -- tracks
@State, @Binding, and @Observable property changes that trigger view updates.
- Time Profiler -- standard CPU profiler for identifying expensive
body computations.
- Hangs instrument -- flags main-thread hangs > 250ms.
Profiling Workflow
- Build for Profiling. Product > Profile (Cmd+I) in Xcode. This creates a Release build with profiling symbols.
- Select the SwiftUI template. Or create a custom template with SwiftUI + Time Profiler + Hangs.
- Record the interaction. Reproduce the exact scroll, navigation, or animation that is slow.
- Inspect the SwiftUI lane. Look for views with high body evaluation counts. A view evaluated hundreds of times during a single scroll is likely the bottleneck.
- Cross-reference with Time Profiler. If a view body is called often AND takes significant time per call, that is the priority fix.
View Body Evaluation Count
In the SwiftUI instrument lane, each row represents a view type. Key signals:
- High count, low time per call: Identity or state-invalidation problem (too many re-evaluations).
- Low count, high time per call: Expensive computation inside
body (formatting, sorting, image work).
- High count AND high time: Both problems -- fix the expensive work first, then fix the invalidation.
Identifying Unnecessary Redraws
Add Self._printChanges() in Debug builds to log exactly which property triggered a view update:
var body: some View {
#if DEBUG
let _ = Self._printChanges()
#endif
Text("Count: \(count)")
}
Remove _printChanges() before submitting to the App Store -- it is a debug-only API.
Time Profiler for Body Hotspots
When Time Profiler shows significant time in a view's body:
- Filter the call tree by the view type name.
- Look for allocations (
NumberFormatter(), DateFormatter()), collection operations (.sorted(), .filter()), or image decoding.
- Move expensive operations to
onChange, task, or precomputed @State.
Identity and Lifetime
Structural Identity vs Explicit Identity
SwiftUI assigns every view an identity used to track its lifetime, state, and animations.
- Structural identity (default): determined by the view's position in the view hierarchy. SwiftUI uses the call-site location in
body to distinguish views.
- Explicit identity: you assign with
.id(_:) modifier or ForEach(items, id: \.stableID).
VStack {
Text("First")
Text("Second")
}
How Identity Tracks View Lifetime
When a view's identity changes, SwiftUI treats it as a new view:
- All
@State is reset.
onAppear fires again.
- Animations may restart.
- Transition animations play (if defined).
When identity stays the same, SwiftUI updates the existing view in place, preserving state and providing smooth transitions.
AnyView and Identity Reset
AnyView erases type information, forcing SwiftUI to fall back to less efficient diffing:
func makeView(for item: Item) -> AnyView {
if item.isPremium {
return AnyView(PremiumRow(item: item))
} else {
return AnyView(StandardRow(item: item))