SwiftUI Performance: Identifying and Fixing Unnecessary View Redraws


Your SwiftUI app feels janky on older devices. Scrolling stutters, list updates lag. You add print statements inside view bodies and discover some views are re-rendering dozens of times per second for no apparent reason. SwiftUI’s declarative model is not a free pass for performance — it moves the cost from imperative layout code to view body evaluation, and a poorly structured hierarchy can evaluate more work per frame than UIKit ever did.

This guide covers the full diagnostic and optimization workflow: measuring redraws with _printChanges() and Instruments, understanding structural identity, extracting subviews, leveraging @Observable’s fine-grained dependency tracking, and applying drawingGroup() for Metal-backed rendering. We won’t cover Metal or SpriteKit integration; this is SwiftUI-native optimization only.

Contents

The Problem

Consider a Pixar film catalog with a top-level FilmLibraryView that owns multiple pieces of state: the currently selected film, the active sort order, a search query, and a loading flag for network requests.

// ❌ Before: poor state organization causes cascade redraws
class FilmLibraryViewModel: ObservableObject {
    @Published var films: [PixarFilm] = []
    @Published var selectedFilm: PixarFilm?
    @Published var searchQuery: String = ""
    @Published var sortOrder: SortOrder = .releaseDate
    @Published var isLoading: Bool = false
    @Published var currentUserName: String = "" // unrelated to the list
}

struct FilmLibraryView: View {
    @StateObject private var viewModel = FilmLibraryViewModel()

    var body: some View {
        VStack {
            SearchBar(query: $viewModel.searchQuery)
            SortPicker(order: $viewModel.sortOrder)
            FilmListContent(
                films: viewModel.films,
                selectedFilm: viewModel.selectedFilm
            )
            UserGreeting(name: viewModel.currentUserName)
        }
    }
}

When the user types in the search bar, searchQuery publishes a change. Because FilmLibraryViewModel is an ObservableObject, every @Published property change causes the entire object to broadcast objectWillChange. SwiftUI invalidates every view subscribed to viewModel — including FilmListContent and UserGreeting — even though neither of them reads searchQuery.

On a device with 200 films, FilmListContent’s body re-evaluates on every keystroke, recomputing the filtered and sorted array from scratch each time. On an iPhone 12 Pro you won’t notice. On an iPhone XR with a thermal throttle active, it stutters.

Measuring Redraws

Before optimizing, measure. Guessing at performance problems leads to premature optimization that makes the code worse without helping the user.

_printChanges()

Apple Docs: _printChanges() — SwiftUI

_printChanges() is an underscore-prefixed (unofficial but stable) method that prints what caused a view to re-evaluate. Add it to a view body to get a diagnostic log:

struct FilmListContent: View {
    let films: [PixarFilm]
    let selectedFilm: PixarFilm?

    var body: some View {
        let _ = Self._printChanges() // prints the cause of every redraw
        List(films) { film in
            FilmRow(film: film, isSelected: selectedFilm?.id == film.id)
        }
    }
}

The output in the console looks like this:

FilmListContent: @self, @identity, _films changed.
FilmListContent: _selectedFilm changed.
FilmListContent: @self, @identity, _films changed.

Each entry tells you exactly which property changed. @self means the view’s struct value changed. @identity means the view was inserted fresh into the hierarchy. If you see a view logging changes on properties it shouldn’t depend on, that’s your optimization target.

Warning: Remove all _printChanges() calls before shipping. Each call allocates a string and writes to stdout, adding measurable overhead in tight render loops.

Instruments: SwiftUI View Body Template

For a production-grade measurement, use the SwiftUI instrument in Xcode’s Instruments:

  1. Open your app in Xcode and choose Product → Profile (⌘I).
  2. Select the SwiftUI template.
  3. Record while performing the interaction that feels slow.
  4. Examine the View Body lane for evaluation counts and durations.

The SwiftUI instrument shows the flame graph of view body evaluations. A view body that takes more than 1ms is worth investigating. Look for views with evaluation counts in the hundreds during a single user interaction.

Structural Identity vs. Value Identity

This is the single most important concept for SwiftUI performance. SwiftUI uses the position in the view tree to identify views across renders, not their values. This is called structural identity.

When you write if/else, SwiftUI sees two different view types at that position in the tree. Switching between branches destroys one view and creates the other — resetting all state, animations, and focus.

// ❌ Creates two distinct structural identities — SwiftUI
// destroys and recreates the view when condition toggles.
if film.isAwardWinner {
    FilmPoster(film: film)
        .border(Color.yellow, width: 2)
} else {
    FilmPoster(film: film)
}
// ✅ One structural identity — SwiftUI updates the existing view.
// The border animates in/out rather than the view being recreated.
FilmPoster(film: film)
    .border(Color.yellow, width: film.isAwardWinner ? 2 : 0)
    .animation(.easeInOut, value: film.isAwardWinner)

Use if/else only when you genuinely want to insert and remove a view. For conditional appearance, use conditional modifiers with stable structural identity.

Apple Docs: Maintaining the identity of state and views — SwiftUI Documentation

Extract Subviews

Every view body in SwiftUI is a pure function of its inputs. When a parent re-evaluates, it calls body on its children — but only if those children’s inputs (their value-type properties) have changed.

The key insight: if you extract a child view as a separate struct, SwiftUI compares the child’s input values before deciding whether to call the child’s body. If the inputs are identical, the child’s body is skipped entirely.

// ❌ Before: SortPicker and SearchBar re-evaluate when films array changes,
// even though they don't use it.
struct FilmLibraryView: View {
    @State private var films: [PixarFilm]
    @State private var searchQuery = ""
    @State private var sortOrder: SortOrder = .releaseDate

    var body: some View {
        VStack {
            // These views get re-evaluated when `films` changes
            HStack {
                Image(systemName: "magnifyingglass")
                TextField("Search films...", text: $searchQuery)
            }
            Picker("Sort", selection: $sortOrder) { /* options */ }
            List(films) { film in
                FilmRow(film: film)
            }
        }
    }
}
// ✅ After: extracted subviews only re-evaluate when their specific
// inputs change
struct FilmLibraryView: View {
    @State private var films: [PixarFilm] = []
    @State private var searchQuery = ""
    @State private var sortOrder: SortOrder = .releaseDate

    var body: some View {
        VStack {
            // re-evaluates only when searchQuery changes
            FilmSearchBar(query: $searchQuery)
            // re-evaluates only when sortOrder changes
            FilmSortPicker(order: $sortOrder)
            // re-evaluates only when films array changes
            FilmList(films: films)
        }
    }
}

struct FilmSearchBar: View {
    @Binding var query: String

    var body: some View {
        HStack {
            Image(systemName: "magnifyingglass")
            TextField("Search films...", text: $query)
        }
        .padding(8)
        .background(Color(.systemGray6))
        .clipShape(RoundedRectangle(cornerRadius: 10))
    }
}

Subview extraction isn’t just cleaner code — it’s the primary mechanism SwiftUI uses to skip redundant work.

Equatable Views

Apple Docs: EquatableView — SwiftUI

When a view’s inputs conform to Equatable, you can tell SwiftUI to skip re-evaluating the body if the inputs haven’t changed using the .equatable() modifier or EquatableView wrapper.

struct FilmRow: View, Equatable {
    let film: PixarFilm
    let isSelected: Bool

    // SwiftUI calls body only when film or isSelected changes
    static func == (lhs: FilmRow, rhs: FilmRow) -> Bool {
        lhs.film.id == rhs.film.id
            && lhs.film.updatedAt == rhs.film.updatedAt
            && lhs.isSelected == rhs.isSelected
    }

    var body: some View {
        HStack {
            FilmPoster(film: film)
                .frame(width: 60, height: 80)
            FilmMetadata(film: film)
            Spacer()
            if isSelected {
                Image(systemName: "checkmark.circle.fill")
                    .foregroundStyle(.accentColor)
            }
        }
        .contentShape(Rectangle())
    }
}

// Wrap at the call site to enable the optimization
FilmRow(film: film, isSelected: selectedFilm?.id == film.id)
    .equatable()

Warning: Equatable conformance must be correct. A flawed == that returns true too aggressively will cause stale UI — the view won’t update when it should. Only use this optimization for views where you can guarantee the equality logic is complete.

@Observable Fine-Grained Tracking

@available(iOS 17.0, *)

Apple Docs: @Observable — Observation framework

The ObservableObject + @Published model broadcasts a single notification whenever any property changes. SwiftUI re-evaluates every view that accessed the object, regardless of which property that view actually read.

@Observable (iOS 17+) tracks access at the property level. SwiftUI only re-evaluates a view when a property that was actually read during the last evaluation changes.

