Profile, diagnose, and remediate SwiftUI runtime performance using code review, Instruments, and repeatable measurements. Use when a SwiftUI screen renders…
SwiftUI Performance
Audit SwiftUI view performance from a reproducible symptom to measured
remediation. Route animation design to swiftui-animation, production telemetry
to metrickit, ownership/leak analysis to ios-memgraph-analysis, navigation
behavior to swiftui-navigation, state architecture to swiftui-patterns, and
layout construction to swiftui-layout-components.
Contents
Workflow Decision Tree
1. Code-First Review
2. Guide the User to Profile
3. Analyze and Diagnose
4. Remediate
Common Code Smells (and Fixes)
5. Verify
Outputs
Instruments Profiling
Identity and Lifetime
Lazy Loading Patterns
State and Observation Optimization
Common Mistakes
Review Checklist
References
Workflow Decision Tree
Code supplied: review it first and label findings as hypotheses.
Symptoms only: collect the smallest relevant view, data flow, reproduction,
device, OS, and build configuration.
Inconclusive review: collect a trace or lane screenshots before prescribing a
broad refactor.
Use this triage list for both code and trace analysis:
Broad state dependencies or invalidation storms
Unstable list identity or root conditional swapping
Formatting, sorting, decoding, or synchronous I/O in body
Layout/geometry feedback loops and oversized images
Implicit animation applied to a large hierarchy
1. Code-First Review
Map each suspect from the triage list to exact code. Report likely causes with
code references, but label them code-backed hypotheses until a trace confirms
cost. Propose a minimal repro or measurement when evidence is missing.
2. Guide the User to Profile
Use the SwiftUI Instruments template on a Release build and real device when
possible. Reproduce the exact interaction, capturing SwiftUI lanes, Time
Profiler, and Hangs/Hitches as relevant. Ask for the trace or screenshots of the
lanes and call tree.
3. Analyze and Diagnose
Apply the same triage list to trace evidence. Correlate long or frequent SwiftUI
updates with the Time Profiler call tree and the reproduced interaction. Separate
trace-backed findings from code-backed hypotheses and name the next measurement
that would resolve remaining uncertainty.
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 into model-layer precomputation, an explicit derived
value updated when its inputs change, a memoized helper, or background processing.
Use @State only when the view owns both the value and its update lifecycle; it is
not a generic cache for arbitrary computation.
Use equatable() only when equality is cheaper than recomputing the subtree and
the compared inputs have stable value semantics.
Downsample images before rendering.
Reduce layout complexity or use fixed sizing where possible.
Common Code Smells (and Fixes)
Smell
Evidence to seek
Targeted fix
Formatter, sort, filter, or decode in body
Long/frequent body updates with matching call-tree cost
Recompute when inputs change; downsample/decode off the main actor
UUID() or unstable id: \.self
Recreated rows, lost state, excess updates
Use stable model identity
Root if/else swaps
State reset or update spikes when toggled
Localize conditional content/modifiers when semantics allow
Broad model reads
Many unrelated views update together
Pass narrow values or move reads into focused child views
Geometry writes during layout
Repeating layout/update cycle
Threshold changes or replace the feedback path with stable layout
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
Use the SwiftUI template in Instruments (Cmd+I to profile). Current SwiftUI lanes include Update Groups, Long View Body Updates, Long Representable Updates / Representable Updates, Other Long Updates / Other Updates, and the Cause & Effect Graph. Correlate those with Time Profiler and Hangs/Hitches.
Add Self._printChanges() in debug builds to log which property triggered a view update:
var body: some View {
#if DEBUG
let _ = Self._printChanges() // "MyView: @self, _count changed."
#endif
Text("Count: \(count)")
}
See references/optimizing-swiftui-performance-instruments.md for the full profiling workflow.
Identity and Lifetime
Identity controls view lifetime and state. Use stable model IDs in repeated
content and reserve .id(_:) changes for intentional resets. Prefer
@ViewBuilder or generic composition over AnyView in profiled hot rows. Treat
root conditional branches as suspects—not automatic defects—when evidence shows
state churn or expensive recreation.
Text(title)
.foregroundStyle(isHighlighted ? .yellow : .primary)
ForEach(items) { item in
Row(item: item).id(item.stableID)
}
Lazy Loading Patterns
Use lazy containers when profiling shows eager construction, layout, or update
work is material; there is no universal item-count threshold. Route grid/list
construction choices to swiftui-layout-components.
Guardrails:
Off-screen views are removed from the lazy stack. SwiftUI may keep them briefly, then delete the views and their view-local state.
Persist important row state outside the row view if it must survive scrolling away.
Body and layout work can happen before onAppear because of prefetching. Do not make onAppear the only setup point for data a row needs to render.
Treat onAppear and onDisappear as visibility signals, not lifetime guarantees.
Filter data before ForEach; avoid if branches that make each element produce zero or one row.
Keep each ForEach element to a constant number of top-level subviews. Wrap row contents in a stable container if needed. Use -LogForEachSlowPath YES while debugging list/table slow paths.
Avoid absolute content-size or content-offset assumptions; lazy stacks estimate off-screen sizes.
Avoid geometry feedback loops in lazy rows. Prefer stable sizing, layout primitives, or a custom Layout before feeding geometry changes back into row state.
State and Observation Optimization
Observation tracks properties read during view evaluation. Reduce fan-out by
passing narrow derived values or moving reads into focused child views.
// Split reads into child views so each tracks only what it renders.
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
NameRow(model: model) // only tracks name
EmailRow(model: model) // only tracks email
AvatarView(model: model) // only tracks avatar
SettingsForm(model: model) // only tracks settings
}
}
}
Cheap computed values can remain derived; expensive transformations need an
explicit owner, input set, and refresh trigger. Do not add view models as a
performance ritual—measure first and route general state design to
swiftui-patterns.
Common Mistakes
Profiling Debug builds. Debug builds include extra runtime checks and disable optimizations, producing misleading perf data. Profile Release builds on a real device.
Observing an entire model when only one property is needed. Break large @Observable models into focused ones, or use computed properties/closures to narrow observation scope.
Using geometry feedback inside ScrollView items. GeometryReader or noisy geometry state can force repeated layout. Prefer stable sizing, custom layout, or narrowly scoped .onGeometryChange (iOS 16+) with thresholds.
Calling DateFormatter() or NumberFormatter() inside body. These are expensive to create. Make them static or move them outside the view.
Animating non-equatable state. If SwiftUI cannot determine equality, it redraws every frame. Conform state to Equatable, then use .animation(_:value:) for simple value-bound changes or .animation(_:body:) for narrower modifier-scoped implicit animation.
Large flat List without identifiers. Use id: or make items Identifiable so SwiftUI can diff efficiently instead of rebuilding the entire list.
Unnecessary @State wrapper objects. Wrapping a simple value type in a class for @State defeats value semantics. Use plain @State with structs.
Blocking MainActor with synchronous I/O. File reads, JSON parsing of large payloads, and image decoding should happen off the main actor. Prefer nonisolated async helpers or dedicated actors; reserve Task.detached for cases where you intentionally break actor inheritance and handle cancellation yourself.
Review Checklist
No DateFormatter/NumberFormatter allocations inside body
Large lists use Identifiable items or explicit id:
@Observable models expose only the properties views actually read
Heavy computation is off MainActor (image processing, parsing)
Lazy rows have stable identity, constant top-level row shape, and prefiltered data
Geometry changes in scroll rows are thresholded and do not feed broad state
Row rendering does not depend on onAppear as the only setup point
Implicit animations use .animation(_:value:) for value-bound changes or .animation(_:body:) for narrower modifier scope
No synchronous network/file I/O on the main thread
Profiling done on Release build, real device
@State is not used as an unspecified cache; every derived value has an explicit owner and refresh trigger
equatable() is used only when comparison is cheaper than recomputation and inputs have stable value semantics
Findings distinguish code-backed hypotheses from trace-backed evidence
@Observable view models are @MainActor-isolated; types crossing concurrency boundaries are Sendable
References
Demystify SwiftUI performance (WWDC23): references/demystify-swiftui-performance-wwdc23.md
Optimizing SwiftUI performance with Instruments: references/optimizing-swiftui-performance-instruments.md
Understanding hangs in your app: references/understanding-hangs-in-your-app.md
Understanding and improving SwiftUI performance: references/understanding-improving-swiftui-performance.md
WWDC transcript sources: references/wwdc-session-sources.md
2f:["$don't have the plugin yet? install it then click "run inline in claude" again.
diagnose and fix swiftui rendering performance problems end-to-end. use this skill when a user reports janky scrolling, high cpu usage, excessive view updates, slow animations, memory bloat, or layout thrash. the skill moves from code review through instrumentation profiling to root-cause analysis and concrete fixes, prioritizing by impact.
required:
optional but recommended:
external connections:
input: user provides code, symptoms, and repro steps.
action:
output: summary of likely root causes with code references, or decision to profile.
input: code review is inconclusive or user requests data-driven diagnosis.
action:
output: trace file or screenshot, device/os/build config, metrics (if visible: frame rate, cpu, memory).
input: code and trace (or screenshots), baseline metrics.
action:
output: ordered list of issues with evidence (trace reference, code line, estimated impact).
input: diagnosis from step 3.
action:
output: concrete code changes with before/after snippets.
input: user re-runs the same instruments capture after applying fixes.
action:
output: delta table (before/after cpu %, frame drops, memory peak) and sign-off.
if user provides working code but only a symptom description (e.g., "scrolling is janky"): then ask for repro steps, build config (debug or release), and the exact view code. do not guess. code review without context is unreliable.
if code review finds an obvious culprit (e.g., uuid() in .id(), formatters in body, broad @observable model): then propose a fix immediately and skip profiling. provide a code example and estimated effort. offer to verify later if the user applies the fix.
if code review is inconclusive (code looks reasonable but user reports performance issues): then move to step 2 (profiling). code review alone cannot detect all problems; instrumentation is required.
if the user is profiling a debug build: then stop and ask them to profile a release build on a physical device. debug builds disable optimizations and include runtime overhead, producing misleading data.
if the trace shows a tall stack in swiftui layout or rendering code: then the culprit is likely layout thrash (deep nesting, geometryreader, preference chains) or a large view count. check for lazy container misuse or unstable identity.
if the trace shows many body re-evaluations in a single view: then the culprit is likely broad @observable scope or unstable state dependency. narrow the scope by splitting the model or moving reads into child views.
if the user reports frame drops but cpu is low: then the bottleneck may be memory (gc pauses), gpu rendering (overdraw, blend mode cost), or i/o (image loading blocking the main thread). ask for memory profiling and check for synchronous i/o in body.
if images are large or decoding happens synchronously in body: then decode off the main thread and store the uiimage result. use task.detached or asyncimage.
if a list has no identifiers or uses id: .self on mutable items: then swiftui cannot diff efficiently and rebuilds the entire list on each change. fix by making items identifiable or using a stable id: closure.
if the user is animating non-equatable state: then swiftui cannot determine equality and redraws every frame. conform the state to equatable, then use .animation(_:value:) for simple value-bound changes.
provide a report with these sections:
metric | before | after | delta
--------------|--------|--------|--------
cpu (avg) | 45% | 12% | -73%
frame drops | 24 | 2 | -92%
memory peak | 180mb | 165mb | -8%
body evals/sec | 1200 | 80 | -93%
all fixes must include a minimal code example and rationale.
the skill worked if:
if metrics do not improve after a fix, either the fix addressed a non-critical issue or the diagnosis was incomplete. loop back to profiling and dig deeper.
problem: dateformatter(), numberformatter(), measurementformatter() are expensive to create and re-create on every body evaluation.
// DON'T
var body: some View {
let formatter = NumberFormatter()
Text(formatter.string(from: 42) ?? "")
}
fix: cache formatters in a static or model property.
// DO
final class AppFormatters {
static let number = NumberFormatter()
static let measure = MeasurementFormatter()
}
var body: some View {
Text(AppFormatters.number.string(from: 42) ?? "")
}
problem: id: .self fails for mutable items; uuid() per render destroys and recreates views.
// DON'T
ForEach(items, id: \.self) { item in Row(item) }
ForEach(items) { item in Row(item).id(UUID()) }
fix: use a stable identifier.
// DO
ForEach(items, id: \.id) { item in Row(item) }
// or if items are Identifiable
ForEach(items) { item in Row(item) }
problem: filter and sort run on every body evaluation, not just when data changes.
// DON'T
ForEach(items.filter { $0.isEnabled }.sorted(by: { $0.name < $1.name })) { item in
Row(item)
}
fix: precompute and cache, then update only when inputs change.
// DO
@State private var filtered: [Item] = []
@State private var sorted: [Item] = []
.onAppear {
updateFiltered()
}
.onChange(of: items) {
updateFiltered()
}
private func updateFiltered() {
filtered = items.filter { $0.isEnabled }
sorted = filtered.sorted { $0.name < $1.name }
}
var body: some View {
ForEach(sorted, id: \.id) { item in Row(item) }
}
problem: if/else at the root of body creates two separate view types, causing identity churn and state reset.
// DON'T
var body: some View {
if isEditing {
EditingView(model: model)
} else {
ReadOnlyView(model: model)
}
}
fix: use one stable base view and localize conditions to modifiers or child sections.
// DO: one stable base, conditions in modifiers
var body: some View {
Form {
Section {
TextField("Name", text: $name)
.disabled(!isEditing)
}
}
.toolbar {
ToolbarItem {
Button(isEditing ? "Done" : "Edit") { isEditing.toggle() }
}
}
}
problem: a single @observable model tracks many properties; a view reads all of them, so it re-renders when any change.
// DON'T
@Observable class Profile {
var name: String = ""
var email: String = ""
var avatar: URL?
var settings: [String: Bool] = [:]
}
struct ProfileView: View {
let profile: Profile
var body: some View {
VStack {
Text(profile.name)
Text(profile.email)
AsyncImage(url: profile.avatar)
Toggle("Dark Mode", isOn: $settings["darkMode"] ?? false)
}
}
}
fix: split into focused child views so each only reads what it needs.
// DO
struct ProfileView: View {
let profile: Profile
var body: some View {
VStack {
NameRow(profile: profile)
EmailRow(profile: profile)
AvatarView(profile: profile)
SettingsToggle(profile: profile)
}
}
}
struct NameRow: View {
let profile: Profile
var body: some View {
Text(profile.name) // only reads name
}
}
problem: decoding happens on the main thread, blocking render.
// DON'T
let uiImage = UIImage(data: data)!
Image(uiImage: uiImage)
fix: decode off-thread and store the result.
// DO
@State private var image: UIImage?
.onAppear {
Task.detached {
let decoded = await decodeImageData(data)
await MainActor.run {
self.image = decoded
}
}
}
var body: some View {
if let image {
Image(uiImage: image)
}
}
private func decodeImageData(_ data: Data) -> UIImage? {
return UIImage(data: data)?.resized(to: CGSize(width: 200, height: 200))
}
problem: animating a broad state change triggers implicit animation on every view in the tree, causing jank.
// DON'T
withAnimation {
isExpanded.toggle() // animates 100+ subviews
}
fix: use value-bound animation or narrow the modifier scope.
// DO: value-bound animation
@State private var isExpanded = false
var body: some View {
Section {
if isExpanded { details }
}
.animation(.easeInOut, value: isExpanded)
}
// or scope to a single modifier
var body: some View {
VStack {
Button("Toggle") { isExpanded.toggle() }
if isExpanded {
details
.transition(.opacity)
}
}
}
problem: geometryreader forces eager measurement and defeats lazy loading.
// DON'T
LazyVStack {
ForEach(items) { item in
GeometryReader { geo in
Row(item: item)
.frame(height: geo.size.height)
}
}
}
fix: move measurement outside the lazy container or use ongeometrychange (ios 16+).
// DO: measure outside
var body: some View {
GeometryReader { geo in
LazyVStack {
ForEach(items) { item in
Row(item: item)
.frame(height: 44)
}
}
}
}
// or use ongeometrychange
LazyVStack {
ForEach(items) { item in
Row(item: item)
.onGeometryChange(
for: CGSize.self,
of: { $0.size },
action: { size in
itemHeights[item.id] = size.height
}
)
}
}
problem: computed properties re-evaluate on every body call.
// DON'T
var filtered: [Item] {
items.filter { $0.isEnabled } // runs every time body is called
}
var body: some View {
List(filtered) { item in Row(item) }
}
fix: use @state and update on change.
// DO
@State private var filtered: [Item] = []
.onAppear {
updateFiltered()
}
.onChange(of: items) {
updateFiltered()
}
private func updateFiltered() {
filtered = items.filter { $0.isEnabled }
}
var body: some View {
List(filtered) { item in Row(item) }
}
problem: anyview destroys type identity and forces inefficient diffing.
// DON'T
func makeView(for item: Item) -> AnyView {
if item.isPremium {
return AnyView(PremiumRow(item: item))
} else {
return AnyView(StandardRow(item: item))
}
}
fix: use @viewbuilder to preserve structural identity.
// DO
@ViewBuilder
func makeView(for