SwiftUI
ScrollView
LazyVStack
LazyHStack
performance issues

Putting a LazyVStack or LazyHStack in a ScrollView causes stuttering

Data Structures & Algorithms practice on Codemia

Step through 300 algorithm problems with animated visualisers that show the data structure changing as the code runs.

Practice algorithms

Introduction

Stuttering when using LazyVStack or LazyHStack inside a ScrollView is caused by on-demand view creation — as you scroll, SwiftUI creates new cells just-in-time, and if that creation is expensive (complex layouts, image loading, state initialization), it causes frame drops. The primary fixes are to simplify cell views, pre-load images asynchronously, avoid heavy onAppear work, and use fixed-size cells with LazyVStack(spacing:pinnedViews:) so SwiftUI can skip measuring. For large datasets, List (backed by UITableView) often performs better than ScrollView + LazyVStack.

Why Stuttering Happens

swift
1// This stutters with large datasets
2ScrollView {
3    LazyVStack {
4        ForEach(0..<10000, id: \.self) { index in
5            ExpensiveCell(item: items[index])
6        }
7    }
8}

The timeline of a stuttering frame:

  1. User scrolls down
  2. SwiftUI determines new cells are needed
  3. LazyVStack creates the cell view body (layout, modifiers, etc.)
  4. SwiftUI measures the cell (calls sizeThatFits)
  5. SwiftUI renders the cell

If steps 3-5 take more than ~16ms (60 FPS) or ~8ms (120 FPS on ProMotion), the frame drops and scrolling stutters.

Fix 1: Simplify Cell Views

Reduce the complexity of each cell:

swift
1// SLOW — too many nested views and modifiers per cell
2struct ExpensiveCell: View {
3    let item: Item
4
5    var body: some View {
6        HStack {
7            AsyncImage(url: item.imageURL)  // Triggers network request
8                .frame(width: 60, height: 60)
9                .clipShape(Circle())
10                .shadow(radius: 5)
11            VStack(alignment: .leading) {
12                Text(item.title).font(.headline)
13                Text(item.subtitle).font(.subheadline)
14                Text(dateFormatter.string(from: item.date)).font(.caption)
15            }
16            Spacer()
17            Image(systemName: "chevron.right")
18        }
19        .padding()
20        .background(RoundedRectangle(cornerRadius: 12).fill(.ultraThinMaterial))
21    }
22}
23
24// FASTER — fewer modifiers, simpler layout
25struct SimpleCell: View {
26    let item: Item
27
28    var body: some View {
29        HStack {
30            CachedImageView(url: item.imageURL)  // Pre-cached image
31                .frame(width: 60, height: 60)
32            VStack(alignment: .leading) {
33                Text(item.title).font(.headline)
34                Text(item.subtitle).font(.subheadline)
35            }
36            Spacer()
37        }
38        .padding()
39    }
40}

Fix 2: Use Fixed-Size Cells

When all cells have the same height, SwiftUI can skip measurement:

swift
1ScrollView {
2    LazyVStack(spacing: 0) {
3        ForEach(items) { item in
4            CellView(item: item)
5                .frame(height: 80)  // Fixed height — no measurement needed
6        }
7    }
8}

Fix 3: Cache Images Properly

AsyncImage starts a new network request each time the cell appears:

swift
1// Use a cached image loader instead of AsyncImage
2class ImageCache: ObservableObject {
3    static let shared = ImageCache()
4    private var cache = NSCache<NSString, UIImage>()
5
6    func image(for url: URL) -> UIImage? {
7        cache.object(forKey: url.absoluteString as NSString)
8    }
9
10    func store(_ image: UIImage, for url: URL) {
11        cache.setObject(image, forKey: url.absoluteString as NSString)
12    }
13}
14
15struct CachedImageView: View {
16    let url: URL
17    @State private var image: UIImage?
18
19    var body: some View {
20        Group {
21            if let image {
22                Image(uiImage: image)
23                    .resizable()
24                    .aspectRatio(contentMode: .fill)
25            } else {
26                Color.gray  // Placeholder
27            }
28        }
29        .task {
30            if let cached = ImageCache.shared.image(for: url) {
31                image = cached
32                return
33            }
34            // Load in background
35            if let (data, _) = try? await URLSession.shared.data(from: url),
36               let loaded = UIImage(data: data) {
37                ImageCache.shared.store(loaded, for: url)
38                image = loaded
39            }
40        }
41    }
42}

