SwiftUI LazyVStack, LazyHStack, and List for on-screen lazy loading

This article is a clearer comparison of SwiftUI container behavior when row views trigger data loading. The point is simple: if each row causes work such as downloading images, avoid eager containers like VStack and prefer lazy containers that only build what the user is close to seeing.

Diagram comparing eager loading in VStack with lazy loading in LazyVStack and List

The article compares container behavior by watching when each row view actually triggers work, not by relying on vague performance assumptions.

This article is about a practical SwiftUI issue: what happens when each cell or row causes a network request, such as downloading a book cover or an animal image. If the container eagerly creates all child views, the app may trigger a large burst of requests the moment the screen appears.

The comparison focuses on four options: VStack, LazyVStack, LazyHStack, List, and as a side note, Form.

If your rows fetch images or other remote data, loading only the visible part of the list can cut down the initial burst of work dramatically.

The article frames this with a book-list example: when a screen shows many books, you may want to download only the images that are on screen right now, then fetch more as the user scrolls. That is the core reason lazy containers matter.

In the sample project, the author tracks behavior by printing request IDs from the mock network layer, so you can see exactly how many rows triggered work and in what order.

Project Link This article links to the sample project at SwiftUI_Lazy-Loading.

The example data source is intentionally simple: a shared networking helper returns a list of IDs, and the row view does the per-item work.

The article does not need a complicated data model. The important part is that each rendered ItemDisplay can cause work, which makes row creation visible in the console.

That makes this article useful as a mental model: container choice changes when rows come into existence, and that changes when side effects such as downloads begin.

A plain VStack inside a ScrollView eagerly builds every row, so it is the wrong choice when rows trigger expensive work.

ScrollView {
    VStack {
        ForEach(Networking.shared.getAllCatIDs(), id: \.self) { catID in
            ItemDisplay(catID: catID)
        }
    }
}

This article shows that this version triggers all IDs immediately. If each row kicks off a network request, then the app effectively starts loading the entire dataset at launch.

id: 99
id: 98
id: 97
...
id: 2
id: 1
id: 0

The exact ordering is not the main point. The main point is that everything starts at once.

LazyVStack delays row creation so the initial work is limited to what is near the visible area, and LazyHStack gives the same idea for horizontal layouts.

ScrollView {
    LazyVStack {
        ForEach(Networking.shared.getAllCatIDs(), id: \.self) { catID in
            ItemDisplay(catID: catID)
        }
    }
}

In the sample run from the article, LazyVStack does not load everything immediately. Instead, it creates only an initial subset of rows, roughly enough for the current viewport and nearby content.

id: 21
id: 9
id: 10
id: 22
id: 1
id: 8
...
id: 26
id: 3
id: 0

The order is not sequential, which is one of the useful observations in this article. The framework is not promising that rows will appear in a simple ascending order. It is only promising that not everything is built up front.

For horizontally scrolling content, the article notes that the same pattern applies with LazyHStack.

List also loads lazily, but in the sample project it behaves more sequentially than LazyVStack.

List(Networking.shared.getAllCatIDs(), id: \.self) { catID in
    ItemDisplay(catID: catID)
}

The article reports that List initially loads only the first several elements, then continues loading more as the user scrolls. Compared with the sample logs from LazyVStack, the load order appears more regular:

id: 0
id: 1
id: 2
id: 3
id: 4
...
id: 18
id: 19

That does not automatically make List better in every case, but it does make it a very strong default when you want list behavior and prefer a more conventional loading pattern.

Form also avoids loading everything immediately, but this article observed duplicate work for some elements, so it is not presented as the cleanest option for this use case.

Form {
    ForEach(Networking.shared.getAllCatIDs(), id: \.self) { catID in
        ItemDisplay(catID: catID)
    }
}

According to the article's console output, Form initially behaves somewhat like a lazy container, but some elements appear to be loaded more than once:

id: 0
id: 1
id: 2
...
id: 18
id: 19
id: 0
id: 1
id: 2
...
id: 19
id: 18
id: 17
id: 16

The article does not turn this into a deep framework diagnosis. It simply treats it as a reason not to use Form as the first choice for this specific lazy-loading pattern.

The main rule from this article still holds up: when row creation triggers real work, prefer lazy containers so that data loads when the user is actually near it.

If your screen is really a list of items with remote images or other on-demand content, do not build it with an eager VStack. Use LazyVStack, LazyHStack, or List depending on the layout and behavior you want.

The article is especially useful because it does not talk about "performance" abstractly. It shows that container choice changes when requests begin, which is the effect developers actually need to reason about.