What is the difference between VStack in a ScrollView, LazyVStack and List for long collections, and what are the trade-offs?

21.4.2 · L3 · Interview

Predict first

Host 1000 rows in a VStack, a LazyVStack and a List. How many row bodies run for the eager stack, and are the lazy ones fewer than 100?

import SwiftUI
import UIKit

nonisolated(unsafe) var bodies = 0

@MainActor func host<V: View>(_ v: V) -> UIHostingController<V> {
    let h = UIHostingController(rootView: v)
    let w = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 800))
    w.rootViewController = h
    w.makeKeyAndVisible()
    h.view.layoutIfNeeded()
    RunLoop.main.run(until: Date().addingTimeInterval(0.3))
    return h
}

struct Row: View {
    let i: Int
    var body: some View { bodies += 1; return Text("row \(i)").frame(height: 44) }
}

@MainActor func run() {
    bodies = 0
    let a = host(ScrollView { VStack { ForEach(0..<1000, id: \.self) { Row(i: $0) } } })
    print("VStack", bodies)
    bodies = 0
    let b = host(ScrollView { LazyVStack { ForEach(0..<1000, id: \.self) { Row(i: $0) } } })
    print("LazyVStack under 100:", bodies < 100)
    bodies = 0
    let c = host(List(0..<1000, id: \.self) { Row(i: $0) })
    print("List under 100:", bodies < 100)
    _ = (a, b, c)
}
MainActor.assumeIsolated { run() }
  • VStack 1000, then both lazy results true
  • VStack 1000, then both lazy results false
  • VStack 100, then both lazy results true

Mental model. A VStack prints the whole book before you read page one. A lazy stack prints only the pages near the one you are on.

An ordinary VStack inside a ScrollView is eager: it creates and lays out every child up front, so a thousand rows costs a thousand view evaluations and their layout before the first frame, whether or not they are visible. LazyVStack (and LazyHStack, LazyVGrid) creates children only when they approach the visible area, so cost depends on the viewport, not the data size; its size is only known as rows are created, so the scroll indicator can jump and the stack has to measure rows to scroll to them. List also virtualises (on iOS it has typically been backed by a collection view that reuses cells, an implementation detail that can change), and adds the built-in list look, swipe actions, selection and editing, which is why long, standard lists are usually a List.

Choose by need: a few rows or something you want fully measured up front, an eager stack; a long custom-styled feed, a lazy stack or grid inside a ScrollView; a standard table with rows, sections and swipe actions, a List. For all of them, give ForEach a stable id so rows are updated rather than recreated (Q260), keep row bodies cheap, load images lazily and downsample them (Q327), and avoid wrapping the lazy container in a view that gives it infinite height, which makes it lay everything out. In UIKit the equivalent discipline is cell reuse and prefetching (Q249).

The eager stack evaluates every row. The lazy containers only build rows near the viewport, so the count stays small regardless of the data size.

Common traps

  • Putting a lazy stack inside an unbounded-height parent. It cannot know what is “off screen” and builds many rows anyway.
  • Using id: \.self for mutable values, or array indices as identity. Rows are rebuilt and animations break when the data changes.

They might follow up with

  • When is a plain VStack fine? For a short, fixed number of rows where you want exact layout.
  • Why does a LazyVStack scroll bar jump? Row heights unknown until built, so the content size is an estimate that is corrected as you scroll.

Related questions

  • 21.4.1 What makes a SwiftUI body run again, how do you see it, and how do you reduce unnecessary invalidation?
  • 7.5.4 How do prefetching and self-sizing cells keep scrolling smooth, and what can still make it janky?
  • 12.7.1 How do you design an image loading pipeline: decoding, downsampling, caching and de-duplication?
  • 8.1.2 What is structural versus explicit identity in SwiftUI, and what happens to state when identity changes?

← What makes a SwiftUI body run again, how do you see it, and how do…