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
- Measuring Redraws
- Structural Identity vs. Value Identity
- Extract Subviews
- Equatable Views
@ObservableFine-Grained Tracking- Lazy Containers
- The
idModifier and Its Cost drawingGroup()andcompositingGroup()- Advanced Usage
- When to Use (and When Not To)
- Summary
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:
- Open your app in Xcode and choose Product → Profile (⌘I).
- Select the SwiftUI template.
- Record while performing the interaction that feels slow.
- 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:
Equatableconformance must be correct. A flawed==that returnstruetoo 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:
@Observablerequires iOS 17+. For earlier deployment targets, split yourObservableObjectinto 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:
Listis already lazy and provides built-in row recycling. PreferListoverScrollView + LazyVStackwhen you need native list behaviors (swipe actions, reorder, separators). UseLazyVStackinsideScrollViewwhen you need custom layout or want to avoidList’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)
| Scenario | Recommendation |
|---|---|
| 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 problems | Split ObservableObject into focused objects |
| Many children that should defer rendering | LazyVStack/LazyHStack in ScrollView |
| Views that should fully reset on selection change | id() with a stable meaningful value |
| Many overlapping transparent animated views | drawingGroup() |
| Hundreds of data-driven elements | Canvas to bypass the view hierarchy |
| Expensive filter/sort on large arrays | Background Task, cache result in state |
| Conditional appearance | Parameterized 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/elsebranches 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 inObservableObject. For iOS 16 targets, narrowObservableObjectscope instead.LazyVStack/LazyVGriddefer body evaluation to scroll-time.Listprovides built-in recycling. UseCanvaswhen 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.