SwiftUI lists that don’t lag: identity, diffing, and avoiding layout thrash
Most SwiftUI list “performance problems” are self-inflicted: unstable identity, accidental view churn, and heavy layout work in rows. Here’s how to make lists fast, measurable, and boring.
SwiftUI lists don’t mysteriously lag. They lag when SwiftUI is forced to rebuild and re-layout far more than you think.
In practice, most “my List is slow” bugs come from three root causes:
- Unstable identity: rows look “new” every update, so SwiftUI throws away reuse.
- Accidental churn: you recreate models/views in ways that defeat diffing.
- Layout thrash: the row is doing expensive measurement (often repeatedly).
This post is a practical, measurable playbook to make lists fast again.
Mental model: SwiftUI is a diff engine
SwiftUI doesn’t render a list the way UIKit does. You describe a tree of values (View structs). SwiftUI then:
- computes a new tree on every state change
- diffs it against the previous tree
- updates the UI using the smallest set of changes it can prove are correct
The keyword is prove.
If you remove SwiftUI’s ability to match “old row A” to “new row A”, it can’t apply a minimal update. It will rebuild more, animate weirdly, scroll-jank, and generally make you question your career choices.
So the goal is simple:
- make identity stable
- make updates small
- make rows cheap to measure
1) Identity: the #1 list performance footgun
Bad: generating IDs in the view
If you do this, you are telling SwiftUI “every row is a new row every time”:
struct RowModel: Identifiable {
let id = UUID() // ❌ changes every init
let title: String
}
struct Screen: View {
let titles: [String]
var body: some View {
List {
ForEach(titles.map { RowModel(title: $0) }) { row in
Text(row.title)
}
}
}
}
Every body recomputation recreates RowModel, which recreates UUID(). Diffing loses.
Fix: IDs must come from the data source (database primary key, server ID, stable hash) and survive view recomputation.
Good: stable IDs from the source of truth
struct Item: Identifiable, Hashable {
let id: String // stable
var title: String
}
struct Screen: View {
@State private var items: [Item]
var body: some View {
List(items) { item in
Text(item.title)
}
}
}
Now SwiftUI can say: “Item(id: 42) existed before and still exists now. Update only what changed.”
Avoid id: \.self unless you really mean it
This is a common trap:
ForEach(items, id: \.self) { item in
Row(item: item)
}
It is only safe when:
Itemis a value type with a stableHashableimplementation- its hash doesn’t change when editable fields change
If hashValue changes, identity changes. SwiftUI will treat the row as removed + inserted. You’ll see:
- scroll position jumps
- animations resetting
- focus and text fields losing state
If you have a real ID, use it:
ForEach(items, id: \.id) { item in
Row(item: item)
}
Don’t use array index as identity
Index identity breaks as soon as you insert, delete, or reorder.
ForEach(items.indices, id: \.self) { i in
Row(item: items[i])
}
You’ll get “wrong row updated” bugs and jank.
If you need the index for UI (e.g. separators, styling), compute it without using it as identity:
ForEach(Array(items.enumerated()), id: \.(element.id)) { index, item in
Row(item: item, index: index)
}
2) Diffing: help SwiftUI do less work
Prefer List(items) or ForEach(items) over manual mapping
This is subtle but important:
List(items)usesitemsdirectly.List(items.map(...))creates a fresh array every time.
Creating a new array is not automatically bad, but it often coincides with unstable identity and extra allocations.
If you need presentation-only formatting, push it into the row (cheaply) or precompute it once in the model.
Keep per-row state keyed by ID
If rows have local UI state (expanded/collapsed, selection, inline editing), do not store it “by index”.
Bad (breaks on reordering):
@State private var expanded: [Bool] // ❌ index-based
Better:
@State private var expandedByID: [Item.ID: Bool] = [:]
Then read/write using item.id.
Use explicit equatability when your row is expensive
SwiftUI will re-run body a lot. That’s normal.
But you can reduce actual updates by making a row equatable when it’s truly driven by a small set of fields:
struct ItemRow: View, Equatable {
let id: String
let title: String
let subtitle: String
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id && lhs.title == rhs.title && lhs.subtitle == rhs.subtitle
}
var body: some View {
VStack(alignment: .leading) {
Text(title)
Text(subtitle).foregroundStyle(.secondary)
}
}
}
List(items) { item in
ItemRow(id: item.id, title: item.title, subtitle: item.subtitle)
}
This doesn’t magically eliminate all work, but it can prevent unnecessary updates when parent state changes for unrelated reasons.
Use this sparingly. If you lie in ==, SwiftUI will cache the lie and your UI will be wrong.
3) Layout thrash: make rows cheap to measure
Most scroll jank happens during layout:
- dynamic text measurement
- nested stacks with flexible sizing
- async images changing size late
- geometry readers forcing re-layout
Rule: don’t do heavy work in body
If you do any of these in a row, you’re paying per-row, per-update:
DateFormatter()creation- expensive string formatting
- JSON decoding
- image decoding / resizing without constraints
Do the boring thing:
- keep formatters static
- precompute derived strings in the model or a view model
- constrain images (fixed frame) so layout can be fast
Example:
enum Formatters {
static let relative: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f
}()
}
Constrain height when you can
If every row is a different height and depends on async content, SwiftUI has to keep re-measuring.
If your design allows it, give rows predictable bounds:
Row(item: item)
.frame(minHeight: 44)
Or constrain text:
Text(item.title)
.lineLimit(1)
This is not “premature optimization”. This is telling the layout system what you already know.
Watch out for GeometryReader in rows
GeometryReader is often the start of a self-inflicted feedback loop:
- row depends on available width
- width depends on layout
- layout invalidates, measurement repeats
If you need sizes for effects, prefer:
- fixed sizes
- container-level geometry, not per-row
containerRelativeFrame/layoutValuepatterns where possible
4) List vs ScrollView+LazyVStack
List is optimized, but it’s also opinionated.
If you need custom row separators, sticky headers, or complex nested scroll effects, you may reach for:
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(items) { item in
Row(item: item)
}
}
}
This can be great, but don’t assume it’s automatically faster.
The same identity/diffing rules apply. The same row layout costs apply.
Use List when you can. Switch only when you have a concrete need.
5) Measure like an adult
Before you “optimize”, answer two questions:
- Is it layout or data churn?
- Is it CPU or main-thread blocking?
Practical tools:
- Instruments → Time Profiler: find the hot functions when scrolling.
- Instruments → SwiftUI template: see view updates and body evaluations.
- Logging signposts around data refresh: confirm how often your model changes.
A useful pattern is to log when your data source actually changes (network refresh, diff application). If the data changes 10× per second, your list is doing exactly what you told it.
Checklist: the boring fixes that work
If your list lags, do this in order:
- Verify stable identity (
Identifiablewith stableid). NoUUID()in views. - Stop using indexes as IDs.
- Make data updates granular (edit one item, don’t rebuild the whole array).
- Make rows cheap (no heavy formatting/allocations in
body). - Constrain layout (line limits, fixed frames where possible).
- Measure again. If it’s still slow, now you earn the right to get fancy.
SwiftUI can be fast. You just have to stop gaslighting the diff engine.