Fix 4: Use List Instead of ScrollView + LazyVStack

List uses UITableView under the hood with cell recycling, which is often smoother:

swift
1// Better performance for long lists
2List(items) { item in
3    CellView(item: item)
4}
5.listStyle(.plain)
6
7// If you need custom styling without List chrome
8List {
9    ForEach(items) { item in
10        CellView(item: item)
11            .listRowInsets(EdgeInsets())
12            .listRowSeparator(.hidden)
13    }
14}
15.listStyle(.plain)

Fix 5: Prefetch Data

Load data before cells appear:

swift
1ScrollView {
2    LazyVStack {
3        ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
4            CellView(item: item)
5                .onAppear {
6                    // Prefetch next batch when near the end
7                    if index == items.count - 10 {
8                        viewModel.loadNextPage()
9                    }
10                }
11        }
12    }
13}

Fix 6: Avoid State Initialization in Cells

swift
1// SLOW — creates a new view model per cell on every scroll
2struct BadCell: View {
3    let item: Item
4    @StateObject var cellViewModel = CellViewModel()  // Expensive init
5
6    var body: some View {
7        Text(cellViewModel.processedTitle)
8    }
9}
10
11// FASTER — precompute data outside the cell
12struct GoodCell: View {
13    let title: String  // Pre-processed by parent
14
15    var body: some View {
16        Text(title)
17    }
18}

Debugging Performance

Use Instruments to identify the bottleneck:

swift
1// Add os_signpost for custom profiling
2import os
3
4let log = OSLog(subsystem: "com.app", category: "scrolling")
5
6struct ProfilingCell: View {
7    let item: Item
8
9    var body: some View {
10        let _ = os_signpost(.event, log: log, name: "CellCreated", "%{public}s", item.id)
11        CellContent(item: item)
12    }
13}
14
15// In Xcode: Profile → Time Profiler → check "Record Thread States"
16// Look for main thread hangs during scrolling

Common Pitfalls

  • Using AsyncImage inside lazy stacks: AsyncImage starts a new download every time a cell appears during scrolling. It does not cache images between appearances. Use a dedicated image caching library (Kingfisher, SDWebImage, or a custom NSCache-backed loader) for smooth scrolling.
  • Heavy onAppear closures: Code in onAppear runs on the main thread when the cell is created. Network requests, database queries, or complex computations in onAppear cause frame drops. Move heavy work to a background task with .task {} (async) or dispatch to a background queue.
  • Variable cell heights without explicit frames: When cells have dynamic heights, LazyVStack must measure each one by calling the body. This measurement cost adds up during fast scrolling. Use .frame(height:) for fixed-height cells or pre-calculate heights when possible.
  • Too many view modifiers per cell: Each modifier (.shadow(), .clipShape(), .background(), .overlay()) adds a layer to the view hierarchy. Dozens of modifiers per cell multiply the rendering cost. Consolidate modifiers and avoid expensive effects like .blur() or .shadow() in scrolling cells.
  • Not using id correctly in ForEach: Without a stable id, SwiftUI may recreate views unnecessarily during scrolling. Use \.self only for simple value types. For model objects, conform to Identifiable with a stable id property so SwiftUI can reuse existing views.

Summary

  • Stuttering is caused by expensive on-demand view creation during scrolling
  • Simplify cell views, use fixed heights, and cache images to reduce per-cell cost
  • Prefer List over ScrollView + LazyVStack for large datasets (cell recycling)
  • Move heavy work out of onAppear and into background tasks with .task {}
  • Profile with Instruments (Time Profiler) to identify which cells or modifiers are causing frame drops

Related reading
Course
Intermediate
27 lessons
15 hours
DSA Fundamentals

Master algorithmic patterns and data structures through hands-on LeetCode-style problems - from arrays and hashing to dynamic programming and advanced graphs.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Data Structures & Algorithms practice on Codemia

Step through 300 algorithm problems with animated visualisers that show the data structure changing as the code runs.

Practice algorithms

All Rights Reserved.