# How to Debug Janky Scrolling in SwiftUI Lists and Grids: A Complete Diagnostic Guide

> Diagnose and fix janky scrolling in SwiftUI lists and grids. Discover common causes like heavy views and unstable keys for smoother UIs.

- Repository: [Thomas Ricouard/Skills](https://github.com/Dimillian/Skills)
- Tags: how-to-guide
- Published: 2026-04-01

---

**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`](https://github.com/Dimillian/Skills/blob/main/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`](https://github.com/Dimillian/Skills/blob/main/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`](https://github.com/Dimillian/Skills/blob/main/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`](https://github.com/Dimillian/Skills/blob/main/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`](https://github.com/Dimillian/Skills/blob/main/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:

```swift
@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:

```swift
@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:

```swift
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:

```swift
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`](https://github.com/Dimillian/Skills/blob/main/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`](https://github.com/Dimillian/Skills/blob/main/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`](https://github.com/Dimillian/Skills/blob/main/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.