Fix SwiftUI Lazy Stack Jank Before You Switch To List

If your custom SwiftUI feed jumps after rotation, loses row state, or hitches as new cards come on screen, the tempting fix is to throw the whole thing into List.

Sometimes that is exactly the right answer. List gives you platform virtualization, row behavior, editing conventions, selection, accessibility defaults, keyboard behavior, and fewer layout decisions to make yourself.

But WWDC26’s “Dive into lazy stacks and scrolling with SwiftUI” is a good reminder that many lazy-stack problems are not “SwiftUI is slow” problems. They usually come from one of four places:

  1. The stack is estimating off-screen sizes, and your code assumes those sizes are exact.
  2. A row does not have stable identity.
  3. A row resolves to a dynamic number of immediate subviews.
  4. Important row state lives in a view that the lazy stack is allowed to discard.

Let’s fix those before switching tools.

The mental model

A LazyVStack inside a ScrollView does not lay out every row in the feed. It lays out enough subviews to fill the visible area, brings more rows in as you scroll, and estimates the size of content it has not measured yet.

That estimate is the important part.

If your first few rows are tall cards and the last rows are compact comments, the lazy stack initially has to guess the total height. As more rows are measured, that guess can change. SwiftUI coordinates that changing estimate with the enclosing scroll view so visible content can stay visually stable, but it means raw values like “the content offset is exactly 900 points” are not a great foundation for app logic.

Apple also calls out prefetching. Lazy stacks can start work before a row appears, which helps avoid hitches. That works best when the row is already in a reasonable size and state before onAppear fires. If onAppear radically changes row height, the stack has to re-evaluate the layout after the user is already scrolling.

Here is the kind of feed we will use:

  • Mixed-height article cards.
  • A horizontal “Trending” carousel.
  • Rows hidden or shown based on a feed mode.
  • Saved state per item.
  • A floating button that jumps back to the top.

That is exactly the sort of layout where LazyVStack is useful, and exactly the sort of layout where small mistakes turn into jank.

The scroll API examples below use the modern SwiftUI scroll APIs introduced in the iOS 18 and macOS 15 generation, plus WWDC26 guidance about lazy-stack behavior. If you are supporting older OS versions, keep your compatibility path behind availability checks, and verify exact behavior in the Xcode 27 beta seed you are using.

The janky version

This code has several common problems packed together. It is not ridiculous code, either. Most of us have written some version of this during a real feature sprint.

import SwiftUI

struct JankyFeedView: View {
    let items: [FeedItem]
    let mode: FeedMode
    let prepareForDisplay: (FeedItem.ID) -> Void

    @State private var showJumpButton = false

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(Array(items.enumerated()), id: \.offset) { _, item in
                    JankyFeedRow(
                        item: item,
                        mode: mode,
                        prepareForDisplay: prepareForDisplay
                    )
                }

                TrendingCarousel(items: items.flatMap(\.relatedItems))
            }
            .padding()
        }
        .onScrollGeometryChange(for: Bool.self) { geometry in
            geometry.contentOffset.y > 240
        } action: { _, isPastThreshold in
            showJumpButton = isPastThreshold
        }
        .overlay(alignment: .bottomTrailing) {
            if showJumpButton {
                Button("Top") {
                    // Imagine an old ScrollViewReader jump here.
                }
                .buttonStyle(.borderedProminent)
                .padding()
            }
        }
    }
}

struct JankyFeedRow: View {
    let item: FeedItem
    let mode: FeedMode
    let prepareForDisplay: (FeedItem.ID) -> Void

    @State private var isSaved = false
    @State private var measuredTitleHeight: CGFloat = 0

