All skills

Performance profiling and SwiftUI debugging skills — Instruments guidance, hangs, memory issues, slow launches, energy drain, unnecessary re-renders, and view identity problems. Use when an app is slow, janky, or resource-hungry.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/performance

This session only. Nothing lands on disk.

swiftui-debuggingcommon-pitfalls.md

≈3.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Common SwiftUI Performance Pitfalls

Specific anti-patterns that cause SwiftUI performance problems, with concrete before/after fixes for each.

1. AnyView Type Erasure

The Problem

AnyView wraps a view in a type-erased container. SwiftUI uses type information for its diffing algorithm -- when types are erased, SwiftUI cannot efficiently diff and must do more work:

  • Cannot use structural identity (all AnyView instances have the same type)
  • Cannot skip diffing subtrees when the type has not changed
  • Prevents compile-time optimization of the view hierarchy

Before (Slow)

// ❌ AnyView defeats SwiftUI's diffing optimization
func makeView(for item: Item) -> AnyView {
    switch item.kind {
    case .text:
        return AnyView(TextItemView(item: item))
    case .image:
        return AnyView(ImageItemView(item: item))
    case .video:
        return AnyView(VideoItemView(item: item))
    }
}

// Usage in ForEach compounds the problem
ForEach(items) { item in
    makeView(for: item)  // SwiftUI cannot diff between renders
}

After (Fast)

// ✅ @ViewBuilder preserves type information for the compiler
@ViewBuilder
func makeView(for item: Item) -> some View {
    switch item.kind {
    case .text:
        TextItemView(item: item)
    case .image:
        ImageItemView(item: item)
    case .video:
        VideoItemView(item: item)
    }
}

// ✅ Or better: dedicated view struct (avoids large switch in @ViewBuilder)
struct ItemView: View {
    let item: Item

    var body: some View {
        switch item.kind {
        case .text:
            TextItemView(item: item)
        case .image:
            ImageItemView(item: item)
        case .video:
            VideoItemView(item: item)
        }
    }
}

When AnyView Is Acceptable

AnyView has negligible cost in these cases:

  • A single view used once (not in a list or ForEach)
  • Prototyping / throwaway code
  • Bridging to UIKit hosting controllers where type erasure is required

2. Object Creation in body

The Problem

body can be called frequently (dozens of times per second during animation). Creating objects each time wastes CPU and memory:

// ❌ Creates a new DateFormatter every time body runs
// DateFormatter init is ~50x more expensive than a simple alloc
var body: some View {
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    Text(formatter.string(from: date))
}

Expensive Objects to Watch For

Object Cost of creation Alternative
DateFormatter ~5-10 microseconds static let shared instance
NumberFormatter ~5-10 microseconds static let shared instance
JSONDecoder / JSONEncoder Moderate static let shared instance
NSRegularExpression Moderate (compiles regex) static let or Swift Regex literal
NSPredicate Moderate static let or reuse via @State
URLSession Heavy Use URLSession.shared or inject
View models / controllers Heavy @State or @StateObject
AttributedString with markdown Moderate (parses markdown) Cache in model or @State

After (Fast)

// ✅ Static shared formatter -- created once, reused forever
struct DateLabel: View {
    let date: Date

    private static let formatter: DateFormatter = {
        let f = DateFormatter()
        f.dateStyle = .medium
        return f
    }()

    var body: some View {
        Text(Self.formatter.string(from: date))
    }
}

// ✅ Or use the modern formatted() API which handles caching internally
struct DateLabel: View {
    let date: Date

    var body: some View {
        Text(date.formatted(.dateTime.month().day().year()))
    }
}

3. Over-Observation with ObservableObject

The Problem

With ObservableObject, changing any @Published property notifies all observers. If a large view observes a store with many properties, it re-evaluates on every change:

// ❌ All views observing AppState re-evaluate when ANY property changes
class AppState: ObservableObject {
    @Published var user: User?
    @Published var items: [Item] = []
    @Published var searchText = ""        // Typing here re-evaluates everything
    @Published var selectedTab = 0
    @Published var isOnboarding = false
    @Published var notifications: [Notification] = []
}

