SwiftUI List vs LazyVStack

Every SwiftUI developer eventually hits this fork: you need to show a scrolling collection, and you can reach for List or for ScrollView + LazyVStack. Both are lazy. Both only build visible rows. So the choice looks like styling preference.

It isn't. They have genuinely different memory behavior, and the difference shows up exactly when you can least afford it - long collections on older devices.

The short version, with the reasoning below:

Use List by default. Reach for LazyVStack when you need a layout List can't express, and only after you've accepted that you're taking on memory management yourself.

Where They Differ

Both are lazy about creating rows. Only List is aggressive about releasing them.

List is backed by UICollectionView on iOS. It recycles: scroll down through a thousand rows and it keeps a bounded set of row views alive, reusing them as content moves through the viewport. Memory stays roughly flat.

LazyVStack creates views on demand as they approach the viewport, but it keeps them alive once created. Scroll through a thousand rows and you have a thousand row views in memory. Scroll back up and nothing is rebuilt, which is fast - but memory has grown monotonically and it doesn't come back down.

You can watch this directly. Put a counter in a row's initializer:

struct LandmarkRow: View {
    let landmark: Landmark

    init(landmark: Landmark) {
        self.landmark = landmark
        print("init \(landmark.name)")
    }

    var body: some View {
        HStack {
            Text(landmark.name)
            Spacer()
            Text(landmark.state).foregroundStyle(.secondary)
        }
    }
}

In a List, scrolling down and back up prints rows again on the way back - they were released and rebuilt. In a LazyVStack, the trip back up prints nothing, because they never went away.

For 50 rows, nobody cares. For 5,000 rows of image-heavy content, List holds steady while LazyVStack climbs until iOS terminates you.

What List Gives You for Free

Beyond recycling, List ships a large amount of behavior that's tedious to rebuild:

List {
    ForEach(landmarks) { landmark in
        LandmarkRow(landmark: landmark)
    }
    .onDelete { indexSet in
        landmarks.remove(atOffsets: indexSet)
    }
    .onMove { source, destination in
        landmarks.move(fromOffsets: source, toOffset: destination)
    }
}
.listStyle(.insetGrouped)
.refreshable {
    await store.reload()
}
.searchable(text: $searchTerm)

Swipe-to-delete, drag-to-reorder, pull-to-refresh, separators, section headers that stick, selection and edit mode, and correct keyboard-avoidance behavior. Plus accessibility - VoiceOver announces "row 3 of 40" in a List automatically, and doesn't in a LazyVStack unless you add it yourself.

That last point is the one people forget. A LazyVStack of tappable rows is, to VoiceOver, a pile of unrelated elements. Getting to parity takes real work with accessibilityElement and custom traits.

What LazyVStack Gives You

LazyVStack wins when you need layout freedom:

Non-uniform layouts. A feed where some items are full-bleed images, some are text cards, some are embedded video, with different spacing rules between them. List fights you here.

Precise control over spacing and insets. List applies its own row insets, separators, and background. You can strip all that off:

List {
    ForEach(items) { item in
        CustomCard(item: item)
            .listRowInsets(EdgeInsets())
            .listRowSeparator(.hidden)
            .listRowBackground(Color.clear)
    }
}
.listStyle(.plain)

But at that point you're writing four modifiers per row to defeat the container. LazyVStack starts where you wanted to end up.

Horizontal or grid layouts. LazyHStack and LazyVGrid have no List equivalent.

Custom scroll effects. Parallax headers, scroll-linked animations, and visualEffect-driven transforms compose more predictably in a ScrollView you control.

ScrollView {
    LazyVStack(spacing: 16, pinnedViews: [.sectionHeaders]) {
        Section {
            ForEach(landmarks) { landmark in
                LandmarkCard(landmark: landmark)
                    .visualEffect { content, proxy in
                        content.scaleEffect(scale(for: proxy))
                    }
            }
        } header: {
            FilterBar(selection: $filter)
        }
    }
    .padding(.horizontal)
}

The Middle Ground People Miss

You rarely have to choose between "styled List" and "hand-built LazyVStack." A List with .listStyle(.plain) and the row chrome removed gets you most of LazyVStack's visual freedom while keeping recycling and accessibility:

List(landmarks) { landmark in
    LandmarkCard(landmark: landmark)
        .listRowSeparator(.hidden)
        .listRowInsets(.init(top: 8, leading: 16, bottom: 8, trailing: 16))
        .listRowBackground(Color.clear)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)

Before you reach for LazyVStack because "List looks too much like Settings," try this. It covers a surprising fraction of custom designs, and you keep the memory behavior.

Performance Notes for LazyVStack

If you've decided LazyVStack is right, a few things make a large difference.

Give rows a stable identity. ForEach over Identifiable items is fine. ForEach(items.indices, id: \.self) is not - it breaks diffing and causes rebuilds on every insertion.

Avoid work in body. With no recycling, an expensive body is expensive forever. Move formatting and computation out:

// Bad - recomputed on every render
Text(landmark.createdAt.formatted(date: .abbreviated, time: .omitted))

// Better - computed once when the domain model is built
Text(landmark.formattedDate)

Set an explicit frame when you can. Uniform row heights let SwiftUI compute scroll geometry without measuring every row:

LazyVStack(spacing: 0) {
    ForEach(landmarks) { landmark in
        LandmarkRow(landmark: landmark)
            .frame(height: 72)
    }
}

Page your data. This is the real fix for the memory problem. If LazyVStack never holds more than a few hundred rows, its lack of recycling stops mattering:

LazyVStack {
    ForEach(landmarks) { landmark in
        LandmarkRow(landmark: landmark)
            .onAppear {
                if landmark == landmarks.last {
                    Task { await store.loadNextPage() }
                }
            }
    }
}

That said - if you're building infinite scroll with paging and recycling concerns, you're rebuilding List. Consider whether you should just use it.

A Decision Table

NeedUse
Standard vertical rowsList
Swipe actions, reorder, edit modeList
Pull to refreshList (.refreshable works on ScrollView too, but rest of the list features don't)
Sections with sticky headersEither
Thousands of rowsList
Free VoiceOver row semanticsList
Mixed row types with custom spacingLazyVStack
Grid or horizontal scrollingLazyVGrid / LazyHStack
Scroll-linked visual effectsLazyVStack
Bounded collection (< ~200 rows), custom designEither, LazyVStack fine

How to Tell Which You Need

Two questions, in order.

Can the collection grow without bound? If yes - a feed, search results, a synced dataset - start with List. The memory behavior isn't negotiable at scale.

Does List with .plain style and cleared row chrome get you the design? If yes, use it. If you're fighting the container for a layout it can't express, move to LazyVStack deliberately, and add paging.

When you're unsure whether it's costing you, measure rather than guess. Instruments' Allocations track and the SwiftUI view-body counter show this clearly, and I walked through that workflow in Performance Profiling with Instruments.

Wrapping Up

  • Both are lazy about creation. Only List recycles and releases.
  • List is the default. It gives you memory behavior, swipe actions, refresh, and accessibility for free.
  • LazyVStack buys layout freedom and costs you recycling. That trade is fine for bounded collections and bad for unbounded ones.
  • Try a stripped-down .plain List before assuming you need LazyVStack for a custom look.
  • If you do use LazyVStack at scale, page your data and keep body cheap.

The mistake worth avoiding: choosing LazyVStack for aesthetics, shipping it, and finding out about the memory curve from crash reports on a user's iPhone with 4,000 items synced.