SwiftUI Long-List Performance: Identity, Laziness, and Fewer Invalidations
There is no universal modifier that fixes a slow SwiftUI list. A hitch can come from unstable identity, a state change invalidating too many rows, expensive work in body, image decoding on the main thread, or the layout structure itself. A dependable process creates a reproducible scenario, uses Instruments to inspect causes, and changes one variable at a time. This article deliberately avoids a context-free “percentage faster,” because device, data, build mode, and interaction determine what a number means.
Establish a measurement scenario
Choose a real supported device and a Release or release-like build. Prepare a fixed data set and record whether the run starts cold or warm, the scroll gesture, image-cache state, OS version, and tools used. Repeat the same operation and consider the central trend plus obvious outliers. One smooth pass is not proof, and one hitch is not a diagnosis.
The fixture should represent the actual shape of data: short and long titles, missing images, different text sizes, and items that update frequently. Optimizing one hundred thousand synthetic rows has little value if the product has two hundred complex records. Ten demo rows cannot validate a long list either.
Start from a behavioral baseline as well. Preserve scroll position, selection, accessibility order, navigation, and animation. A change that raises throughput by removing required semantics is not an optimization.
Derive identity from the domain
SwiftUI uses identity to decide whether a view is the same element, moved, or newly inserted. A UUID generated every time a property is read, a display title that can repeat, or an array index after sorting destroys continuity. Identity belongs to a database key or another stable domain identifier:
struct FeedItem: Identifiable, Equatable {
let id: UUID
let title: String
let imageURL: URL?
let isRead: Bool
}
struct FeedView: View {
let items: [FeedItem]
var body: some View {
List(items) { item in
FeedRow(item: item)
}
}
}
Do not add .id(UUID()) to FeedRow as a way to force updates. It resets row-local state, transitions, and the framework’s understanding of continuity. An identity change should mean that the domain entity changed, not that the data flow needs a workaround.
Lazy does not mean free
List and LazyVStack delay creation of off-screen content, but visible rows still perform layout and rendering. Choose based on interaction semantics. List supplies platform row behavior, selection, swipe actions, and accessibility integration. ScrollView with LazyVStack offers freer composition. The word “Lazy” is not evidence that one is always faster.
Keep date parsing, Markdown conversion, large-image resizing, synchronous file access, and full-array filtering out of body. Compute pure derived values when their inputs change. Move file, download, and decode work off the main actor, and cancel tasks when a row is no longer needed. A cache needs capacity and invalidation; an unbounded dictionary postpones work by creating a memory problem.
Avoid starting a new unstructured task from every evaluation. Give loading an owner, coalesce identical requests, and make the result keyed by stable resource identity. Then scrolling away and back does not create a storm of duplicated work.
Narrow the invalidation surface
A broad observable store can cause many rows to be reevaluated after one property changes. Pass each row a small immutable value containing only what it needs instead of the entire application state. Do not reach immediately for EquatableView to suppress updates. First use the SwiftUI Instruments template to inspect why a body updated. Add an explicit equality boundary only when inputs truly match and reevaluation is measurably expensive.
Frequently changing values such as progress and timers should not broadcast one refresh pulse to every item. Localize the observation to the affected row, reduce update frequency to what a person can perceive, and pause work when invisible. Debouncing search input can be appropriate; delaying direct selection or deletion feedback merely to improve throughput is not.
Diagnose images separately from layout
Network latency, compressed-data transfer, image decoding, resizing, and SwiftUI layout are different stages. Instrument or signpost them separately so a “cache hit” has a precise meaning. Caching original bytes may still repeat decode work. Caching oversized rendered images can exhaust memory. Produce a representation appropriate to the display size, limit cache cost, and release reconstructible images under pressure.
Deep GeometryReader nesting, broad preference propagation, blurred shadows, and mutually dependent measurements can raise layout cost. Simplify one representative row and measure it before changing the entire application. Two visually identical layouts are not performance-equivalent until the target device shows evidence.
Keep a regression scenario
Retain the fixture and gesture as a repeatable performance case. Track interpretable signals such as first interaction, scroll hitches, main-thread work, and peak memory. Absolute timing from different CI machines is noisy, so automation is better at flagging a substantial trend than certifying final experience. Confirm a suspected regression and its fix on representative hardware.
The practical order is identity, invalidation scope, main-thread work, resource pipeline, and layout. Let the tools justify each change. Stable identity and explicit state ownership often improve correctness before they improve speed, which is a better foundation than searching for a mythical fastest list container.