skills/performance/swiftui-debugging/SKILL.md
Diagnose SwiftUI performance issues including unnecessary re-renders, view identity problems, and slow body evaluations. Use when SwiftUI views are slow, janky, or re-rendering too often.
npx skillsauth add rshankras/claude-code-apple-skills swiftui-debuggingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Systematic guide for diagnosing and fixing SwiftUI performance problems: unnecessary view re-evaluations, identity issues, expensive body computations, and lazy loading mistakes.
Use this skill when the user:
Self._printChanges() or view debugging@Observable or ObservableObject performance differencesAnyView and asks about performance implicationsEvery investigation follows the same loop: symptom -> measure -> identify -> optimize -> RE-MEASURE. The last step is the one people skip -- an "optimization" that was never re-measured is a guess, and SwiftUI guesses are wrong often enough (a fix can shift cost elsewhere) that the loop is not optional. Never close a performance issue on the strength of the diff alone.
What SwiftUI performance problem are you seeing?
|
+- Views re-render when they should not
| +- Read body-reevaluation.md
| +- Self._printChanges() to identify which property changed
| +- @Observable vs ObservableObject observation differences
| +- Splitting views to narrow observation scope
|
+- Scrolling is slow / choppy (lists, grids)
| +- Read lazy-loading.md
| +- VStack vs LazyVStack, ForEach without lazy container
| +- List prefetching, grid cell reuse
|
+- Views lose state unexpectedly / animate when they should not
| +- Read view-identity.md
| +- Structural vs explicit identity
| +- .id() misuse, conditional view branching
|
+- Known pitfall (AnyView, DateFormatter in body, etc.)
| +- Read common-pitfalls.md
| +- AnyView type erasure, object creation in body
| +- Over-observation, expensive computations
|
+- General "my SwiftUI app is slow" (unknown cause)
| +- Start with body-reevaluation.md, then common-pitfalls.md
| +- Use Instruments SwiftUI template (see Debugging Tools below)
| API / Technique | Minimum Version | Reference |
|----------------|-----------------|-----------|
| Self._printChanges() | iOS 15 | body-reevaluation.md |
| @Observable | iOS 17 / macOS 14 | body-reevaluation.md |
| @ObservableObject | iOS 13 | body-reevaluation.md |
| LazyVStack / LazyHStack | iOS 14 | lazy-loading.md |
| LazyVGrid / LazyHGrid | iOS 14 | lazy-loading.md |
| .id() modifier | iOS 13 | view-identity.md |
| Instruments SwiftUI template | Xcode 14+ | SKILL.md |
| Redesigned SwiftUI instrument | Xcode 26 / Instruments 26 | SKILL.md |
| os_signpost | iOS 12 | SKILL.md |
| # | Mistake | Fix | Details |
|---|---------|-----|---------|
| 1 | Large ForEach inside VStack or ScrollView without lazy container | Wrap in LazyVStack -- eager VStack creates all views upfront | lazy-loading.md |
| 2 | Using AnyView to erase types | Use @ViewBuilder, Group, or concrete generic types -- AnyView defeats diffing | common-pitfalls.md |
| 3 | Creating objects in body (DateFormatter(), NumberFormatter()) | Use static let shared instances or @State for mutable objects | common-pitfalls.md |
| 4 | Observing entire model when only one property is needed | Split into smaller @Observable objects or extract subviews | body-reevaluation.md |
| 5 | Unstable .id() values causing full view recreation every render | Use stable identifiers (database IDs, UUIDs), never array indices or random values | view-identity.md |
List and Table gather all identifiers eagerly at load -- even though row views are lazy. Cheap, precomputed IDs mean fast loads; an id: key path that computes or hashes something expensive runs for every element before anything renders.
The row-count equation: rows = elements x views-per-element, and views-per-element must be a constant the framework can read without executing your closures.
if filters and no AnyView inside ForEach -- both make views-per-element non-constant, forcing SwiftUI to run every closure just to count rows (and defeating List's constant-count optimizations).items.filter { ... } in body re-runs linearly on every single body evaluation.Table, prefer the streamlined ForEach(collection) initializer (no id:, no per-row closure gymnastics) -- it keeps row counts statically constant.When a body or update shows up slow in the profile, it is almost always one of these:
@StateObject/@State object doing I/O in its initializerbody -- string interpolation/formatting, sorting, filteringbody -- formatters, predicates, intermediate arraysbody -- Bundle.main searches, decoding images synchronouslyMove loading and expensive derivation to .task (or the model), then re-measure.
Scope dependencies tightly -- but not obsessively. Pass the child view the Image it renders, not the whole model object, so unrelated model changes stop invalidating it. Don't over-rotate: splitting a huge struct into dozens of single-property parameters costs more in plumbing than it saves. @Observable already narrows invalidation to the properties a body actually reads -- lean on that first.
Add to any view body to see what triggered re-evaluation:
var body: some View {
let _ = Self._printChanges()
// ... view content
}
Output reads: ViewName: @self, @identity, _propertyName changed. Shorthand: @self = the view's value changed (parent rebuilt it), a named property = that specific dependency changed. See body-reevaluation.md for the full interpretation guide.
Also callable from LLDB without editing code -- pause in a view context and run:
(lldb) expression Self._printChanges()
Remove it before shipping -- it has real runtime cost.
Requires Xcode 26 and recent OS releases on the profiled device (trace-recording support lives in the OS). Cmd+I builds Release and opens Instruments; the SwiftUI template bundles the SwiftUI instrument, Time Profiler, and Hangs + Hitches instruments. Start here when the question is "which body is blowing the frame budget."
Why body time matters: each frame, the app handles events, runs the bodies of changed views, and must finish before the frame deadline; a body that overruns delays the whole train and the previous frame stays on screen too long -- a hitch. Two failure shapes blow the deadline the same way: (1) one long body update, (2) many individually-fast but unnecessary updates in one frame (WWDC25 306).
Top-level lanes:
| Lane | Meaning |
|------|---------|
| Update Groups | When SwiftUI is doing any work. If CPU spikes while this lane is empty, the problem is outside SwiftUI -- switch to the general profiling skill |
| Long View Body Updates | body properties taking too long |
| Long Representable Updates | UIView/UIViewController/NSView representable updates taking too long |
| Other Long Updates | All other long SwiftUI work |
Color coding: long updates are orange or red by likelihood of contributing to a hitch or hang -- investigate red first; normal updates are gray. Long updates at the very start of a trace are launch-time initial hierarchy builds -- expected, they won't hitch; don't chase them (WWDC25 306).
Workflow for a long body:
The classic find (the session's demo): a computed property read in body created a NumberFormatter + MeasurementFormatter and formatted a string on every body run, per visible row. Fix: create formatters once (stored property on the model/manager), precompute strings into an ID-keyed cache, and let body do a dictionary lookup (WWDC25 306). See common-pitfalls.md for the general rule.
Backtraces explain imperative UIKit updates but not SwiftUI -- a SwiftUI backtrace is recursive AttributeGraph frames and never says why your view updated. So "why did body run?" really means "what marked my body outdated?" -- which is exactly what the instrument's Cause & Effect Graph answers (hover-arrow on a view name -> Show Cause & Effect Graph):
@Observable change, environment); blue nodes = your code / your interactions; the graph reads left (cause) -> right (effect)The demo bug it exposed: every row's body called isFavorite(landmark), which read the shared landmarks array on an @Observable model -- so every row depended on the whole array, and one tap re-ran every visible row's body.
// ❌ Every row reads the shared array -> @Observable makes every row depend on it
func isFavorite(_ landmark: Landmark) -> Bool {
favoritesCollection.landmarks.contains(landmark) // whole-array dependency
}
// ✅ Per-item @Observable view model -> each row depends only on its own flag
@Observable class ViewModel { var isFavorite: Bool = false }
@ObservationIgnored private var viewModels: [Landmark.ID: ViewModel] = [:]
// ^ @ObservationIgnored: don't observe the dictionary itself, only each model
func isFavorite(_ landmark: Landmark) -> Bool {
viewModel(for: landmark).isFavorite // body's read = narrow dependency
}
func addFavorite(_ landmark: Landmark) {
favoritesCollection.landmarks.append(landmark)
viewModel(for: landmark).isFavorite = true
}
Verified in the trace: 2 taps = exactly 2 body updates. Rule: make data dependencies as granular as the UI that renders them (WWDC25 306).
Environment rule: EnvironmentValues is one value-type struct, and every view using @Environment depends on the whole struct. Any environment change notifies all such views; each compares its own key's value -- body only re-runs if it changed, but the comparison itself costs time in every reading view. Never store rapidly-changing values (geometry, timers) in the environment (WWDC25 306). The graph distinguishes External Environment nodes (changed outside SwiftUI, e.g. color scheme) from EnvironmentWriter nodes (changed via .environment(...)).
import os
private let perfLog = OSLog(subsystem: "com.app.perf", category: "SwiftUI")
var body: some View {
let _ = os_signpost(.event, log: perfLog, name: "MyView.body")
// ... view content
}
View in Instruments with the os_signpost instrument to count body evaluations per second.
.id() values (random, Date(), array index on mutable arrays)if/else) do not cause unnecessary view destructionForEach uses stable, unique identifiers from the model@Observable classes preferred over ObservableObject (iOS 17+)@State changes that trigger body re-evaluationLazyVStack / LazyHStack, not VStack / HStackList or lazy stack used for 50+ items.frame(maxHeight: .infinity) on children inside lazy containers (defeats laziness)AnyView type erasure (use @ViewBuilder or Group)body (DateFormatter, NSPredicate, view models)task { } or Task.detachedAsyncImage or .resizable() with proper sizing, not raw UIImage decoding in body| File | Content |
|------|---------|
| view-identity.md | Structural vs explicit identity, .id() usage, conditional branching |
| body-reevaluation.md | What triggers body, _printChanges(), @Observable vs ObservableObject |
| lazy-loading.md | Lazy vs eager containers, List, ForEach, grid performance |
| common-pitfalls.md | AnyView, object creation in body, over-observation, expensive computations |
| ../profiling/SKILL.md | General Instruments profiling (Time Profiler, Memory, Energy) |
| ../../swiftui/data-flow/SKILL.md | The identity/lifetime mental model behind all of the above -- read it when fixes feel like guesswork |
development
US web checkout via the StoreKit External Purchase Link entitlement — currently 0% Apple commission (litigation ongoing), how to ship it safely, and how to architect for a commission flip so a future ruling is a config change, not a rewrite. Use when adding external purchase links, weighing web checkout vs IAP, or planning US-storefront pricing strategy.
tools
Revenue beyond the single-app price tag — own-app bundles, Family Sharing as a conversion lever, cross-developer bundles & suites, and institutional licensing via Group Purchases / Apple School & Business Manager. Use when a developer has multiple apps, a subscription worth sharing, complementary indie partners, or school/clinic/business buyers.
testing
Run a structured accessibility audit on an iOS/macOS app — automated XCUITest audits, Accessibility Inspector, manual VoiceOver/Dynamic Type passes, and App Store Accessibility Nutrition Label evaluation. Use before release, when preparing Nutrition Label declarations, or for EU Accessibility Act compliance.
tools
Stage-by-stage audit of an app's App Store growth machinery against a 54-item P0–P9 playbook — every item scored from an App Store Connect MCP call, a codebase check, or an explicit question to the user, then routed to the skill or command that fixes it. Read-only on App Store Connect. Use for a growth audit or scorecard, a pre-launch growth plan, a quarterly re-audit, or "which growth levers am I missing."