SwiftUI Performance Optimization — Smooth UIs, Less Recomputing
Source: Dev.to
SwiftUI is fast — but only if you use it correctly.
Over time, you’ll run into:
- choppy scroll performance
- laggy animations
- slow lists
- views refreshing too often
- unnecessary recomputations
- async work blocking UI
- memory spikes from images
- 120 Hz animations dropping frames
This guide shows the real‑world performance rules I now follow in all my apps. These aren’t theoretical — they fix problems you actually hit when building full SwiftUI apps. Let’s make your UI buttery smooth. 🧈⚡
1. Break Up Heavy Views Into Smaller Subviews
SwiftUI re‑renders the entire view when any @State/@ObservedObject changes.
Bad:
VStack {
Header()
ExpensiveList(items: items) // ← huge view
}
.onChange(of: searchQuery) {
// whole view recomputes
}
Better:
VStack {
Header()
ExpensiveList(items: items) // isolated
}
Best:
struct BigScreen: View {
var body: some View {
Header()
BodyContent() // isolated subview prevents extra recomputes
}
}
Small reusable components = huge performance wins.
2. Use @StateObject & @observable Correctly
Use @StateObject for objects that should not re‑initialize:
@StateObject var viewModel = HomeViewModel()
Use @observable for lightweight models:
@Observable
class HomeViewModel { … }
Use @State for simple values (not complex structs). Never store heavy objects inside @State.
3. Keep Async Work Off the Main Thread
Wrong:
func load() async {
let data = try? API.fetch() // slow
self.items = data // UI frozen
}
Right:
func load() async {
let data = try? await Task.detached { await API.fetch() }.value
await MainActor.run { self.items = data }
}
Never block the main thread — SwiftUI depends on it.
4. Use .drawingGroup() for Heavy Drawing
If you render gradients, blur layers, large symbols, or complex masks, it gets expensive fast.
MyComplexShape()
.drawingGroup()
This forces a GPU render pass → much faster.
5. Optimize Images (Most Common Lag Source)
Use resized thumbnails:
Image(uiImage: image.resized(to: 200))
Avoid loading large images in a ScrollView. Prefer:
.resizable().scaledToFit().interpolation(.medium)- async loading + caching
For remote images, use URLCache or a library like Nuke.
6. Avoid Heavy Layout Work Inside ScrollViews
Common mistake:
ScrollView {
ForEach(items) { item in
ExpensiveLayout(item: item)
}
}
Expensive layout inside scrolling causes stutter.
Solutions:
- Reduce modifiers
- Cache computed values
- Break child views into isolated components
7. Prefer LazyVGrid / LazyVStack Over List (When Needed)
List is great — until it isn’t.
Use LazyVStack when:
- Custom animations are needed
- You have large compositional layouts
- Rows contain complex containers
Use List when:
- Rows are simple
- You want native cell optimizations
8. Avoid Recomputing Views With .id(UUID())
MyView()
.id(UUID()) // BAD – destroys identity
This forces a full view reload each frame. Use .id(...) only for controlled resets.
9. Computed Properties Should Be Fast
Bad:
var filtered: [Item] {
hugeArray.filter { … } // expensive
}
Every render recomputes it.
Better:
@State private var filtered: [Item] = []
Update when needed:
.onChange(of: searchQuery) { _ in
filtered = hugeArray.filter { … }
}
10. Use Transaction to Control Animation Cost
Default animations can stutter. Smooth them:
withTransaction(Transaction(animation: .snappy)) {
isOpen.toggle()
}
Custom animation transactions reduce layout jumps.
11. Turn Off Animations During Bulk Updates
withAnimation(.none) {
items = newItems
}
Prevents lag during large list operations.
12. Use Instruments (YES, It Works With SwiftUI)
Profile with:
- SwiftUI “Dirty Views”
- Memory Graph
- Time Profiler
- Allocation Tracking
- FPS drops
90 % of lag comes from:
- Huge views
- Expensive init
- Images
- Main‑thread blocking
✔️ Final Performance Checklist
Before shipping, ensure:
- No main‑thread API calls
- No expensive computed properties
- No layout thrashing inside
ScrollViews - Images are resized or cached
- Heavy views split into components
- ViewModels use
@StateObjector @observable - Animations use
.snappyor.springand are isolated - Lists use lazy containers when needed