// ❌ ObservableObject: changing `isLoading` re-evaluates FilmListContent
// even though it never reads `isLoading`
class FilmLibraryViewModel: ObservableObject {
    @Published var films: [PixarFilm] = []
    @Published var isLoading = false
    @Published var currentUserName = ""
}

// ✅ @Observable: SwiftUI tracks which properties each view reads
@available(iOS 17.0, *)
@Observable
class FilmLibraryViewModel {
    var films: [PixarFilm] = []
    // only views reading `isLoading` re-evaluate when this changes
    var isLoading = false
    // only views reading `currentUserName` re-evaluate
    var currentUserName = ""
}

With @Observable, FilmListContent subscribes only to films. Changing isLoading or currentUserName doesn’t touch it. The cascade redraw problem is eliminated at the framework level.

Note: @Observable requires iOS 17+. For earlier deployment targets, split your ObservableObject into smaller objects with narrower responsibility — one for the film list state, one for user session state — so each change broadcasts only to the views that care.

Lazy Containers

Apple Docs: LazyVStack — SwiftUI

VStack and HStack eagerly evaluate and render all their children. For a list of 200 Pixar films, this means 200 FilmRow bodies are computed during the initial layout pass, even if only 12 rows are visible.

LazyVStack and LazyVGrid defer row creation until the row is about to appear on screen:

// ❌ VStack inside ScrollView evaluates all 200 rows immediately
ScrollView {
    VStack {
        ForEach(films) { film in
            FilmRow(film: film)
        }
    }
}

// ✅ LazyVStack only evaluates rows as they scroll into view
ScrollView {
    LazyVStack(spacing: 0) {
        ForEach(films) { film in
            FilmRow(film: film)
        }
    }
}

Tip: List is already lazy and provides built-in row recycling. Prefer List over ScrollView + LazyVStack when you need native list behaviors (swipe actions, reorder, separators). Use LazyVStack inside ScrollView when you need custom layout or want to avoid List’s extra chrome.

The id Modifier and Its Cost

Apple Docs: id(_:) — SwiftUI

The id(_:) modifier explicitly sets the value SwiftUI uses for structural identity. When the value changes, SwiftUI treats the view as a new view — it destroys the old instance and creates a fresh one.

This is useful for forcing a full reset (e.g., restarting a video player after selecting a new film), but it’s frequently misused as a way to “force refresh” views that have stale state:

// ❌ Using id() to force refresh — creates a new view on every render,
// losing all animation state and triggering a full layout.
FilmDetailView(film: film)
    .id(UUID()) // DON'T: new UUID() every render = new view every render
// ✅ Use id() intentionally with a stable, meaningful value
FilmDetailView(film: film)
    .id(film.id) // resets the view only when the selected film changes

Every time id changes, SwiftUI inserts a new view into the hierarchy. All @State resets to initial values, all animations restart from zero, and any focused state is lost. The performance cost is equivalent to removing the view and inserting a new one — which is exactly what’s happening.

drawingGroup() and compositingGroup()

Apple Docs: drawingGroup(opaque:colorMode:) — SwiftUI

When a view subtree contains many overlapping transparent layers — a particle effect for Pixar character confetti, a badge stack, or a complex gradient overlay — CoreAnimation composites each layer individually. For deeply nested transparent views, this can saturate the GPU compositing pass.

drawingGroup() flattens the subtree into a single offscreen Metal texture before the final compositing step:

// A confetti burst for awarding Pixar Oscar films
// 50 overlapping emoji views with opacity animations
ConfettiBurstView(emoji: ["🏆", "⭐️", "🎬"])
    .drawingGroup() // renders all children to one texture, composites once

drawingGroup() trades memory (the offscreen texture) for GPU compositing operations. It helps when you have many overlapping transparent children; it hurts when the content is simple, because the texture allocation and rasterization pass add overhead that outweighs the compositing savings.

compositingGroup() is the lighter-weight sibling — it groups the view’s children into a single layer for the purposes of opacity and blendMode application without necessarily forcing Metal rasterization:

// Applies a single opacity to the whole badge stack, not to each badge
BadgeStack(awards: film.awards)
    .compositingGroup()
    .opacity(film.isHighlighted ? 1.0 : 0.6)

Advanced Usage

Canvas for High-Performance Custom Drawing

Apple Docs: Canvas — SwiftUI

