Sixty frames per second is not a goal. It is the baseline contract between your app and everyone who paid for the device running it. When a List or LazyVStack drops frames on scroll, you have broken that contract — and in my experience, the cause is almost always one of five things.
None of them require switching to UICollectionView. Here's what to check and how to fix each one.
Before you profile: measure first
Open Instruments. Use the SwiftUI profiling template — it shows you which view bodies re-computed on each render pass. Before you change any code, identify whether you have too many renders, renders that take too long, or both. Fixing the wrong problem wastes time.
The metric that matters is “Committed View Updates” in the SwiftUI instrument. If a row view re-computes on every scroll tick, that is your root cause. If it computes rarely but each computation takes 5ms, your problem is in the body itself.
Cause 1: Identity instability
SwiftUI's diffing algorithm identifies views by their position in the hierarchy and their explicit id. If you iterate a collection with ForEach and the element doesn't conform to Identifiable, SwiftUI falls back to positional identity — meaning any reorder, insert, or delete causes every row to re-render.
// Bad: positional identity — any change redraws all rows
ForEach(apps, id: .self) { app in
AppRow(app: app)
}
// Good: stable identity — only changed rows re-render
ForEach(apps) { app in // App conforms to Identifiable
AppRow(app: app)
}The fix is simple — make your model conform to Identifiable with a stable, unique id. If you're using a UUID generated at model creation time, that's fine. If you're deriving IDs from mutable properties, you will get unnecessary re-renders whenever those properties change.
Cause 2: Fat view bodies
SwiftUI re-computes a view's body whenever its input state changes. If your row view subscribes to a large @ObservableObject (or uses @Observable in iOS 17+), it re-computes on every property change of that object — even properties the row doesn't use.
// Bad: row subscribes to the whole store, re-renders on any store change
struct AppRow: View {
@EnvironmentObject var store: AppStore
let appId: UUID
var body: some View {
Text(store.apps[appId]?.name ?? "") // Only uses name
}
}
// Good: pass only what the row needs
struct AppRow: View {
let name: String // Primitive — only re-renders when name changes
var body: some View {
Text(name)
}
}Passing primitives (or small structs) to row views is the single most effective performance optimisation for SwiftUI lists. If a row only displays a name and an icon, it should receive a String and an Image, not the full model object.
Cause 3: Image loading on the main thread
Images are the most common cause of scroll stutter that doesn't appear in a SwiftUI instrument trace. The bottleneck isn't view re-computation — it's JPEG/PNG decompression happening synchronously as the row comes into view.
For remote images, always decode off the main thread. For local images, use Image(uiImage:) with a UIImage loaded asynchronously:
struct AsyncAppImage: View {
let url: URL
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Color.gray.opacity(0.15)
}
}
.task {
guard image == nil else { return }
image = await loadImage(url: url)
}
}
}
private func loadImage(url: URL) async -> UIImage? {
// Decode on a background thread via async/await
return await Task.detached(priority: .userInitiated) {
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}.value
}For app screenshots or asset catalogue images, use UIImage(named:) in a background task and cache the result. The main thread should receive a pre-decoded UIImage, never raw data.
Cause 4: List vs LazyVStack
List and LazyVStack are not equivalent. List has built-in recycling — it reuses row views as they scroll off-screen, the same way UITableView recycles cells. LazyVStack does not recycle: it creates a new view for each row as it enters the viewport and destroys it when it exits.
For large datasets (1,000+ rows), List uses significantly less memory. For small datasets (<50 rows), the difference is negligible. Where LazyVStack wins is customisation: you control separators, backgrounds, and swipe actions directly without fighting List's UIKit-backed defaults.
Rule of thumb: use List when the row count is large or unbounded. Use LazyVStack inside a ScrollView when you need full control of the visual appearance and the dataset is bounded.
Cause 5: Unnecessary @State and @StateObject
Every @State and @StateObject in a row view creates an object whose lifetime is tied to that row. If the row is destroyed and recreated as it scrolls off and back on screen (as LazyVStack does), the state resets. If you have animations, selection states, or loading flags in row-level state, they will flicker.
Keep stateful logic in the parent ViewModel, not in individual rows. A row should be as close to a pure function as possible: given an input, produce a view. Selection state, expanded/collapsed state, and loading state belong in the source of truth at the list level.
// Bad: stateful row — state resets when row is recycled
struct AppRow: View {
let app: App
@State private var isExpanded = false // Lost when row leaves viewport
// ...
}
// Good: state lifted to parent
struct AppListView: View {
@StateObject var viewModel: AppListViewModel
// viewModel.expandedIds: Set<UUID>
var body: some View {
List(viewModel.apps) { app in
AppRow(
app: app,
isExpanded: viewModel.expandedIds.contains(app.id),
onToggle: { viewModel.toggleExpanded(app.id) }
)
}
}
}The checklist
Before reaching for UIKit, run through this list:
- Identity: does every
ForEachuse a stableIdentifiableid? - View inputs: are rows receiving primitives, not whole model objects?
- Images: is image decompression happening off the main thread?
- Container choice: is
ListorLazyVStackthe right choice for the dataset size? - State placement: is per-row state actually necessary, or can it move to the parent ViewModel?
In practice, fixing causes 1 and 2 — identity instability and fat view bodies — resolves scroll stutter in the majority of cases. Profile first, fix the confirmed bottleneck, measure again. SwiftUI's instrument tooling is good enough that you should never be guessing.