---
name: dpearson2699/swiftui-performance
source: https://app.decimal.ai/s/dpearson2699-swiftui-performance@1/SKILL.md
source_sha256: 2faff2dc939f
---

# 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](#workflow-decision-tree)
- [1. Code-First Review](#1-code-first-review)
- [2. Guide the User to Profile](#2-guide-the-user-to-profile)
- [3. Analyze and Diagnose](#3-analyze-and-diagnose)
- [4. Remediate](#4-remediate)
- [Common Code Smells (and Fixes)](#common-code-smells-and-fixes)
- [5. Verify](#5-verify)
- [Outputs](#outputs)
- [Instruments Profiling](#instruments-profiling)
- [Identity and Lifetime](#identity-and-lifetime)
- [Lazy Loading Patterns](#lazy-loading-patterns)
- [State and Observation Optimization](#state-and-observation-optimization)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
- [References](#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:

```swift
var body: some View {
    #if DEBUG
    let _ = Self._printChanges()  // "MyView: @self, _count changed."
    #endif
    Text("Count: \(count)")
}
```

See [references/optimizing-swiftui-performance-instruments.md](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.

```swift
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.

```swift
// 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

1. **Profiling Debug builds.** Debug builds include extra runtime checks and disable optimizations, producing misleading perf data. Profile Release builds on a real device.
2. **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.
3. **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.
4. **Calling `DateFormatter()` or `NumberFormatter()` inside `body`.** These are expensive to create. Make them static or move them outside the view.
5. **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.
6. **Large flat `List` without identifiers.** Use `id:` or make items `Identifiable` so SwiftUI can diff efficiently instead of rebuilding the entire list.
7. **Unnecessary `@State` wrapper objects.** Wrapping a simple value type in a class for `@State` defeats value semantics. Use plain `@State` with structs.
8. **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](references/demystify-swiftui-performance-wwdc23.md)
- Optimizing SwiftUI performance with Instruments: [references/optimizing-swiftui-performance-instruments.md](references/optimizing-swiftui-performance-instruments.md)
- Understanding hangs in your app: [references/understanding-hangs-in-your-app.md](references/understanding-hangs-in-your-app.md)
- Understanding and improving SwiftUI performance: [references/understanding-improving-swiftui-performance.md](references/understanding-improving-swiftui-performance.md)
- WWDC transcript sources: [references/wwdc-session-sources.md](references/wwdc-session-sources.md)