Canvas bypasses SwiftUI’s view diffing entirely and lets you draw directly into a GraphicsContext using CoreGraphics-style commands. It’s the right tool for visualizations with hundreds or thousands of elements — a film timeline, a ratings chart, or the star map for a WALL-E-themed astronomy app:

struct FilmTimelineView: View {
    let films: [PixarFilm]

    var body: some View {
        Canvas { context, size in
            let barWidth = size.width / CGFloat(films.count)

            for (index, film) in films.enumerated() {
                let x = CGFloat(index) * barWidth
                let barHeight = size.height * film.audienceScore
                let rect = CGRect(
                    x: x,
                    y: size.height - barHeight,
                    width: barWidth - 2,
                    height: barHeight
                )
                context.fill(Path(rect), with: .color(.accentColor))
            }
        }
        .frame(height: 200)
    }
}

Canvas is not interactive by default — taps don’t route to individual elements. Use Canvas for read-only visualizations and add overlay views or gesture handling for interactivity.

Moving Expensive Computations Off the Main Thread

View bodies run on the main thread. Any computation inside body blocks the render loop. Filter and sort operations on large arrays are common culprits:

// ❌ Filtering 5,000 films inside body — runs synchronously on main thread
var filteredFilms: [PixarFilm] {
    films
        .filter { $0.title.localizedCaseInsensitiveContains(searchQuery) }
        .sorted { $0.releaseDate > $1.releaseDate }
}

Move the computation to a background task and cache the result:

@available(iOS 17.0, *)
@Observable
class FilmSearchViewModel {
    var films: [PixarFilm] = []
    var searchQuery: String = "" {
        didSet { scheduleSearch() }
    }
    private(set) var filteredFilms: [PixarFilm] = []

    private func scheduleSearch() {
        let query = searchQuery
        let allFilms = films
        Task.detached(priority: .userInitiated) {
            let results = allFilms
                .filter { $0.title.localizedCaseInsensitiveContains(query) }
                .sorted { $0.releaseDate > $1.releaseDate }
            await MainActor.run {
                self.filteredFilms = results
            }
        }
    }
}

The view body now reads filteredFilms, which is pre-computed. The body evaluation is O(1) — just rendering the already-filtered array.

Instruments: Hang Detection

Xcode 15’s Instruments includes a Hang Detection instrument that flags frames where the main thread was blocked for longer than 250ms. This complements the SwiftUI instrument by surfacing synchronous work (disk I/O, unoptimized computation, UserDefaults reads on main thread) that causes visible hitches during animation.

Profile your app under realistic conditions — a full film library loaded, network requests in flight, animations running — to catch hangs that don’t appear in Simulator.

When to Use (and When Not To)

ScenarioRecommendation
Determining what triggered a redraw_printChanges() in debug builds
Expensive body receiving the same inputs.equatable() with correct ==
iOS 17+ app with complex shared state@Observable for fine-grained tracking
Pre-iOS 17 broadcast redraw problemsSplit ObservableObject into focused objects
Many children that should defer renderingLazyVStack/LazyHStack in ScrollView
Views that should fully reset on selection changeid() with a stable meaningful value
Many overlapping transparent animated viewsdrawingGroup()
Hundreds of data-driven elementsCanvas to bypass the view hierarchy
Expensive filter/sort on large arraysBackground Task, cache result in state
Conditional appearanceParameterized modifier, not if/else

Summary

  • Add Self._printChanges() to any view body to log what triggered a redraw. Remove it before shipping.
  • Structural identity — the view’s position in the tree — determines whether SwiftUI destroys and recreates a view or updates it in place. Prefer conditional modifiers over if/else branches for appearance changes.
  • Subview extraction is the primary optimization tool: extracted structs allow SwiftUI to skip body evaluation when inputs haven’t changed.
  • @Observable (iOS 17+) tracks property-level access, eliminating the cascade redraw problem inherent in ObservableObject. For iOS 16 targets, narrow ObservableObject scope instead.
  • LazyVStack/LazyVGrid defer body evaluation to scroll-time. List provides built-in recycling. Use Canvas when the element count makes the SwiftUI view tree itself the bottleneck.
  • drawingGroup() offloads many-transparent-layer compositing to Metal. id() forces full view replacement — use it intentionally, never as a refresh hack.

With redraws minimized, the next layer of refinement is polishing your animations so they stay smooth under the conditions you’ve now optimized for. See SwiftUI Animations: Implicit, Explicit, Matched Geometry, and Custom Transitions for the full animation toolkit.