    var body: some View {
        if item.isVisible(in: mode) {
            VStack(alignment: .leading, spacing: 12) {
                FeedImage(url: item.imageURL)
                    .frame(height: max(160, measuredTitleHeight * 2))

                Text(item.title)
                    .font(.headline)
                    .onGeometryChange(for: CGFloat.self, of: \.size.height) { _, height in
                        measuredTitleHeight = height
                    }

                if item.kind == .expanded {
                    Text(item.summary)
                        .font(.subheadline)
                }

                Button(isSaved ? "Saved" : "Save") {
                    isSaved.toggle()
                }
            }
            .padding()
            .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
            .onAppear {
                // This may load metadata and change row height after appearance.
                prepareForDisplay(item.id)
            }
        }
    }
}

struct TrendingCarousel: View {
    let items: [RelatedItem]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 12) {
                ForEach(items) { item in
                    RelatedCard(item: item)
                }
            }
            .padding(.horizontal)
        }
    }
}

The visible symptoms might be:

  • The jump button appears at slightly different moments after rotation or large Dynamic Type changes.
  • Saved state disappears after scrolling far away and back.
  • Saved state appears on the wrong row after inserts or deletes.
  • The carousel clips cards with longer titles.
  • Programmatic scrolling lands near the target, then visibly adjusts.
  • Rows hitch when they appear because onAppear changes too much.

The fix is not one magic modifier. It is a set of small structural changes.

Use stable identity

The first problem is this line:

ForEach(Array(items.enumerated()), id: \.offset) { _, item in
    JankyFeedRow(item: item, mode: mode, prepareForDisplay: prepareForDisplay)
}

Index identity is fragile in a feed. Insert one new item at the top and every index after it now means something else. SwiftUI may preserve state for “row 5,” but “row 5” is now a different article.

Give your model a stable ID and use it directly:

struct FeedItem: Identifiable, Equatable {
    let id: UUID
    var title: String
    var summary: String
    var imageURL: URL?
    var kind: FeedItemKind
    var minimumMode: FeedMode
    var relatedItems: [RelatedItem]

    func isVisible(in mode: FeedMode) -> Bool {
        minimumMode.rawValue <= mode.rawValue
    }
}

struct FeedView: View {
    let items: [FeedItem]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(items) { item in
                    FeedRow(item: item)
                }
            }
            .padding()
        }
    }
}

Also avoid creating identity in the view body:

// Avoid this.
FeedRow(item: item)
    .id(UUID())

That tells SwiftUI the row is a brand-new row every time the body runs. Stable data identity is the beginning of a stable lazy stack.

Filter at the data level

Apple specifically calls out a subtle lazy-stack issue: a view produced by ForEach can resolve to a dynamic number of subviews. That includes a row body that sometimes returns content and sometimes returns nothing.

This is the problem:

struct FeedRow: View {
    let item: FeedItem
    let mode: FeedMode

    var body: some View {
        if item.isVisible(in: mode) {
            FeedCard(item: item)
        }
    }
}

It looks harmless, but the lazy stack has to reason about row subviews and indices. For a repeated row, keep the number of rows stable by filtering before the ForEach.

For a plain array, make the visible collection first:

struct FeedView: View {
    let allItems: [FeedItem]
    let mode: FeedMode

    private var visibleItems: [FeedItem] {
        allItems.filter { item in
            item.isVisible(in: mode)
        }
    }

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(visibleItems) { item in
                    FeedRow(item: item)
                }
            }
            .padding()
        }
    }
}

If this feed comes from SwiftData, push the filter into the query:

import SwiftData
import SwiftUI

struct SwiftDataFeedView: View {
    @Query private var items: [FeedItemModel]

    init(mode: FeedMode) {
        let minimumMode = mode.rawValue

        _items = Query(
            filter: #Predicate<FeedItemModel> { item in
                item.minimumModeRawValue <= minimumMode
            },
            sort: \.publishedAt,
            order: .reverse
        )
    }

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(items) { item in
                    FeedRow(item: item)
                }
            }
            .padding()
        }
    }
}

The same rule applies if authentication, entitlement, or networking state determines whether the whole feed can render. Handle that above the lazy stack:

struct FeedGate: View {
    @Environment(Session.self) private var session

    var body: some View {
        if session.isSignedIn {
            FeedView(
                allItems: session.feedItems,
                mode: session.feedMode
            )
        } else {
            ContentUnavailableView(
                "Sign In Required",
                systemImage: "person.crop.circle.badge.exclamationmark"
            )
        }
    }
}

The lazy stack should be able to look at the data and know how many rows it has.

Make each row one immediate subview

A second version of the same caveat is a row body with multiple top-level views:

struct ArticleRow: View {
    let item: FeedItem

    var body: some View {
        FeedImage(url: item.imageURL)
        Text(item.title)
        Text(item.summary)
    }
}

SwiftUI can resolve that body into multiple subviews. For a row used repeatedly in a lazy stack, prefer a single immediate container:

struct FeedRow: View {
    let item: FeedItem

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            FeedImage(url: item.imageURL)
                .aspectRatio(16.0 / 9.0, contentMode: .fill)
                .frame(maxWidth: .infinity)
                .clipped()

            Text(item.title)
                .font(.headline)

            switch item.kind {
            case .compact:
                EmptyView()

            case .expanded:
                Text(item.summary)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
        .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
    }
}

The row can still have different internal layouts. The important part is that the lazy stack sees one row-shaped thing for each item.

If your real row has complex size dependencies, consider moving that logic into a custom Layout instead of measuring with geometry and feeding state back into the same row. Apple calls out geometry-driven layout changes after appearance as a pattern that can make programmatic scrolling less smooth.

Move durable state out of the row

Lazy stacks can keep off-screen views around for a while, but not forever. When a row is eventually discarded, its @State goes with it.

That makes this a bad place for durable feed state:

struct FeedRow: View {
    let item: FeedItem
    @State private var isSaved = false

    var body: some View {
        Button(isSaved ? "Saved" : "Save") {
            isSaved.toggle()
        }
    }
}

For tiny visual state, @State is fine. A pressed animation, a temporary hover effect, or a local disclosure that does not matter after leaving the screen can live in the row.

But saved, selected, highlighted, muted, followed, and read/unread state should belong to the model, an outer view, or a store:

struct FeedView: View {
    let items: [FeedItem]
    @State private var savedIDs: Set<FeedItem.ID> = []

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(items) { item in
                    FeedRow(
                        item: item,
                        isSaved: savedIDs.contains(item.id)
                    ) {
                        toggleSaved(item.id)
                    }
                }
            }
            .padding()
        }
    }

    private func toggleSaved(_ id: FeedItem.ID) {
        if savedIDs.contains(id) {
            savedIDs.remove(id)
        } else {
            savedIDs.insert(id)
        }
    }
}

struct FeedRow: View {
    let item: FeedItem
    let isSaved: Bool
    let onToggleSaved: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(item.title)
                .font(.headline)

            Button(isSaved ? "Saved" : "Save", action: onToggleSaved)
                .buttonStyle(.bordered)
        }
        .padding()
        .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
    }
}

This fixes two issues at once: the state survives row eviction, and stable item IDs prevent it from getting associated with the wrong row after inserts.

Bound nested horizontal lazy stacks

A horizontal LazyHStack inside a vertical LazyVStack is a good pattern for carousels. The trap is height.

A horizontal lazy stack does not measure every off-screen card before deciding its ideal height. If the first card is short and a later card has a three-line title, the later card can clip or force awkward layout changes.

Make the card size predictable:

struct TrendingCarousel: View {
    let items: [RelatedItem]

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Trending")
                .font(.headline)

            ScrollView(.horizontal) {
                LazyHStack(spacing: 12) {
                    ForEach(items) { item in
                        RelatedCard(item: item)
                            .frame(width: 180, height: 216)
                    }
                }
                .padding(.horizontal)
            }
            .scrollIndicators(.hidden)
        }
        .frame(height: 272)
    }
}

struct RelatedCard: View {
    let item: RelatedItem

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            FeedImage(url: item.imageURL)
                .frame(height: 120)
                .clipped()

            Text(item.title)
                .font(.subheadline.weight(.semibold))
                .lineLimit(2, reservesSpace: true)

            Text(item.subtitle)
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(1)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        .padding(12)
        .background(.background.secondary, in: RoundedRectangle(cornerRadius: 14))
    }
}

The exact numbers are not special. The principle is: nested lazy content should have stable outer dimensions. Use fixed card heights, reserved text space, aspect ratios, or a custom layout. Do not ask the outer vertical lazy stack to discover the eventual height of a horizontal scroller by accident.

If your app supports large Dynamic Type sizes, avoid treating those fixed numbers as universal. Scale the card height with @ScaledMetric, choose larger heights in accessibility text sizes, or switch the carousel to a vertical layout when text needs more space. Predictable dimensions should not mean clipped content.

Stop using absolute offset for row logic

onScrollGeometryChange is useful, but it can be misused.

This kind of threshold is fragile with lazy stacks:

.onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y > 240
} action: { _, isPastThreshold in
    showJumpButton = isPastThreshold
}

The problem is not the modifier. The problem is making an exact content offset responsible for semantic UI. With a lazy stack, the content offset is tied to estimated content above the visible region. That estimate can change as SwiftUI learns more about the rows.

If the UI depends on which row is visible, ask which row is visible.

First define scroll targets:

enum FeedTarget: Hashable {
    case top
    case item(FeedItem.ID)
    case trending
}

Then attach IDs and use onScrollTargetVisibilityChange:

struct BetterFeedView: View {
    let items: [FeedItem]
    let relatedItems: [RelatedItem]

    @State private var showJumpToTop = false
    @State private var scrollPosition = ScrollPosition()

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                Color.clear
                    .frame(height: 1)
                    .id(FeedTarget.top)

                ForEach(items) { item in
                    FeedRow(
                        item: item,
                        isSaved: false,
                        onToggleSaved: {}
                    )
                    .id(FeedTarget.item(item.id))
                }

                TrendingCarousel(items: relatedItems)
                    .id(FeedTarget.trending)
            }
            .scrollTargetLayout()
            .padding()
        }
        .scrollPosition($scrollPosition)
        .onScrollTargetVisibilityChange(
            idType: FeedTarget.self,
            threshold: 0.6
        ) { visibleTargets in
            showJumpToTop = !visibleTargets.contains(.top)
        }
        .safeAreaInset(edge: .bottom) {
            if showJumpToTop {
                Button {
                    withAnimation {
                        scrollPosition.scrollTo(id: FeedTarget.top)
                    }
                } label: {
                    Label("Top", systemImage: "arrow.up")
                }
                .buttonStyle(.borderedProminent)
                .padding()
            }
        }
    }
}

Now the button is tied to meaning: “the top target is no longer visible.” It is not tied to “the estimated offset crossed 240 points.”

Use onScrollGeometryChange for coarse geometry-derived values: scroll progress, whether the user is near the bottom, or a compact visual effect where a small estimate shift is acceptable.

struct FeedWithProgress: View {
    let items: [FeedItem]
    @State private var scrollProgress: CGFloat = 0

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(items) { item in
                    FeedRow(item: item, isSaved: false, onToggleSaved: {})
                }
            }
            .padding()
        }
        .onScrollGeometryChange(for: CGFloat.self) { geometry in
            let scrollableHeight = max(
                geometry.contentSize.height - geometry.containerSize.height,
                1
            )

            return min(max(geometry.contentOffset.y / scrollableHeight, 0), 1)
        } action: { _, newProgress in
            scrollProgress = newProgress
        }
        .overlay(alignment: .top) {
            ProgressView(value: scrollProgress)
                .progressViewStyle(.linear)
        }
    }
}