Fix Option A: Migrate to @Observable (iOS 17+)

// ✅ Per-property observation -- views only update when their dependencies change
@Observable
class AppState {
    var user: User?
    var items: [Item] = []
    var searchText = ""          // Only views reading searchText re-evaluate
    var selectedTab = 0
    var notifications: [Notification] = []
}

Fix Option B: Split Into Focused Stores (Pre-iOS 17)

// ✅ Each store has a narrow scope
class UserStore: ObservableObject {
    @Published var user: User?
}

class SearchStore: ObservableObject {
    @Published var searchText = ""
    @Published var results: [Item] = []
}

class NavigationStore: ObservableObject {
    @Published var selectedTab = 0
}

Fix Option C: Extract Subviews to Narrow Scope

// ❌ Entire view re-evaluates when searchText changes
struct ContentView: View {
    @ObservedObject var store: AppState

    var body: some View {
        VStack {
            TextField("Search", text: $store.searchText)
            ItemList(items: store.items)
            UserBadge(user: store.user)
        }
    }
}

// ✅ Each subview only connects to what it needs
struct ContentView: View {
    @ObservedObject var store: AppState

    var body: some View {
        VStack {
            SearchBar(store: store)
            ItemList(items: store.items)
            UserBadge(user: store.user)
        }
    }
}

struct SearchBar: View {
    @ObservedObject var store: AppState  // Re-evaluates often, but it's tiny

    var body: some View {
        TextField("Search", text: $store.searchText)
    }
}

4. Expensive Computation in body

The Problem

body runs on the main thread. Any expensive work directly blocks the UI:

// ❌ Filtering and sorting 10,000 items every time body runs
var body: some View {
    let filtered = items.filter { $0.matchesQuery(searchText) }
    let sorted = filtered.sorted { $0.date > $1.date }

    List(sorted) { item in
        ItemRow(item: item)
    }
}

After: Cache Computed Results

// ✅ Compute on change, not on every body call
struct ItemListView: View {
    let items: [Item]
    @State private var searchText = ""
    @State private var filteredItems: [Item] = []

    var body: some View {
        VStack {
            TextField("Search", text: $searchText)
            List(filteredItems) { item in
                ItemRow(item: item)
            }
        }
        .onChange(of: searchText) { _, newValue in
            filteredItems = items.filter { $0.matchesQuery(newValue) }
                .sorted { $0.date > $1.date }
        }
        .onAppear {
            filteredItems = items.sorted { $0.date > $1.date }
        }
    }
}

After: Offload to Background for Large Data Sets

// ✅ Background computation for expensive operations
struct ItemListView: View {
    let items: [Item]
    @State private var searchText = ""
    @State private var filteredItems: [Item] = []

    var body: some View {
        VStack {
            TextField("Search", text: $searchText)
            List(filteredItems) { item in
                ItemRow(item: item)
            }
        }
        .task(id: searchText) {
            // Runs on a background cooperative thread
            // Automatically cancelled if searchText changes again
            let query = searchText
            let allItems = items
            let results = await Task.detached {
                allItems.filter { $0.matchesQuery(query) }
                    .sorted { $0.date > $1.date }
            }.value
            filteredItems = results
        }
    }
}

5. Large Images Without Proper Sizing

The Problem

Loading full-resolution images in a scrolling list causes memory spikes and decode latency:

// ❌ Loads full resolution, decodes on main thread
List(photos) { photo in
    Image(uiImage: UIImage(contentsOfFile: photo.path)!)
        .resizable()
        .frame(height: 200)
}

After (Fast)

// ✅ AsyncImage handles loading and caching
List(photos) { photo in
    AsyncImage(url: photo.url) { image in
        image.resizable().scaledToFill()
    } placeholder: {
        Color.gray.opacity(0.2)
    }
    .frame(height: 200)
    .clipped()
}

// ✅ For local images: thumbnail generation off main thread
struct ThumbnailView: View {
    let imagePath: String
    @State private var thumbnail: UIImage?

