DEV Community

Daniil Kornilov
Daniil Kornilov

Posted on Edited on

SwiftUI performance ideas to evaluate with profiling

Disclosure: This article was created or edited with AI assistance. Revised on 13 October 2026 to remove promotional links and unsupported outcome claims. It is educational guidance, not a verified personal case study or a measured result. Examples are illustrative; use only your own documented experience and measurements.

The sections below present options and illustrative examples. Adapt them to the requirements of your project or role; they do not establish a universal rule or promise a result.

1. Use @Observable Instead of ObservableObject

Observation tracks property access, which can narrow updates for a particular model. Measure the effect in your app:

// Old way - triggers unnecessary updates
class ViewModel: ObservableObject {
    @Published var name = ""
    @Published var age = 0
}

// New way - only updates when accessed properties change
@Observable
class ViewModel {
    var name = ""
    var age = 0
}
Enter fullscreen mode Exit fullscreen mode

The new macro tracks only the properties your view actually reads.

2. Avoid Expensive Computations in body

Never do heavy work inside your view's body:

// BAD - computed every render
var body: some View {
    let filtered = items.filter { $0.isActive }.sorted()
    List(filtered) { item in
        Text(item.name)
    }
}

// GOOD - cached in ViewModel
var body: some View {
    List(viewModel.filteredItems) { item in
        Text(item.name)
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Use LazyVStack and LazyHStack

Lazy stacks create content as needed and can prefetch near the visible area. Compare with a regular stack using profiling:

// BAD - loads 10,000 items at once
ScrollView {
    VStack {
        ForEach(items) { item in
            ExpensiveRow(item: item)
        }
    }
}

// GOOD - loads only visible items
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ExpensiveRow(item: item)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Add Stable IDs to ForEach

SwiftUI uses IDs to track changes. Unstable IDs cause unnecessary redraws:

// BAD - index changes when items are reordered
ForEach(items.indices, id: \.self) { index in
    ItemRow(item: items[index])
}

// GOOD - stable ID from data model
ForEach(items) { item in
    ItemRow(item: item)
}
Enter fullscreen mode Exit fullscreen mode

5. Use EquatableView for Complex Views

Equatable can allow SwiftUI to skip some updates when the compared value is unchanged. Equality must include everything relevant to rendering:

struct ProfileCard: View, Equatable {
    let user: User

    static func == (lhs: ProfileCard, rhs: ProfileCard) -> Bool {
        lhs.user.id == rhs.user.id && 
        lhs.user.name == rhs.user.name
    }

    var body: some View {
        // Complex view here
    }
}

// Usage
EquatableView(content: ProfileCard(user: user))
Enter fullscreen mode Exit fullscreen mode

6. Cache Images Properly

Don't reload images on every render:

// Basic loading example; inspect actual requests and caching behavior
AsyncImage(url: imageURL) { image in
    image.resizable()
} placeholder: {
    ProgressView()
}

// An explicit cache is an option when measurement shows a need
// Or implement your own cache
struct CachedAsyncImage: View {
    let url: URL
    @State private var image: UIImage?

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image)
                    .resizable()
            } else {
                ProgressView()
            }
        }
        .task(id: url) {
            image = nil
            let loaded = await ImageCache.shared.load(url)
            guard !Task.isCancelled else { return }
            image = loaded
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Use drawingGroup() for Complex Graphics

For a measured rendering bottleneck, compare drawingGroup with the original implementation:

// Renders as a single flattened image
ZStack {
    ForEach(0..<100) { i in
        Circle()
            .fill(Color.blue.opacity(0.1))
            .frame(width: CGFloat(i * 3))
    }
}
.drawingGroup() // Measure rendering time and memory before adopting this
Enter fullscreen mode Exit fullscreen mode

8. Debounce Search Input

Don't trigger API calls on every keystroke:

@MainActor
@Observable
class SearchViewModel {
    var searchText = ""
    var results: [Item] = []

    private var searchTask: Task<Void, Never>?

    func search() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            do {
                let found = try await api.search(searchText)
                guard !Task.isCancelled else { return }
                results = found
            } catch is CancellationError {
                return
            } catch {
                // Expose an error state appropriate to your UI.
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

9. Use task(id:) Correctly

Cancel and restart tasks when data changes:

struct UserDetailView: View {
    let userID: Int
    @State private var user: User?

    var body: some View {
        VStack {
            if let user {
                Text(user.name)
            }
        }
        .task(id: userID) { // Restarts when userID changes
            user = try? await api.fetchUser(userID)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

10. Profile with Instruments

Use Xcode's Instruments to find real bottlenecks:

  1. Product → Profile (Cmd+I)
  2. Choose SwiftUI template
  3. Look for:
    • High "body invocations"
    • Long "View body" times
    • Frequent "State changes"

Quick Reference

Symptom Investigation
Slow scrolling Profile construction and layout; compare container types
Stuttering animations Identify layout/rendering work; compare drawingGroup only if relevant
Frequent updates Inspect observed properties and identity; validate any equality check
Memory spikes Measure allocations, decoded-image size, cache limits and task lifetime
Laggy search Debounce input

Re-measure after each change and keep a reproducible comparison.

Scope of the code examples

These are separate teaching snippets, not a complete tested application or a production-ready package. Match availability to your deployment target, provide omitted model/service definitions, and check errors, cancellation, accessibility and persistence before adapting them.

Measure before choosing a lazy container or a rendering optimization. A change such as drawingGroup can add costs; it is not a general fix for choppy animation. See Apple's container guidance and performance guide.

Top comments (0)