Back to Blog

SwiftUI lists that don’t lag: identity, diffing, and avoiding layout thrash

Most SwiftUI list “performance problems” are self-inflicted: unstable identity, accidental view churn, and heavy layout work in rows. Here’s how to make lists fast, measurable, and boring.

7 min read

SwiftUI lists don’t mysteriously lag. They lag when SwiftUI is forced to rebuild and re-layout far more than you think.

In practice, most “my List is slow” bugs come from three root causes:

  1. Unstable identity: rows look “new” every update, so SwiftUI throws away reuse.
  2. Accidental churn: you recreate models/views in ways that defeat diffing.
  3. Layout thrash: the row is doing expensive measurement (often repeatedly).

This post is a practical, measurable playbook to make lists fast again.

Mental model: SwiftUI is a diff engine

SwiftUI doesn’t render a list the way UIKit does. You describe a tree of values (View structs). SwiftUI then:

  • computes a new tree on every state change
  • diffs it against the previous tree
  • updates the UI using the smallest set of changes it can prove are correct

The keyword is prove.

If you remove SwiftUI’s ability to match “old row A” to “new row A”, it can’t apply a minimal update. It will rebuild more, animate weirdly, scroll-jank, and generally make you question your career choices.

So the goal is simple:

  • make identity stable
  • make updates small
  • make rows cheap to measure

1) Identity: the #1 list performance footgun

Bad: generating IDs in the view

If you do this, you are telling SwiftUI “every row is a new row every time”:

struct RowModel: Identifiable {
  let id = UUID() // ❌ changes every init
  let title: String
}

struct Screen: View {
  let titles: [String]

  var body: some View {
    List {
      ForEach(titles.map { RowModel(title: $0) }) { row in
        Text(row.title)
      }
    }
  }
}

Every body recomputation recreates RowModel, which recreates UUID(). Diffing loses.

Fix: IDs must come from the data source (database primary key, server ID, stable hash) and survive view recomputation.

Good: stable IDs from the source of truth

struct Item: Identifiable, Hashable {
  let id: String // stable
  var title: String
}

struct Screen: View {
  @State private var items: [Item]

  var body: some View {
    List(items) { item in
      Text(item.title)
    }
  }
}

Now SwiftUI can say: “Item(id: 42) existed before and still exists now. Update only what changed.”

Avoid id: \.self unless you really mean it

This is a common trap:

ForEach(items, id: \.self) { item in
  Row(item: item)
}

It is only safe when:

  • Item is a value type with a stable Hashable implementation
  • its hash doesn’t change when editable fields change

If hashValue changes, identity changes. SwiftUI will treat the row as removed + inserted. You’ll see:

  • scroll position jumps
  • animations resetting
  • focus and text fields losing state

If you have a real ID, use it:

ForEach(items, id: \.id) { item in
  Row(item: item)
}

Don’t use array index as identity

Index identity breaks as soon as you insert, delete, or reorder.

ForEach(items.indices, id: \.self) { i in
  Row(item: items[i])
}

You’ll get “wrong row updated” bugs and jank.

If you need the index for UI (e.g. separators, styling), compute it without using it as identity:

ForEach(Array(items.enumerated()), id: \.(element.id)) { index, item in
  Row(item: item, index: index)
}

2) Diffing: help SwiftUI do less work

Prefer List(items) or ForEach(items) over manual mapping

This is subtle but important:

  • List(items) uses items directly.
  • List(items.map(...)) creates a fresh array every time.

Creating a new array is not automatically bad, but it often coincides with unstable identity and extra allocations.

If you need presentation-only formatting, push it into the row (cheaply) or precompute it once in the model.

Keep per-row state keyed by ID

If rows have local UI state (expanded/collapsed, selection, inline editing), do not store it “by index”.

Bad (breaks on reordering):

@State private var expanded: [Bool] // ❌ index-based

Better:

@State private var expandedByID: [Item.ID: Bool] = [:]

Then read/write using item.id.

Use explicit equatability when your row is expensive

SwiftUI will re-run body a lot. That’s normal.

But you can reduce actual updates by making a row equatable when it’s truly driven by a small set of fields:

struct ItemRow: View, Equatable {
  let id: String
  let title: String
  let subtitle: String

  static func == (lhs: Self, rhs: Self) -> Bool {
    lhs.id == rhs.id && lhs.title == rhs.title && lhs.subtitle == rhs.subtitle
  }

  var body: some View {
    VStack(alignment: .leading) {
      Text(title)
      Text(subtitle).foregroundStyle(.secondary)
    }
  }
}

List(items) { item in
  ItemRow(id: item.id, title: item.title, subtitle: item.subtitle)
}

This doesn’t magically eliminate all work, but it can prevent unnecessary updates when parent state changes for unrelated reasons.

Use this sparingly. If you lie in ==, SwiftUI will cache the lie and your UI will be wrong.

3) Layout thrash: make rows cheap to measure

Most scroll jank happens during layout:

  • dynamic text measurement
  • nested stacks with flexible sizing
  • async images changing size late
  • geometry readers forcing re-layout

Rule: don’t do heavy work in body

If you do any of these in a row, you’re paying per-row, per-update:

  • DateFormatter() creation
  • expensive string formatting
  • JSON decoding
  • image decoding / resizing without constraints

Do the boring thing:

  • keep formatters static
  • precompute derived strings in the model or a view model
  • constrain images (fixed frame) so layout can be fast

Example:

enum Formatters {
  static let relative: RelativeDateTimeFormatter = {
    let f = RelativeDateTimeFormatter()
    f.unitsStyle = .short
    return f
  }()
}

Constrain height when you can

If every row is a different height and depends on async content, SwiftUI has to keep re-measuring.

If your design allows it, give rows predictable bounds:

Row(item: item)
  .frame(minHeight: 44)

Or constrain text:

Text(item.title)
  .lineLimit(1)

This is not “premature optimization”. This is telling the layout system what you already know.

Watch out for GeometryReader in rows

GeometryReader is often the start of a self-inflicted feedback loop:

  • row depends on available width
  • width depends on layout
  • layout invalidates, measurement repeats

If you need sizes for effects, prefer:

  • fixed sizes
  • container-level geometry, not per-row
  • containerRelativeFrame / layoutValue patterns where possible

4) List vs ScrollView+LazyVStack

List is optimized, but it’s also opinionated.

If you need custom row separators, sticky headers, or complex nested scroll effects, you may reach for:

ScrollView {
  LazyVStack(alignment: .leading, spacing: 0) {
    ForEach(items) { item in
      Row(item: item)
    }
  }
}

This can be great, but don’t assume it’s automatically faster.

The same identity/diffing rules apply. The same row layout costs apply.

Use List when you can. Switch only when you have a concrete need.

5) Measure like an adult

Before you “optimize”, answer two questions:

  1. Is it layout or data churn?
  2. Is it CPU or main-thread blocking?

Practical tools:

  • Instruments → Time Profiler: find the hot functions when scrolling.
  • Instruments → SwiftUI template: see view updates and body evaluations.
  • Logging signposts around data refresh: confirm how often your model changes.

A useful pattern is to log when your data source actually changes (network refresh, diff application). If the data changes 10× per second, your list is doing exactly what you told it.

Checklist: the boring fixes that work

If your list lags, do this in order:

  1. Verify stable identity (Identifiable with stable id). No UUID() in views.
  2. Stop using indexes as IDs.
  3. Make data updates granular (edit one item, don’t rebuild the whole array).
  4. Make rows cheap (no heavy formatting/allocations in body).
  5. Constrain layout (line limits, fixed frames where possible).
  6. Measure again. If it’s still slow, now you earn the right to get fancy.

SwiftUI can be fast. You just have to stop gaslighting the diff engine.