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
// ...
}
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))
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)
}
}
}
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
}
}
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()
}
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
}
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
}
}
}
}
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
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)
}
}
}
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)