How to Debug Janky Scrolling in SwiftUI Lists and Grids: A Complete Diagnostic Guide
Janky scrolling in SwiftUI lists and grids stems from heavy view hierarchies, unstable identity keys, and improper container choices that force the UI thread to recompute expensive work every frame.
Eliminating scroll stutter requires understanding the architectural constraints of SwiftUI's rendering engine. The open-source knowledge base at Dimillian/Skills provides concrete diagnostic patterns and code implementations to help you debug janky scrolling in SwiftUI lists and grids and restore butter-smooth 60 FPS performance.
The Three Root Causes of Scroll Performance Issues
Heavy View Hierarchies and Eager Layout
When every row or grid cell recomputes expensive filtering, sorting, or image decoding inside the body property, the main thread cannot maintain the target frame rate. According to the performance guardrails documented in swiftui-ui-patterns/references/performance.md, any expensive work executed within body blocks the UI thread during scroll events.
Non-Stable Identity for ForEach Items
Using indexes or changing identifiers forces SwiftUI to rebuild large portions of the list on every mutation. The guidelines in swiftui-ui-patterns/references/list.md emphasize that ForEach requires stable IDs to enable efficient diffing and view reuse. When you use id: \.self or id: \.index on collections that reorder, the framework discards and recreates views unnecessarily, causing frame-rate drops.
Improper Container Selection
Mixing List with custom ScrollView layouts or nesting scroll views on the same axis incurs extra gesture-handling overhead. The reference documentation in swiftui-ui-patterns/references/scrollview.md warns against same-axis nesting and recommends lazy containers for large collections. Using eager stacks like VStack for hundreds of rows materializes all views immediately, consuming memory and CPU cycles regardless of visibility.
Diagnostic Workflow for SwiftUI Scroll Performance
Follow this systematic approach to identify the bottleneck in your scrolling interface:
-
Profile with Instruments — Open Instruments and select Time Profiler. Scroll through your list while monitoring CPU usage. Spikes in the
LayoutorSwiftUIcategories indicate expensive layout computation. -
Audit
bodyComplexity — Search your view implementations for.sorted(,.map(,Image(uiImage:, or network calls insidebody. These operations execute on every state change and block the render loop. -
Validate Identity Keys — Grep for
ForEachpatterns using\.selfor manual indices. If your collection supports reordering, insertion, or deletion, these patterns trigger full view rebuilds. -
Inspect Container Hierarchy — Look for
ScrollViewnested insideListor multipleScrollViewcalls on the same axis. Also verify you are not usingVStackorHStackwhereLazyVStackorLazyHStackwould suffice. -
Verify Lazy Loading — Ensure large feeds use
LazyVStack,LazyHStack, orLazyVGridrather than eager stacks that render all children upfront.
Fix Strategies for Smooth 60 FPS Scrolling
Move heavy work off the main thread — Pre-compute sorted or filtered arrays in a ViewModel and expose them as read-only properties. This ensures body performs only lightweight view assembly.
Adopt lazy containers — Replace VStack and HStack with LazyVStack and LazyHStack for vertical feeds. Use LazyVGrid with adaptive columns for icon or media grids. These containers defer view creation until the row enters the visible bounds, as detailed in swiftui-ui-patterns/references/scrollview.md.
Stabilize identity keys — Ensure each model conforms to Identifiable or provide a stable id using a UUID or database primary key. Avoid index-based IDs when items can be inserted, removed, or reordered, following the patterns in swiftui-ui-patterns/references/list.md.
Separate scrolling concerns — Use ScrollViewReader for programmatic jumps, but keep it outside of List. Avoid nesting scroll views of the same axis; if you need a sticky input bar, use safeAreaInset(edge:) instead of embedding the bar inside the scroll view.
Profile repeatedly — After each optimization, re-run Instruments to confirm CPU usage drops and the frame rate stabilizes during rapid scrolling.
Practical Code Implementations
Stable IDs with List and ScrollViewReader
This implementation uses stable Identifiable conformance and pairs List with ScrollViewReader for smooth programmatic scrolling:
@MainActor
struct FeedListView: View {
@State private var items: [Post] = [] // `Post` conforms to Identifiable
@State private var scrollToId: UUID? // stable UUID for jump-to-top
var body: some View {
ScrollViewReader { proxy in
List(items) { post in // List automatically uses `post.id`
FeedRow(post: post)
}
.listStyle(.plain)
.onChange(of: scrollToId) { _, target in
if let target {
withAnimation {
proxy.scrollTo(target, anchor: .top)
}
scrollToId = nil
}
}
}
}
}
Lazy Vertical Stack with Safe Area Insets
This pattern defers row creation using LazyVStack and keeps the input bar fixed without nested scroll views:
@MainActor
struct ChatView: View {
@State private var messages: [Message] = []
@State private var scrollProxy: ScrollViewProxy?
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
ForEach(messages) { msg in
ChatBubble(message: msg)
.id(msg.id) // stable IDs
}
Color.clear.frame(height: 1).id("bottom")
}
.padding(.horizontal, 16)
}
.safeAreaInset(edge: .bottom) {
MessageInputBar()
}
.onAppear {
scrollProxy = proxy
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
}
}
Adaptive Grid for Media Collections
LazyVGrid with adaptive columns scales efficiently and materializes only visible cells:
let columns = [GridItem(.adaptive(minimum: 120), spacing: 8)]
struct PhotoGridView: View {
let photos: [Photo] // `Photo` is Identifiable
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(photos) { photo in
PhotoThumbnail(photo: photo)
.id(photo.id) // stable IDs
}
}
.padding(8)
}
}
}
Pre-computing Data Outside Body
Move expensive operations out of the view render loop to prevent frame drops:
struct SortedFeedView: View {
let unsortedItems: [Item]
// Pre-compute once, no work in `body`
private var sortedItems: [Item] {
unsortedItems.sorted { $0.date > $1.date }
}
var body: some View {
List(sortedItems) { item in
FeedRow(item: item)
}
}
}
Summary
- Heavy work belongs in models, not in
body, to keep the render loop lightweight and prevent UI thread blocking. - Lazy containers (
LazyVStack,LazyHStack,LazyVGrid) defer instantiation until views enter the viewport, essential for large datasets. - Stable identity via
Identifiableconformance prevents unnecessary view rebuilds when collections mutate. - Container selection matters: Avoid nesting scroll views on the same axis and prefer
safeAreaInsetover manual embedding for sticky UI elements. - Profile with Instruments using Time Profiler to validate fixes and ensure consistent 60 FPS performance.
Frequently Asked Questions
Why does my SwiftUI List stutter when scrolling large datasets?
Stuttering typically occurs when the UI thread blocks on expensive operations inside body or when the list lacks stable identifiers. According to swiftui-ui-patterns/references/performance.md, moving sorting, filtering, or image decoding into a view model and ensuring your data conforms to Identifiable eliminates these hitches.
How do I identify non-stable identity in ForEach?
Search your codebase for ForEach declarations using id: \.self or id: \.index. As documented in swiftui-ui-patterns/references/list.md, these patterns force SwiftUI to treat every item as new when the collection order changes, destroying and recreating views instead of reusing them. Replace these with stable database keys or UUIDs.
Should I use List or LazyVStack for infinite scrolling feeds?
Use LazyVStack inside a ScrollView for infinite feeds where you need granular control over scroll position or custom cell layouts. Use List when you require built-in features like swipe actions, reordering, or section headers. Both support lazy loading when paired with stable IDs, though LazyVStack offers more layout flexibility for complex feeds.
Can nesting ScrollView cause performance issues?
Yes. Nesting ScrollView containers on the same axis creates gesture-handling conflicts and layout thrashing. The guidelines in swiftui-ui-patterns/references/scrollview.md recommend using safeAreaInset(edge:) for fixed overlays like input bars instead of embedding them inside the scroll view, and avoiding ScrollView inside List unless absolutely necessary.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →