Skip to content
·7 min read

SwiftUI List Performance: When Your Scrolling Stutters and How to Fix It

SwiftUI List looks like UIKit UITableView but performs differently. Lazy loading, identity, diffing, and view updates all matter. Here's how to keep your lists smooth.

SwiftUIiOSPerformanceLists

SwiftUI List looks like a friendly version of UITableView. It is more opinionated, easier to set up, and — until your list gets long — it feels effortless. Then you ship a list of 500 items with rich rows and the scroll stutters.

The fixes are not obvious. SwiftUI hides the cell recycling machinery that UIKit developers are used to. The performance problems come from different places. Here is what I look for when a client's list is janky.

Start with List, not ForEach in a ScrollView

The most common mistake I see:

ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemRow(item: item)
        }
    }
}

This works for short lists and dies on long ones. LazyVStack is lazy about mounting rows, but the moment a row scrolls into view, it stays mounted. Scroll through 500 items and you accumulate 500 mounted rows. Memory grows. Rendering slows.

Use List for any list over about 50 items:

List(items) { item in
    ItemRow(item: item)
}
.listStyle(.plain)

List recycles rows aggressively, like UICollectionView. Off-screen rows are unmounted. Memory stays flat. Scrolling stays smooth.

LazyVStack is fine for custom designs that do not fit List's defaults. Just know the trade-off.

Stabilize row identity

ForEach and List need stable, unique IDs to know which rows are which. Without them, SwiftUI falls back to index-based identity and re-renders more than necessary.

// Bad — index-based identity
ForEach(items) { item in
    ItemRow(item: item)
}
 
// Good — explicit stable ID
struct Item: Identifiable {
    let id: UUID
    var name: String
}
 
ForEach(items) { item in
    ItemRow(item: item)
}

When the list reorders, items insert, or items delete, stable IDs let SwiftUI animate the changes correctly. Index-based IDs cause rows to flicker and re-animate.

For Core Data entities, use the objectID. For server data, use whatever the API gives you. Generate a UUID at creation time if nothing else is available.

Keep row bodies small

SwiftUI re-evaluates body for any view whose inputs change. For a row, "inputs" includes the data, the environment, and any observable objects it reads.

// Bad — heavy work in body
struct ItemRow: View {
    let item: Item
 
    var body: some View {
        VStack {
            Text(item.name)
            Text(item.timestamp.formatted(date: .complete, time: .shortened)) // expensive
            Text("Price: \(formatPrice(item.price))") // expensive
        }
    }
}

Date.formatted and number formatting are surprisingly expensive. They run on every body evaluation, which is every render.

Pre-compute the strings:

struct ItemRow: View {
    let item: Item
 
    private var formattedDate: String {
        item.timestamp.formatted(date: .complete, time: .shortened)
    }
 
    private var formattedPrice: String {
        formatPrice(item.price)
    }
 
    var body: some View {
        VStack {
            Text(item.name)
            Text(formattedDate)
            Text("Price: \(formattedPrice)")
        }
    }
}

For data that does not change, pre-compute once at the data layer and store the formatted strings on the model.

Avoid observation pollution

If every row reads a shared @Observable model that also updates for unrelated reasons, every row re-renders on every update.

@Observable
class CartStore {
    var items: [CartItem] = []
    var total: Decimal = 0
    var discountCode: String = ""
}
 
struct CartItemRow: View {
    @Environment(CartStore.self) private var cart
    let item: CartItem
 
    var body: some View {
        HStack {
            Text(item.name)
            // Reading cart.total here means the row re-renders
            // every time total updates, even if this item did not change
            Text("\(cart.total)")
        }
    }
}

With @Observable, only rows that read cart.total re-render when total changes. That is the property-level tracking working. But if every row reads total, every row re-renders on every cart update.

The fix: pass plain values to rows. They are not observable, so they cannot trigger re-renders from changes to unrelated fields.

List(items) { item in
    CartItemRow(
        item: item,
        total: cart.total  // plain value
    )
}

Test on a real device

The simulator is fast. Too fast. It hides performance problems that show up on older iPhones.

Run the app on the oldest device you support. Use Instruments with the SwiftUI template:

  • Hangs — shows frames that took too long to render
  • View Body — counts how many times each view's body was evaluated
  • View Properties — counts observable property reads

Aim for body evaluations in the low hundreds during a scroll. Thousands means you have a problem.

Specific fixes I reach for

  • Move work out of body into computed properties or pre-computed data.
  • Avoid id: \.self on reference types. Use a stable property or UUID.
  • Use EquatableView for rows where SwiftUI's diff is too aggressive.
  • Switch from ScrollView + LazyVStack to List for long lists.
  • Drop shadow and blur on rows — they are expensive at scale. Use them sparingly or pre-render as image assets.
  • Avoid .animation on ForEach — use explicit withAnimation on data mutations.

When to consider UIKit

For extremely demanding lists (thousands of items, complex layouts, video playback), UICollectionView with UIKit cell configuration still edges out SwiftUI. The gap is closing with every iOS release, but for the most extreme cases, UIKit wins.

Use SwiftUI for 95 percent of lists. Drop down to UIKit when you have proof SwiftUI cannot keep up.

List performance in SwiftUI is about identity, observation, and body cost. Get those right and most lists scroll smoothly without any other optimization.

Need help with SwiftUI performance?

I optimize SwiftUI apps for clients, from list scrolling to full app rewrites. Let's talk.

Frequently Asked Questions

Why is my SwiftUI List slow when scrolling?

The most common causes are using ForEach inside a ScrollView instead of List, unstable row identities causing re-renders, heavy computations inside row bodies, and reading too much observable state per row. Start by switching to List with stable IDs.

What is the difference between List and LazyVStack in SwiftUI?

List is backed by UICollectionView and handles cell recycling, swipe actions, and editing. LazyVStack inside a ScrollView is lighter and more flexible for custom layouts but does not recycle cells as aggressively. Use List for standard table views, LazyVStack for custom designs.

How do I stop SwiftUI List from re-rendering every row?

Use stable IDs (UUID or database id) for Identifiable conformance, keep row body computations cheap, and avoid having every row observe an object that updates for unrelated reasons. With @Observable, only rows that read the changed property re-render.

Is SwiftUI List slower than UIKit UITableView?

For most lists, no — List uses UICollectionView underneath. For very large lists with complex rows, UIKit with manual cell configuration can still edge out SwiftUI. The difference is small and SwiftUI improves with every release.