Find the SwiftUI State Change Behind a Slow Body Update
The most frustrating SwiftUI performance bug is the one that looks obvious and still refuses to be obvious.
You tap one button, one small part of the UI should change, and the app pauses. Or a list scrolls smoothly until a piece of state changes, then a frame stutters. The temptation is to stare at body, blame the nearest ForEach, sprinkle a few EquatableViews around, and hope the app forgives you.
Instruments 26 gives us a better path. The SwiftUI instrument can show which view bodies are taking too long, then help connect a body update back to the data change that caused it. That matters because SwiftUI call stacks are usually not enough. A backtrace can tell you SwiftUI is updating, but not what marked your view as outdated.
Let’s walk through the workflow with a small favorite-button bug. The exact model is fake, but the shape is common: a row reads too much shared state, so one mutation makes too many rows update.
Start with the symptom
Here is a simplified list row:
import SwiftUI
struct TrailRow: View {
@Environment(TrailStore.self) private var store
let trail: Trail
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(trail.name)
.font(.headline)
Text(formattedDistance)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button {
store.toggleFavorite(trail)
} label: {
Image(systemName: store.isFavorite(trail) ? "heart.fill" : "heart")
}
.buttonStyle(.plain)
}
}
private var formattedDistance: String {
let formatter = MeasurementFormatter()
formatter.unitOptions = .naturalScale
return formatter.string(
from: Measurement(value: trail.distanceInMeters, unit: UnitLength.meters)
)
}
}
There are two possible problems hiding in this row.
The first is expensive work in body. formattedDistance creates a formatter every time the body runs. A single formatter creation may not feel scary, but a scrolling list can ask many rows to update before the next frame deadline.
The second is update frequency. store.isFavorite(trail) may look harmless, but it depends on how TrailStore is implemented. If every row reads the same array of favorite trails, changing that array can make every visible row’s body eligible to run.
Those are different fixes. The formatter problem wants caching or precomputed display text. The dependency problem wants more granular state. Instruments helps separate them.
Record the interaction
Run the app in a configuration that is close to what people use. In Xcode, choose Product > Profile, pick the SwiftUI template in Instruments, start recording, and perform the interaction that stutters. For this example, scroll the list, tap a favorite button, scroll again, then stop the recording.
Apple’s SwiftUI performance documentation and the WWDC25 Instruments session call out the important categories:
Update Groupsshows when SwiftUI is doing update work.Long View Body Updateshighlights body calculations that took long enough to investigate.- Long representable/platform view updates point to hosted UIKit or AppKit view work.
Other Long Updatescovers other SwiftUI work, including geometry and text layout calculations.
I usually start with Long View Body Updates. If the lane is quiet but the Update Groups lane shows a long busy region, the issue may be lots of small updates instead of one slow body. That distinction is useful because it keeps the investigation honest.
Find the expensive body
Expand the SwiftUI track and select View Body Updates. In the detail pane, look for your app module and the view type with long updates. If a row like TrailRow appears near the top, inspect one update.
Apple shows this flow in the WWDC25 SwiftUI performance session:
- Filter the summary to long view body updates when you want to focus on slow bodies.
- Use the row for the view type you care about to show the individual updates.
- Right-click, or Control-click, a long update and choose
Set Inspection Range and Zoom. - Select the
Time Profilertrack and inspect the call tree for that range.
For the row above, you would expect to find time under TrailRow.body, then under formattedDistance, then under formatter work. That is a good fix candidate because it is pure display preparation being repeated during view updates.
Move that work out of the body path:
@MainActor
@Observable
final class TrailViewModel {
let trail: Trail
var isFavorite: Bool
private(set) var distanceText: String
private static let distanceFormatter: MeasurementFormatter = {
let formatter = MeasurementFormatter()
formatter.unitOptions = .naturalScale
return formatter
}()
init(trail: Trail, isFavorite: Bool) {
self.trail = trail
self.isFavorite = isFavorite
self.distanceText = Self.formatDistance(trail.distanceInMeters)
}
func updateDistance(_ distanceInMeters: Double) {
distanceText = Self.formatDistance(distanceInMeters)
}
private static func formatDistance(_ distanceInMeters: Double) -> String {
distanceFormatter.string(
from: Measurement(value: distanceInMeters, unit: UnitLength.meters)
)
}
}
If the distance only changes when location changes, update the cached string when the location update arrives instead of formatting lazily from the view. Reusing the formatter helps, but the real win is moving repeated display preparation out of the body path. The rule is not “never use computed properties.” The rule is “do not hide repeated CPU work behind a property that body reads.”
After the change, record again. Do not assume the fix worked because the code feels nicer. The long TrailRow updates should disappear or become much smaller.
Look for too many updates
Now handle the other half: one tap should not make every row update.
Select the portion of the trace where you tapped the favorite button. If Update Groups is busy but individual updates are not especially long, look at the summary for View Body Updates. If many TrailRow bodies ran after one tap, open the cause-and-effect graph for one of those view updates. In the WWDC25 demo, this is the Show Cause & Effect Graph action from a view update.
The graph is the missing piece for SwiftUI debugging. It starts from the update you are inspecting and shows the chain of events that caused it. In Apple’s session, the graph links a gesture to an @Observable change, then to the view body updates that depended on that change. Blue nodes represent app code or user actions, while gray nodes represent system framework work.
A graph for the row above might tell this story:
- A button gesture called
toggleFavorite(_:). TrailStore.favoriteTrailschanged.- Every visible
TrailRow.bodyhad a dependency onfavoriteTrails. - Every visible row updated, even though only one heart changed.
That is a data-flow bug, not a formatter bug.
Make the dependency smaller
Here is the store shape that causes the broad invalidation:
@MainActor
@Observable
final class TrailStore {
var trails: [Trail] = []
var favoriteTrails: [Trail.ID] = []
func isFavorite(_ trail: Trail) -> Bool {
favoriteTrails.contains(trail.id)
}
func toggleFavorite(_ trail: Trail) {
if let index = favoriteTrails.firstIndex(of: trail.id) {
favoriteTrails.remove(at: index)
} else {
favoriteTrails.append(trail.id)
}
}
}
Every row asks the store to search the whole favorites array. With observation, the important part is not only the function call. It is the property read inside the function. Each row reads favoriteTrails, so each row depends on favoriteTrails.
One fix is to give each row a small observable model:
@Observable
final class TrailStore {
private var rowModelsByID: [Trail.ID: TrailViewModel] = [:]
private var favoriteIDs: Set<Trail.ID> = []
func rowModel(for trail: Trail) -> TrailViewModel {
if let model = rowModelsByID[trail.id] {
return model
}
let model = TrailViewModel(
trail: trail,
isFavorite: favoriteIDs.contains(trail.id)
)
rowModelsByID[trail.id] = model
return model
}
func toggleFavorite(_ model: TrailViewModel) {
if favoriteIDs.contains(model.trail.id) {
favoriteIDs.remove(model.trail.id)
model.isFavorite = false
} else {
favoriteIDs.insert(model.trail.id)
model.isFavorite = true
}
}
}
Then the row reads only its own model:
struct TrailRow: View {
@Environment(TrailStore.self) private var store
let model: TrailViewModel
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(model.trail.name)
.font(.headline)
Text(model.distanceText)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button {
store.toggleFavorite(model)
} label: {
Image(systemName: model.isFavorite ? "heart.fill" : "heart")
}
.buttonStyle(.plain)
}
}
}
There are other valid designs. You might pass a Binding<Bool> to the row, split favorite state into a separate model, or make the parent compute the row’s favorite value. The point is the same: the row should depend on the smallest piece of state that can actually change its output.
Record again and inspect the same tap. The cause-and-effect graph should show the gesture and one row model update causing the row you tapped to update. That second recording matters: after you reduce one cause, another event may still drive the same view.
Watch environment values
The environment is another easy place to create broad update checks. Reading @Environment(\.colorScheme) is normal. Putting a timer tick, scroll geometry, or rapidly changing layout value into the environment is much more suspicious.
Apple’s WWDC25 session explains that views reading the environment can be notified when environment values update. SwiftUI can skip running a body when the value that view cares about did not actually change, but checking still has a cost. If many views read environment values and those values change frequently, the work adds up.
Keep fast-changing values close to the views that need them. Prefer a binding, a small observable model, or a focused child view over a global environment value when the value changes many times per second.
A small checklist
When a SwiftUI screen hitches, I use this order:
- Record the actual interaction with the SwiftUI template.
- Check
Long View Body Updatesfirst. - Use
Set Inspection Range and ZoomplusTime Profilerto find expensive work inside one slow body. - Move formatting, parsing, sorting, image processing, and other nontrivial work out of
body. - If updates are numerous rather than individually slow, inspect
Update Groups. - Open the cause-and-effect graph and compare the nodes with what you expected to change.
- Make dependencies more granular: smaller observable models, narrower bindings, fewer broad environment reads.
- Record again and verify both the duration and the number of updates.
The nice thing about this workflow is that it changes the question. Instead of “why is SwiftUI slow?”, you can ask “which update ran, what made it outdated, and what code did it execute?” That is a much smaller problem. Smaller problems are where performance work finally gets fun again.
Further reading:
- WWDC25: Optimize SwiftUI performance with Instruments: https://developer.apple.com/videos/play/wwdc2025/306/
- Understanding and improving SwiftUI performance: https://developer.apple.com/documentation/xcode/understanding-and-improving-swiftui-performance
- Performance analysis in SwiftUI: https://developer.apple.com/documentation/swiftui/performance-analysis
- Improving app responsiveness: https://developer.apple.com/documentation/xcode/improving-app-responsiveness