Even here, treat the value as presentation, not business logic. Do not use a changing raw offset to decide that item 47 was read.

Programmatic scrolling works, but help it

ScrollPosition is the modern shape for programmatic scrolling in this area. Apple’s session shows it working with lazy stacks even when the destination is off-screen, but the stack still has to estimate where that off-screen destination is.

Make that job easy:

  • Use stable IDs.
  • Make each ForEach item resolve to one immediate row.
  • Filter before the ForEach.
  • Avoid row layouts that change height after appearance.
  • Avoid doing major setup in onAppear.

Here is a section jump:

struct SectionedFeedView: View {
    let items: [FeedItem]
    let relatedItems: [RelatedItem]

    @State private var scrollPosition = ScrollPosition()

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16, pinnedViews: [.sectionHeaders]) {
                Section {
                    ForEach(items) { item in
                        FeedRow(item: item, isSaved: false, onToggleSaved: {})
                            .id(FeedTarget.item(item.id))
                    }
                } header: {
                    FeedSectionHeader(title: "Latest")
                }

                Section {
                    TrendingCarousel(items: relatedItems)
                } header: {
                    FeedSectionHeader(title: "Trending")
                        .id(FeedTarget.trending)
                }
            }
            .scrollTargetLayout()
            .padding()
        }
        .scrollPosition($scrollPosition)
        .toolbar {
            Button("Trending") {
                withAnimation {
                    scrollPosition.scrollTo(id: FeedTarget.trending)
                }
            }
        }
    }
}

During the Xcode 27 beta cycle, check the exact ScrollPosition and scroll-target APIs against your installed SDK. The examples follow the direction Apple showed at WWDC26, but names and behavior can still move while the beta is active.

Initialize rows before they appear

onAppear is still useful. Infinite scrolling with a bottom ProgressView is a reasonable use. But using onAppear to configure every row can fight prefetching.

Less ideal:

struct ArticleRow: View {
    let item: FeedItem
    @State private var viewModel = ArticleRowModel()

    var body: some View {
        ArticleRowContent(viewModel: viewModel)
            .onAppear {
                viewModel.configure(with: item)
            }
    }
}

Better: create the model in a useful state before the row appears.

struct ArticleRow: View {
    @State private var viewModel: ArticleRowModel

    init(item: FeedItem) {
        _viewModel = State(initialValue: ArticleRowModel(item: item))
    }

    var body: some View {
        ArticleRowContent(viewModel: viewModel)
    }
}

This pattern is safest when the row is keyed by an immutable item ID and the view model is a cache-backed loader for that ID. @State initialization runs once for a given view identity, so do not use this to copy mutable item content that must keep updating. For mutable content, read the model directly, pass a binding, or explicitly update the view model when the item changes.

If the row needs remote data, pair that with caching and cancellation. The point is not “never load in a row.” The point is to avoid a row appearing in one size and then immediately becoming a very different size because setup was delayed until onAppear.

The cleaned-up feed

Putting the pieces together, the feed becomes less clever and more predictable:

struct PolishedFeedView: View {
    let mode: FeedMode
    let relatedItems: [RelatedItem]

    @Query private var items: [FeedItemModel]
    @State private var savedIDs: Set<FeedItemModel.ID> = []
    @State private var showJumpToTop = false
    @State private var scrollPosition = ScrollPosition()

