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:

  1. Profile with Instruments — Open Instruments and select Time Profiler. Scroll through your list while monitoring CPU usage. Spikes in the Layout or SwiftUI categories indicate expensive layout computation.

  2. Audit body Complexity — Search your view implementations for .sorted(, .map(, Image(uiImage:, or network calls inside body. These operations execute on every state change and block the render loop.

  3. Validate Identity Keys — Grep for ForEach patterns using \.self or manual indices. If your collection supports reordering, insertion, or deletion, these patterns trigger full view rebuilds.

  4. Inspect Container Hierarchy — Look for ScrollView nested inside List or multiple ScrollView calls on the same axis. Also verify you are not using VStack or HStack where LazyVStack or LazyHStack would suffice.

  5. Verify Lazy Loading — Ensure large feeds use LazyVStack, LazyHStack, or LazyVGrid rather 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 Identifiable conformance prevents unnecessary view rebuilds when collections mutate.
  • Container selection matters: Avoid nesting scroll views on the same axis and prefer safeAreaInset over 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →