DEV Community

10 SwiftUI Performance Tips for 2026

SwiftUI is fast by default, but small mistakes can kill your app's performance. Here are 10 tips I've learned that will keep your UI buttery smooth.

1. Use @Observable Instead of ObservableObject

iOS 17 introduced the @Observable macro which is more efficient:

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

Regular stacks load all content immediately. Lazy stacks only load visible items:

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

Prevent unnecessary updates by implementing Equatable:

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:

// BAD - reloads every time
AsyncImage(url: imageURL) { image in
    image.resizable()
} placeholder: {
    ProgressView()
}

// GOOD - use a caching library like Kingfisher or SDWebImage
// 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 {
            image = await ImageCache.shared.load(url)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Use drawingGroup() for Complex Graphics

When you have many overlapping views with effects, use drawingGroup():

// 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() // Huge performance boost!
Enter fullscreen mode Exit fullscreen mode

8. Debounce Search Input

Don't trigger API calls on every keystroke:

@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 }
            results = try await api.search(searchText)
        }
    }
}
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

Problem Solution
Slow scrolling Use LazyVStack/LazyHStack
Stuttering animations Use drawingGroup()
Too many redraws Use @observable, EquatableView
Memory spikes Cache images, use task(id:)
Laggy search Debounce input

Want optimized, production-ready components?

Check out my SwiftUI UI Components Pack - 38 components that are:

  • Optimized for performance
  • Tested on real devices
  • Ready to copy-paste

Or get the complete SwiftUI Starter Kit Pro with 5 screens and full architecture already set up.

Build fast apps, faster!

Top comments (0)