    init(mode: FeedMode, relatedItems: [RelatedItem]) {
        self.mode = mode
        self.relatedItems = relatedItems

        let minimumMode = mode.rawValue
        _items = Query(
            filter: #Predicate<FeedItemModel> { item in
                item.minimumModeRawValue <= minimumMode
            },
            sort: \.publishedAt,
            order: .reverse
        )
    }

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16, pinnedViews: [.sectionHeaders]) {
                Color.clear
                    .frame(height: 1)
                    .id(FeedTarget.top)

                Section {
                    ForEach(items) { item in
                        FeedModelRow(
                            item: item,
                            isSaved: savedIDs.contains(item.id)
                        ) {
                            toggleSaved(item.id)
                        }
                        .id(FeedTarget.item(item.id))
                    }
                } header: {
                    FeedSectionHeader(title: "Latest")
                }

                Section {
                    TrendingCarousel(items: relatedItems)
                } header: {
                    FeedSectionHeader(title: "Trending")
                }
            }
            .scrollTargetLayout()
            .padding()
        }
        .scrollPosition($scrollPosition)
        .onScrollTargetVisibilityChange(
            idType: FeedTarget.self,
            threshold: 0.6
        ) { visibleTargets in
            showJumpToTop = !visibleTargets.contains(.top)
        }
        .safeAreaInset(edge: .bottom) {
            if showJumpToTop {
                Button {
                    withAnimation {
                        scrollPosition.scrollTo(id: FeedTarget.top)
                    }
                } label: {
                    Label("Top", systemImage: "arrow.up")
                }
                .buttonStyle(.borderedProminent)
                .padding()
            }
        }
    }

    private func toggleSaved(_ id: FeedItemModel.ID) {
        if savedIDs.contains(id) {
            savedIDs.remove(id)
        } else {
            savedIDs.insert(id)
        }
    }
}

struct FeedModelRow: View {
    let item: FeedItemModel
    let isSaved: Bool
    let onToggleSaved: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            FeedImage(url: item.imageURL)
                .aspectRatio(16.0 / 9.0, contentMode: .fill)
                .frame(maxWidth: .infinity)
                .clipped()

            Text(item.title)
                .font(.headline)

            if item.isExpanded {
                Text(item.summary)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            Button(isSaved ? "Saved" : "Save", action: onToggleSaved)
                .buttonStyle(.bordered)
        }
        .padding()
        .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
    }
}

Notice what changed:

  • ForEach uses stable model identity.
  • Filtering happens in the SwiftData query.
  • Each row has one immediate root container.
  • Saved state is owned outside the row.
  • The horizontal carousel has bounded dimensions.
  • The floating button is based on visible targets, not a magic offset.
  • Programmatic scrolling uses ScrollPosition.
  • Rows are designed to be a reasonable size before onAppear.

This is not about making LazyVStack perfect. It is about giving SwiftUI a layout it can reason about.

When List is the right answer

You should still use List when your UI is basically a list.

Reach for List when you want platform row behavior, edit mode, selection, deletion, swipe actions, accessibility defaults, keyboard behavior, or a familiar settings-style presentation. List is also a good default for large, mostly homogeneous rows where custom scroll effects and nested carousels are not central to the design.

Stay with ScrollView plus LazyVStack when you need a custom feed: mixed card shapes, full-bleed sections, nested horizontal scrollers, custom pinned headers, bespoke transitions, or layout that does not want to look like a platform list.

The mistake is treating List as a performance panic button. If your lazy stack is unstable because IDs are unstable, rows filter themselves out, or row height changes after appearance, those are worth fixing either way.

Beta caveats

This article is based on Apple’s WWDC26 material and the Xcode 27 beta-era API direction as of June 23, 2026.

A few cautions:

  • Check exact API names against the SDK you are compiling with.
  • Test on device, not just previews.
  • Test rotation, Dynamic Type, long feeds, inserts at the top, deletes in the middle, and programmatic jumps near the end.
  • Treat absolute scroll geometry as approximate when lazy content is involved.
  • If a beta seed shows behavior that contradicts the session guidance, reduce the example and file Feedback.

The best lazy-stack debugging question is simple:

Can SwiftUI know how many rows there are, what each row’s stable identity is, and roughly how large each row will be before it appears?

If yes, LazyVStack can do its job. If no, you are asking it to scroll smoothly while the ground is moving underneath it.

Official sources