How do you find and fix performance problems in SwiftUI?
TL;DR
SwiftUI is slow when body runs too often or does too much work.
Fixes: keep body cheap, split views so each one reads only what it needs, keep identity stable, and use lazy stacks for long content.
Always measure first. Use Instruments (the SwiftUI instrument) and Self._printChanges().
1. Keep body cheap
body can run many times per second. Do not create formatters, sort arrays or decode images there.
// Bad
var body: some View {
let formatter = DateFormatter() // new object every update
formatter.dateStyle = .medium
return List(items.sorted { $0.date > $1.date }) { item in // sorts every update
Text(formatter.string(from: item.date))
}
}
// Good
var body: some View {
List(model.sortedItems) { item in
Text(item.date, format: .dateTime.day().month().year())
}
}Move heavy work to the model, and compute it when the data changes, not when the view draws.
2. Depend on less data
A view updates when data it reads changes. Read less, update less.
// Bad: the whole row list updates when anything in `store` changes
struct Header: View {
let store: Store
var body: some View { Text(store.user.name) }
}
// Better: pass only what is needed
struct Header: View {
let name: String
var body: some View { Text(name) }
}@Observable already tracks per property. With old ObservableObject, any @Published change updates every observer, so this matters even more.
3. Split big views
Small child views are cheap. SwiftUI can skip them if their inputs did not change.
struct ProductScreen: View {
let product: Product
@State private var quantity = 1
var body: some View {
VStack {
ProductHeader(product: product) // not updated when quantity changes
Stepper("Qty \(quantity)", value: $quantity)
}
}
}4. Stable identity
- Use stable IDs in
ForEach. Not indices, notUUID()created on each load. - Avoid
AnyViewin lists. - Avoid
if/elsethat swaps between two copies of the same view. Change a modifier value instead.
5. Lazy containers
ScrollView {
LazyVStack {
ForEach(posts) { PostRow(post: $0) }
}
}VStack builds all children at once. LazyVStack and List build only visible rows.
6. Images
- Downsample big images before showing them. A 4000 px photo in a 100 pt cell wastes memory and CPU.
- Decode off the main thread, for example with
UIImage.byPreparingThumbnail(ofSize:). AsyncImageis simple but gives you little control over caching.
How to measure
var body: some View {
let _ = Self._printChanges() // debug only: prints why body ran
Text(title)
}- Instruments > SwiftUI template. Xcode 26 has a new SwiftUI instrument that shows long view body updates and what caused each update.
- Animation Hitches instrument for dropped frames while scrolling.
- Time Profiler for slow code inside
bodyor models. - Always profile a Release build on a real device.
Other tips
.equatable()or anEquatableview can skip updates when inputs are equal. Measure first; it is rarely needed with@Observable..drawingGroup()renders complex vector drawings with Metal. Use it for heavy shapes, not for normal UI.- Avoid
GeometryReaderin every row. UseonGeometryChange(iOS 18, back-deployed) orcontainerRelativeFramewhen possible.
Common follow-up questions
- Why does my whole list update when one item changes? The list reads a big object, or row IDs are unstable.
- Is creating view structs expensive? No. Structs are cheap. The cost is in
bodywork and in layout. - What is the main thread budget? About 16.7 ms per frame at 60 Hz and 8.3 ms at 120 Hz.
- Does
@Statein many rows hurt? Not by itself. Heavy work on each update does.