    var body: some View {
        Group {
            if let thumbnail {
                Image(uiImage: thumbnail)
                    .resizable()
                    .scaledToFill()
            } else {
                Color.gray.opacity(0.2)
            }
        }
        .frame(height: 200)
        .clipped()
        .task {
            thumbnail = await UIImage(contentsOfFile: imagePath)?
                .byPreparingThumbnail(ofSize: CGSize(width: 400, height: 400))
        }
    }
}

6. Unnecessary @State Updates

The Problem

Setting a @State variable to its current value still triggers a re-evaluation:

// ❌ Triggers re-evaluation even when value hasn't changed
.onReceive(timer) { _ in
    currentTime = Date()  // Always a new value, always triggers body
}

// ❌ Setting state in body (causes infinite loop risk)
var body: some View {
    let _ = { isReady = true }()  // NEVER modify state during body
    Text("Hello")
}

After (Fast)

// ✅ Guard against redundant updates
.onReceive(timer) { _ in
    let newTime = Date()
    // Only update if the displayed value actually changes
    if Calendar.current.component(.second, from: newTime) !=
       Calendar.current.component(.second, from: currentTime) {
        currentTime = newTime
    }
}

// ✅ Use TimelineView for time-based updates (iOS 15+)
TimelineView(.periodic(from: .now, by: 1.0)) { context in
    Text(context.date.formatted(.dateTime.hour().minute().second()))
}

7. Deep View Hierarchies

The Problem

Deeply nested view modifiers create large view trees that are expensive to diff:

// ❌ Each modifier adds a wrapper view to the tree
Text("Hello")
    .padding()
    .background(Color.blue)
    .cornerRadius(8)
    .padding()
    .background(Color.gray)
    .cornerRadius(12)
    .shadow(radius: 4)
    .padding()
    .background(Color.white)
    .cornerRadius(16)

After: Consolidate Modifiers

// ✅ Flatten by using composite modifiers and fewer nesting levels
Text("Hello")
    .padding(8)
    .background(
        RoundedRectangle(cornerRadius: 8)
            .fill(Color.blue)
    )
    .padding(8)
    .background(
        RoundedRectangle(cornerRadius: 12)
            .fill(Color.gray)
            .shadow(radius: 4)
    )
    .padding(8)

Custom ViewModifier for Reusable Combinations

// ✅ Encapsulate common modifier combinations
struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color(uiColor: .secondarySystemBackground))
            )
    }
}

extension View {
    func cardStyle() -> some View {
        modifier(CardStyle())
    }
}

// Usage: single modifier instead of three
Text("Hello").cardStyle()

8. Inefficient Conditional Rendering

The Problem

Using opacity(0) or hidden() to hide views still keeps them in the view tree and evaluates their body:

// ❌ View is invisible but still evaluated and in the layout
ExpensiveView()
    .opacity(showExpensive ? 1 : 0)

After: Conditional Inclusion

// ✅ View is not created at all when hidden
if showExpensive {
    ExpensiveView()
}

// ✅ Or use Group with optional for cleaner syntax
Group {
    if showExpensive {
        ExpensiveView()
    }
}

Note: this changes the view's structural identity (see view-identity.md). Use opacity when you need to preserve state; use if when you want to avoid the evaluation cost.

Quick Diagnosis Table

Symptom Likely pitfall Fix
Entire screen re-renders on any state change Over-observation (#3) Migrate to @Observable or split stores
List scrolling stutters Eager loading, images (#5) Use LazyVStack, async image loading
View body runs 100+ times/second Unnecessary state updates (#6) Guard against redundant state changes
Memory climbs while scrolling Full-resolution images (#5) Use thumbnails, AsyncImage
Initial load takes seconds Eager container (#1 in lazy-loading.md) Switch to lazy container
Type checker hangs during build Large @ViewBuilder switch Extract cases into separate view structs
Animation stutters Expensive computation in body (#4) Move to .task {} or onChange

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    This skill provides expert-level guidance for performance profiling and SwiftUI debugging on Apple platforms. It contains technical documentation, best practices, and diagnostic workflows based on official Apple Developer sessions. No security risks were identified.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
All 4 allowed tools
ReadGlobGrepBash

README badge

README badge for rshankras/claude-code-apple-skills/performance