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

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

  1. Open your project in Xcode 26.
  2. Choose Product > Profile (or press Cmd+I) to build for profiling and launch Instruments.
  3. In the template chooser, select SwiftUI.
  4. Click Record to begin capturing.

The template automatically includes three instruments:

  • SwiftUI View Body — records every body evaluation with the view type name.
  • SwiftUI State Changes — records every @Observable, @State, or @Binding mutation.
  • 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 body was 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 body getter.

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:

  1. A state change event appears: MovieCatalog.selectedMovieID mutated.
  2. An arrow leads from that event to MovieListView.body.
  3. Another arrow leads from MovieListView.body to every MovieRowView.body in 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.

MetricHealthy RangeAction Required
Body duration< 1msNone
Body duration1-4msMonitor; optimize if frequent
Body duration> 4msInvestigate immediately
Evals/sec< 10Normal for most views
Evals/sec10-30Acceptable during animation
Evals/sec> 30 outside animationLikely unnecessary

Reducing Body Cost

When the Cause & Effect Graph reveals an expensive body, consider these strategies ordered by impact:

  1. Split the observable (as shown above) to reduce the observation surface.
  2. Extract child views so that re-evaluations are scoped to smaller subtrees.
  3. Use Equatable conformance on views with value-type inputs — SwiftUI can skip re-evaluation entirely if the inputs have not changed.
  4. Move computation out of body. If filteredMovies is expensive, cache the result and update it only when movies or searchQuery changes 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, @Observable tracking 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)

ScenarioRecommendation
Visible frame drops during scroll or animationUse the SwiftUI Instrument first. The Cause & Effect Graph pinpoints the responsible state change.
Pre-launch performance auditProfile critical user flows with the SwiftUI template to establish a baseline.
Debugging @Observable trackingThe graph shows exactly which properties are tracked per view. More reliable than reading source.
Memory leak investigationUse Allocations and Leaks instruments instead. The SwiftUI template does not track lifetimes.
Network or disk I/O bottlenecksUse the Network or File Activity instruments. Body profiling will not surface I/O waits.
Quick _printChanges() sufficesIf 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 @Observable objects 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.