DEV Community

10 SwiftUI Performance Tricks That Made My Apps 3x Faster

SwiftUI is declarative and elegant. It's also easy to accidentally make it slow.

After profiling and optimizing 27 iOS apps, here are 10 performance tricks that consistently make the biggest difference.

1. Use @State Over @StateObject When Possible

@StateObject creates an observable object that triggers view updates. For simple values, @State is cheaper.

// Slow: creates unnecessary observation overhead
class CounterModel: ObservableObject {
    @Published var count = 0
}

struct CounterView: View {
    @StateObject var model = CounterModel()
    // ...
}

// Fast: direct state management
struct CounterView: View {
    @State private var count = 0
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Rule: Use @Observable / @StateObject only when you need shared state or complex logic.

2. Avoid Body Recomputation with EquatableView

SwiftUI recomputes body whenever state changes. For expensive views, skip unnecessary recomputations:

struct ExpensiveView: View, Equatable {
    let data: [Item]

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.data.count == rhs.data.count
    }

    var body: some View {
        // complex layout
    }
}

// Usage
EquatableView(content: ExpensiveView(data: items))
Enter fullscreen mode Exit fullscreen mode

3. Lazy Loading in Lists

Never load all items at once. Use LazyVStack instead of VStack inside ScrollView:

// Slow: renders ALL items immediately
ScrollView {
    VStack {
        ForEach(items) { item in
            ItemRow(item: item)
        }
    }
}

// Fast: renders only visible items
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemRow(item: item)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This alone can turn a 2-second load into instant rendering for lists with 1000+ items.

4. Cache Images Properly

Loading images without caching is the #1 cause of scroll jank:

struct CachedAsyncImage: View {
    let url: URL?
    @State private var image: UIImage?

    private static let cache = NSCache<NSURL, UIImage>()

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } else {
                ProgressView()
                    .task { await loadImage() }
            }
        }
    }

    private func loadImage() async {
        guard let url else { return }

        if let cached = Self.cache.object(forKey: url as NSURL) {
            image = cached
            return
        }

        guard let (data, _) = try? await URLSession.shared.data(from: url),
              let downloaded = UIImage(data: data) else { return }

        Self.cache.setObject(downloaded, forKey: url as NSURL)
        image = downloaded
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Use .task Instead of .onAppear for Async Work

.task automatically cancels when the view disappears. .onAppear doesn't:

// Bad: potential memory leak and race condition
.onAppear {
    Task {
        await viewModel.loadData()
    }
}

// Good: auto-cancellation on disappear
.task {
    await viewModel.loadData()
}
Enter fullscreen mode Exit fullscreen mode

6. Minimize View Identity Changes

SwiftUI uses identity to track views. Changing identity forces a full recreation:

// Slow: new identity on every state change
ForEach(items) { item in
    ItemView(item: item)
        .id(item.hashValue) // DON'T: hashValue can change
}

// Fast: stable identity
ForEach(items) { item in
    ItemView(item: item)
        // ForEach uses item.id automatically
}
Enter fullscreen mode Exit fullscreen mode

7. Extract Subviews to Limit Redraws

When one piece of state changes, the entire body recomputes. Extract independent parts:

// Slow: timer updates redraw the entire view
struct ProfileView: View {
    @State private var name = "John"
    @State private var timer = 0

    var body: some View {
        VStack {
            Text(name) // redraws when timer changes!
            Text("\(timer)")
            ExpensiveChart()  // redraws when timer changes!
        }
        .task {
            while true {
                try? await Task.sleep(for: .seconds(1))
                timer += 1
            }
        }
    }
}

// Fast: timer only redraws TimerView
struct ProfileView: View {
    @State private var name = "John"

    var body: some View {
        VStack {
            Text(name)
            TimerView() // isolated redraws
            ExpensiveChart()
        }
    }
}

struct TimerView: View {
    @State private var timer = 0
    var body: some View {
        Text("\(timer)")
            .task {
                while true {
                    try? await Task.sleep(for: .seconds(1))
                    timer += 1
                }
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

8. Use drawingGroup() for Complex Graphics

For views with many layers, shadows, or blending:

ZStack {
    ForEach(0..<100) { i in
        Circle()
            .fill(.blue.opacity(0.3))
            .frame(width: CGFloat(i) * 3)
            .blur(radius: 2)
    }
}
.drawingGroup() // renders offscreen then composites
Enter fullscreen mode Exit fullscreen mode

9. Debounce Search Input

Don't fire a network request on every keystroke:

struct SearchView: View {
    @State private var query = ""
    @State private var results: [Item] = []

    var body: some View {
        TextField("Search...", text: $query)
            .task(id: query) {
                try? await Task.sleep(for: .milliseconds(300))
                guard !Task.isCancelled else { return }
                results = await search(query)
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

The .task(id:) modifier restarts the task whenever query changes, and the sleep acts as a natural debounce.

10. Profile Before Optimizing

Use Instruments (Xcode > Product > Profile) before guessing:

  • SwiftUI Instrument: Shows body evaluations and update counts
  • Time Profiler: Identifies slow functions
  • Allocations: Finds memory leaks

The biggest performance wins usually come from fixing one or two hot spots, not from micro-optimizing everything.

Quick Reference

Problem Solution
Slow list scrolling LazyVStack + image caching
Excessive redraws Extract subviews + Equatable
Memory leaks .task instead of .onAppear
Choppy animations drawingGroup()
Search lag Debounce with .task(id:)

I share performance tips and SwiftUI patterns daily on t.me/SwiftUIDaily. Follow for production-ready code you can use in your apps today.

Top comments (0)