List Performance in SwiftUI
SwiftUI's List is built on UIKit's UITableView (or UICollectionView on newer OS versions) under the hood, which means it already benefits from cell reuse. But that doesn't make it immune to jank. Performance problems in List usually come from three places: identity churn that forces SwiftUI to throw away and rebuild rows instead of updating them, expensive work happening inside row bodies on every render, and eager evaluation of data that should be computed lazily. Understanding how List diffs its content using Identifiable or explicit id(_:) is the starting point for diagnosing slow lists.
Cricket analogy: A stadium reuses the same physical seats for every match like cell reuse, but if the scoreboard misidentifies which fan holds which ticket each over, ushers keep reseating everyone unnecessarily, causing chaos in the stands.
Stable Identity Is Everything
SwiftUI decides whether a row is 'the same view, just updated' or 'a brand new view' based on identity, not equality of content. If your ForEach uses array indices as IDs, or if your model's id changes when unrelated properties change (for example, a computed id derived from a mutable field), SwiftUI will misattribute rows, causing unnecessary teardown/rebuild cycles, lost @State, and visible flicker during scrolling or updates. Always prefer a stable, unique identifier — a UUID or a database primary key — that never changes for the lifetime of that logical item.
Cricket analogy: If a scorer tracks batsmen by position in the batting order instead of their name, a mid-innings retirement-hurt substitution confuses the whole scoreboard, wiping accumulated stats instead of just updating the new batsman's line.
struct Message: Identifiable {
let id: UUID
var text: String
var isRead: Bool
}
struct InboxView: View {
@State private var messages: [Message] = MessageStore.sampleMessages
var body: some View {
List(messages) { message in
MessageRow(message: message)
}
.listStyle(.plain)
}
}
struct MessageRow: View {
let message: Message
var body: some View {
HStack {
Circle()
.fill(message.isRead ? Color.clear : Color.blue)
.frame(width: 8, height: 8)
Text(message.text)
.lineLimit(2)
Spacer()
}
}
}Keep Row Bodies Cheap
Every property access inside a row's body re-executes on each render pass SwiftUI performs for that row, so expensive formatting, image decoding, or date math should be precomputed and stored on the model, not recalculated inline. Extracting each row into its own small View struct (as MessageRow above) also matters: it lets SwiftUI diff and update that subview independently, rather than re-evaluating a monolithic closure body for the entire list every time any single row changes.
Cricket analogy: Recalculating a batsman's full career strike rate from raw ball-by-ball data every time the scoreboard refreshes is wasteful; store the running average and just update it, and give each batsman's tile its own mini-scoreboard so only that tile redraws.
A common pitfall is loading and decoding images synchronously inside a row body, or performing a full-collection filter/sort computation directly in the List's content closure. Both of these run repeatedly as rows scroll on and off screen, causing dropped frames. Use AsyncImage or a caching image loader, and precompute filtered/sorted arrays outside of body.
LazyVStack vs. List
List gives you native cell recycling, section headers, swipe actions, and platform-correct styling for free, and for most data-driven, scrollable content it is the right default. A ScrollView with LazyVStack is more appropriate when you need full control over the visual layout — for example a custom grid-like feed, or when you don't want List's built-in insets, separators, and selection chrome. Both are lazy: they only materialize views for rows near the visible viewport, so the real performance lever is what happens inside each row, not which container you pick.
Cricket analogy: A standard stadium (List) gives you built-in turnstiles, seating charts, and stewarding for free; a pop-up ground on a village field (LazyVStack) gives full control over layout for a custom format, but either way the real bottleneck is how fast each gate processes a fan, not which venue you pick.
Instruments' SwiftUI and Core Animation templates can show you exactly how many times a row body is evaluated per second while scrolling. If you see a row re-rendering far more often than the data actually changes, suspect identity issues or a piece of @State/@ObservedObject scoped too broadly.
- List reuses underlying platform cells, but SwiftUI still diffs rows by identity, so unstable or duplicate IDs cause unnecessary rebuilds.
- Prefer Identifiable models with a truly stable id (UUID or database key) over array-index-based identity.
- Extract row content into dedicated View structs so SwiftUI can diff and update rows independently.
- Avoid doing expensive work (image decoding, filtering, formatting) inside a row's body; precompute and store it on the model.
- List and LazyVStack are both lazy about materializing offscreen content; choose based on desired chrome, not raw performance.
- Use Instruments to measure actual row re-render frequency rather than guessing at the source of jank.
Practice what you learned
1. Why does using array indices as List row IDs commonly cause performance problems when the underlying array is mutated?
2. What is the primary performance benefit of extracting a List row's content into its own dedicated View struct?
3. Which of these is the best practice for displaying a computed, expensive string (like a formatted currency value) in a list row?
4. What does List provide over a plain ScrollView with LazyVStack for the same data set?
5. How can you empirically confirm that a specific SwiftUI list row is re-rendering more often than its data actually changes?
Was this page helpful?
You May Also Like
List and ForEach
Learn how List renders scrollable, platform-styled collections and how ForEach generates repeated views from a data collection using stable identity.
LazyVStack and LazyHStack
Understand how LazyVStack and LazyHStack defer creation of off-screen views inside a ScrollView, enabling efficient custom scrolling layouts.
@State and @Binding
Understand how @State owns local view state and how @Binding lets child views read and write that state without owning it themselves.
NavigationStack Basics
Understand how NavigationStack replaced NavigationView, how navigationDestination drives push navigation, and how to structure hierarchical screens in SwiftUI.