Instruments: SwiftUI View Body Profiling with Cause & Effect Graphs
You have a SwiftUI list that stutters on scroll. You have already checked your data source, confirmed your images are cached, and verified that you are not blocking the main thread. Yet the frame drops persist. The question is no longer “is something wrong?” — it is “which state change is triggering which view body re-evaluation, and how often?”
This post walks through the SwiftUI Instrument introduced in Xcode 26, focusing on the Cause & Effect Graph that connects state mutations to view body evaluations with frame-level precision. We will not cover general Instruments workflows (that is covered in Debugging Performance Issues with Instruments) or broad SwiftUI performance strategies (see SwiftUI Performance).
Contents
- The Problem
- The SwiftUI Instrument Template
- Reading the Cause & Effect Graph
- Diagnosing Unnecessary Re-evaluations
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider a movie catalog app backed by @Observable. You have a top-level list, a detail view, and a global search
filter. Everything looks clean — yet scrolling the list produces visible hitches.
@Observable
final class MovieCatalog {
var movies: [Movie] = []
var searchQuery: String = ""
var selectedMovieID: UUID?
var filteredMovies: [Movie] {
guard !searchQuery.isEmpty else { return movies }
return movies.filter {
$0.title.localizedCaseInsensitiveContains(searchQuery)
}
}
}
struct Movie: Identifiable {
let id = UUID()
let title: String
let releaseYear: Int
let director: String
}
And a list view that observes the catalog:
struct MovieListView: View {
@State private var catalog = MovieCatalog()
var body: some View {
NavigationStack {
List(catalog.filteredMovies) { movie in
MovieRowView(movie: movie)
}
.searchable(text: $catalog.searchQuery)
.navigationTitle("Pixar Catalog")
}
}
}
struct MovieRowView: View {
let movie: Movie
var body: some View {
VStack(alignment: .leading) {
Text(movie.title)
.font(.headline)
Text("\(movie.releaseYear) · \(movie.director)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
The symptom: every time selectedMovieID changes (from a tap on a detail view, a remote push, or a programmatic
selection), the entire list re-evaluates. You cannot see this from code review alone. The @Observable macro tracks
property access at a fine-grained level, but filteredMovies reads movies and searchQuery — if the compiler
determines the body accesses the catalog object itself, any mutation to any tracked property on MovieCatalog causes a
re-evaluation. This is the kind of causality chain that the SwiftUI Instrument was built to reveal.
The SwiftUI Instrument Template
Xcode 26 ships a dedicated SwiftUI template in Instruments. Unlike the generic Time Profiler, this template is purpose-built to capture SwiftUI-specific events: view body evaluations, state changes, layout passes, and the new Cause & Effect relationships between them.
Launching a SwiftUI Profiling Session
- Open your project in Xcode 26.
- Choose Product > Profile (or press Cmd+I) to build for profiling and launch Instruments.
- In the template chooser, select SwiftUI.
- Click Record to begin capturing.
The template automatically includes three instruments:
- SwiftUI View Body — records every
bodyevaluation with the view type name. - SwiftUI State Changes — records every
@Observable,@State, or@Bindingmutation. - Cause & Effect Graph — draws directed edges from state changes to the view body evaluations they caused.
Note: The SwiftUI template requires a debug or profiling build configuration. Release builds strip the SwiftUI instrumentation hooks. Make sure your scheme uses the “Profile” build configuration, which Xcode creates by default.
Understanding the Timeline
After recording a session, the timeline displays three lanes stacked vertically. The top lane shows State Changes as
discrete events pinned to the frame where they occurred. The middle lane shows View Body Evaluations as intervals
whose width represents the time spent in body. The bottom lane renders the Cause & Effect Graph as arrows
connecting a state change event to one or more body evaluations.
Clicking any arrow highlights both the causing state change and the affected view body. This is the core interaction — it answers the question “why did this view re-evaluate?” with a concrete, traceable answer.
Apple Docs:
Instruments— Developer Tools
Reading the Cause & Effect Graph
The Cause & Effect Graph is a directed acyclic graph (DAG) rendered inline on the Instruments timeline. Each node is either a state mutation or a body evaluation. Edges flow from cause (state change) to effect (body evaluation).
Anatomy of an Edge
Each edge carries metadata you can inspect in the Detail pane:
- Source: the property that changed (e.g.,
MovieCatalog.selectedMovieID). - Target: the view whose
bodywas re-evaluated (e.g.,MovieListView). - Frame: the render loop frame number where this evaluation occurred.
- Duration: wall-clock time spent in the target view’s
bodygetter.
Tracing Our Catalog Bug
Going back to our MovieCatalog example, here is what the Cause & Effect Graph reveals when we tap a movie to set
selectedMovieID:
- A state change event appears:
MovieCatalog.selectedMovieIDmutated. - An arrow leads from that event to
MovieListView.body. - Another arrow leads from
MovieListView.bodyto everyMovieRowView.bodyin the visible range.
The question becomes: why does changing selectedMovieID cause MovieListView to re-evaluate its body? The answer is
in how @Observable tracking works. If MovieListView.body accesses any property on catalog — even indirectly
through filteredMovies — SwiftUI registers the view as an observer of the entire object’s access set for that render
pass. Since filteredMovies is a computed property that reads movies and searchQuery, SwiftUI tracks those two
properties. But if the body also reads selectedMovieID somewhere (perhaps passed to a navigation destination
modifier), then a change to selectedMovieID correctly triggers re-evaluation.
The Cause & Effect Graph makes this visible without guesswork. You do not need to add print statements or
Self._printChanges() — the graph shows the full causality chain.
Tip: Select multiple edges by holding Shift and clicking. The Detail pane shows all selected edges in a table, which is useful for comparing evaluation durations across different state changes.
Diagnosing Unnecessary Re-evaluations
Once you can see which state changes trigger which view evaluations, the next step is deciding whether those evaluations are necessary. Not every re-evaluation is a problem — SwiftUI is designed to re-evaluate bodies cheaply. The issue arises when a body evaluation is both frequent and expensive.
Identifying the Expensive Path
In the Detail pane, sort body evaluations by Duration descending. Any evaluation taking more than 2ms is worth investigating — at 60fps you have roughly 16ms per frame, and view body evaluation is only one part of the render loop.
For our catalog app, suppose the graph shows that MovieListView.body takes 8ms on every selectedMovieID change
because filteredMovies recomputes over a 10,000-element array. The fix is to break the observation dependency.
Splitting the Observable
Extract selectedMovieID into its own @Observable object so that changes to selection do not cause the list to
re-evaluate:
@Observable
final class MovieCatalog {
var movies: [Movie] = []
var searchQuery: String = ""
var filteredMovies: [Movie] {
guard !searchQuery.isEmpty else { return movies }
return movies.filter {
$0.title.localizedCaseInsensitiveContains(searchQuery)
}
}
}
@Observable
final class NavigationState {
var selectedMovieID: UUID? // Extracted from MovieCatalog
}
Now update the view to use both objects:
struct MovieListView: View {
@State private var catalog = MovieCatalog()
@State private var navigation = NavigationState()
var body: some View {
NavigationStack {
List(catalog.filteredMovies) { movie in
MovieRowView(movie: movie)
.onTapGesture {
navigation.selectedMovieID = movie.id
}
}
.searchable(text: $catalog.searchQuery)
.navigationTitle("Pixar Catalog")
.navigationDestination(
item: $navigation.selectedMovieID
) { id in
MovieDetailView(movieID: id, catalog: catalog)
}
}
}
}
After re-profiling, the Cause & Effect Graph now shows that a NavigationState.selectedMovieID mutation leads only to
the navigation destination evaluation — MovieListView.body is no longer re-evaluated on selection changes. The 8ms
hitch per tap is gone.
Using Self._printChanges() as a Complement
While the Cause & Effect Graph gives you the full picture in Instruments, Self._printChanges() remains useful during
development for quick console checks:
struct MovieListView: View {
@State private var catalog = MovieCatalog()
var body: some View {
let _ = Self._printChanges() // Logs on every evaluation
NavigationStack {
// ...
}
}
}
The console output tells you which property triggered the re-evaluation:
MovieListView: @self, @identity, _catalog changed.
Use _printChanges() in debug builds for rapid iteration, then use the SwiftUI Instrument for production-level analysis
where you need frame correlation and duration measurements.
Warning:
Self._printChanges()is a debug-only API. It is not available in release builds and should never ship to production. The compiler will emit an error if you try.
Advanced Usage
Filtering by View Type
In a large app, the Cause & Effect Graph can become dense. Instruments lets you filter by view type name in the search
bar at the bottom of the Detail pane. Type MovieRowView to see only edges that terminate at MovieRowView.body. This
is invaluable in apps with hundreds of view types.
Correlating with Core Animation Commits
The SwiftUI template can be combined with the Core Animation Commits instrument. Add it to the same trace document by clicking the + button in the Instruments toolbar. Core Animation Commits shows you when SwiftUI’s render server actually composites a frame. By correlating a body evaluation from the Cause & Effect Graph with a Core Animation commit, you can determine whether a re-evaluation actually produced a visible pixel change or was a no-op that SwiftUI’s diffing absorbed.
// A view that re-evaluates but produces identical output
struct StudioLabelView: View {
let studioName: String // Always "Pixar Animation Studios"
var body: some View {
Text(studioName)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
If StudioLabelView.body re-evaluates because its parent re-evaluated, the Cause & Effect Graph shows the edge. But if
the Core Animation Commits lane shows no new commit, SwiftUI’s diffing correctly skipped the update. This is a
“harmless” re-evaluation — optimizing it away improves CPU usage but not frame rate.
Inspecting @State vs @Observable Mutations
The State Changes lane distinguishes between @State/@Binding mutations (shown with a blue marker) and @Observable
property mutations (shown with an orange marker). This color coding helps you quickly spot whether a re-evaluation was
caused by local view state or by a shared model change.
When debugging complex navigation flows — say, a MovieDetailView that writes back to a shared MovieCatalog — the
color distinction immediately tells you whether the triggering mutation was local to the detail view or propagated from
the catalog.
Profiling @Environment and Preference Changes
Environment value changes also appear in the State Changes lane. If you have a custom environment value that changes frequently:
struct RenderQualityKey: EnvironmentKey {
static let defaultValue: RenderQuality = .standard
}
extension EnvironmentValues {
var renderQuality: RenderQuality {
get { self[RenderQualityKey.self] }
set { self[RenderQualityKey.self] = newValue }
}
}
enum RenderQuality {
case standard, high, ultra
}
Every view in the subtree that reads renderQuality will re-evaluate when it changes. The Cause & Effect Graph shows
these as fan-out patterns: one environment change causing dozens of body evaluations. If you spot this pattern, consider
narrowing the scope of the environment modifier to only the subtree that actually needs the value.
Performance Considerations
Body Evaluation Cost vs. Frequency
The Cause & Effect Graph gives you two axes to reason about performance: how often a body evaluates (frequency) and how long each evaluation takes (duration). The product of these two is your total CPU cost for that view.
| Metric | Healthy Range | Action Required |
|---|---|---|
| Body duration | < 1ms | None |
| Body duration | 1-4ms | Monitor; optimize if frequent |
| Body duration | > 4ms | Investigate immediately |
| Evals/sec | < 10 | Normal for most views |
| Evals/sec | 10-30 | Acceptable during animation |
| Evals/sec | > 30 outside animation | Likely unnecessary |
Reducing Body Cost
When the Cause & Effect Graph reveals an expensive body, consider these strategies ordered by impact:
- Split the observable (as shown above) to reduce the observation surface.
- Extract child views so that re-evaluations are scoped to smaller subtrees.
- Use
Equatableconformance on views with value-type inputs — SwiftUI can skip re-evaluation entirely if the inputs have not changed. - Move computation out of
body. IffilteredMoviesis expensive, cache the result and update it only whenmoviesorsearchQuerychanges rather than recomputing on every body evaluation.
@Observable
final class MovieCatalog {
var movies: [Movie] = [] {
didSet { recomputeFilteredMovies() }
}
var searchQuery: String = "" {
didSet { recomputeFilteredMovies() }
}
private(set) var filteredMovies: [Movie] = []
private func recomputeFilteredMovies() {
guard !searchQuery.isEmpty else {
filteredMovies = movies
return
}
filteredMovies = movies.filter {
$0.title.localizedCaseInsensitiveContains(searchQuery)
}
}
}
By converting filteredMovies from a computed property to a stored, eagerly-updated property, you ensure the filtering
work happens once per mutation rather than once per body evaluation. The Cause & Effect Graph will still show the same
edges, but the Duration column for MovieListView.body will drop significantly.
Apple Docs:
Improving your app's performance— Xcode
WWDC Sessions to Watch
The following sessions provide deeper context for the tooling and patterns discussed here:
- WWDC 2025 — “What’s new in SwiftUI”: introduces the SwiftUI Instrument and Cause & Effect Graph.
- WWDC 2025 — “Demystify SwiftUI performance”: covers structural identity,
@Observabletracking semantics, and when SwiftUI skips body evaluations. - WWDC 2025 — “What’s new in Instruments”: walks through the Power Profiler, Processor Trace, and the new SwiftUI template.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Visible frame drops during scroll or animation | Use the SwiftUI Instrument first. The Cause & Effect Graph pinpoints the responsible state change. |
| Pre-launch performance audit | Profile critical user flows with the SwiftUI template to establish a baseline. |
Debugging @Observable tracking | The graph shows exactly which properties are tracked per view. More reliable than reading source. |
| Memory leak investigation | Use Allocations and Leaks instruments instead. The SwiftUI template does not track lifetimes. |
| Network or disk I/O bottlenecks | Use the Network or File Activity instruments. Body profiling will not surface I/O waits. |
Quick _printChanges() suffices | If you already know which view is re-evaluating and need a quick check, skip the full session. |
The SwiftUI Instrument is at its most valuable when you have a performance problem you cannot explain from code review alone — when you need to see the runtime causality chain between state and rendering.
Summary
- The SwiftUI Instrument in Xcode 26 includes a Cause & Effect Graph that draws directed edges from state mutations to view body evaluations, eliminating guesswork about why views re-evaluate.
- Body evaluation duration and frequency are the two axes to monitor. A view that re-evaluates cheaply and infrequently is fine; one that re-evaluates expensively on every state change needs optimization.
- Splitting
@Observableobjects by responsibility is the highest-impact technique for reducing unnecessary re-evaluations. Keep unrelated state in separate observable objects. - Combine the SwiftUI template with Core Animation Commits to distinguish between re-evaluations that produce pixel changes and those absorbed by SwiftUI’s diffing.
Self._printChanges()remains useful for quick console-based debugging, but the Cause & Effect Graph provides frame-correlated, duration-measured, production-grade analysis.
For profiling energy impact and CPU-level performance beyond SwiftUI’s view layer, see Instruments: Power Profiler and CPU Counters. For a broader look at the new developer tools landscape, check out Xcode 26: The Features That Change How You Work.