Nov 17, 2025·7 min read

SwiftUI performance tuning for long lists: practical fixes

SwiftUI performance tuning for long lists: practical fixes for re-renders, stable row identity, pagination, image loading, and smooth scrolling on older iPhones.

SwiftUI performance tuning for long lists: practical fixes

What “slow lists” look like in real SwiftUI apps

A “slow list” in SwiftUI usually isn’t a bug. It’s the moment your UI can’t keep up with your finger. You notice it while scrolling: the list hesitates, frames drop, and everything feels heavy.

Typical signs:

  • Scrolling stutters, especially on older devices
  • Rows flicker or briefly show the wrong content
  • Taps feel delayed, or swipe actions start late
  • The phone gets warm and battery drains faster than expected
  • Memory usage grows the longer you scroll

Long lists can feel slow even when each row looks “small,” because the cost isn’t just drawing pixels. SwiftUI still has to figure out what each row is, compute layout, resolve fonts and images, run your formatting code, and diff updates when data changes. If any of that work happens too often, the list becomes a hotspot.

It also helps to separate two ideas. In SwiftUI, a “re-render” often means a view’s body is recomputed. That part is usually cheap. The expensive work is what the recompute triggers: heavy layout, image decoding, text measurement, or rebuilding many rows because SwiftUI thinks their identity changed.

Picture a chat with 2,000 messages. New messages arrive every second, and each row formats timestamps, measures multi-line text, and loads avatars. Even if you only add one item, a poorly scoped state change can cause many rows to re-evaluate, and some of them to redraw.

The goal isn’t micro-optimization. You want smooth scrolling, instant taps, and updates that touch only the rows that actually changed. The fixes below focus on stable identity, cheaper rows, fewer unnecessary updates, and controlled loading.

The main causes: identity, work per row, and update storms

When a SwiftUI list feels slow, it’s rarely “too many rows.” It’s extra work happening while you scroll: rebuilding rows, recalculating layout, or reloading images over and over.

Most root causes fall into three buckets:

  • Unstable identity: rows don’t have a consistent id, or you use \.self for values that can change. SwiftUI can’t match old rows to new rows, so it rebuilds more than needed.
  • Too much work per row: date formatting, filtering, resizing images, or doing network/disk work inside the row view.
  • Update storms: one change (typing, a timer tick, progress updates) triggers frequent state updates, and the list refreshes repeatedly.

Example: you have 2,000 orders. Each row formats currency, builds an attributed string, and starts an image fetch. Meanwhile, a “last synced” timer updates once per second in the parent view. Even if the order data doesn’t change, that timer can still invalidate the list often enough to make scrolling choppy.

Why List and LazyVStack can feel different

List is more than a scroll view. It’s designed around table/collection behavior and system optimizations. It often handles large datasets with less memory, but it can be sensitive to identity and frequent updates.

ScrollView + LazyVStack gives you more control over layout and visuals, but it’s also easier to accidentally do extra layout work or trigger expensive updates. On older devices, that extra work shows up sooner.

Before you rewrite your UI, measure first. Small fixes like stable IDs, moving work out of rows, and reducing state churn often solve the problem without changing containers.

Fix row identity so SwiftUI can diff efficiently

When a long list feels janky, identity is often the culprit. SwiftUI decides which rows can be reused by comparing IDs. If those IDs change, SwiftUI treats rows as new, throws away old ones, and rebuilds more than needed. That can look like random re-renders, lost scroll position, or animations firing for no reason.

The simplest win: make each row’s id stable and tied to your data source.

A common mistake is generating identity inside the view:

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

This forces a new ID every render, so every row becomes “different” every time.

Prefer IDs that already exist in your model, like a database primary key, a server ID, or a stable slug. If you don’t have one, create it once when you create the model - not inside the view.

struct Item: Identifiable {
  let id: Int
  let title: String
}

List(items) { item in
  Row(item: item)
}

Be careful with indices. ForEach(items.indices, id: \.self) ties identity to position. If you insert, delete, or sort, rows “move,” and SwiftUI may reuse the wrong view for the wrong data. Use indices only for truly static arrays.

If you use id: \.self, make sure the element’s Hashable value is stable over time. If the hash changes when a field updates, the row’s identity changes too. A safe rule for Equatable and Hashable: base them on a single, stable ID, not on editable properties like name or isSelected.

Sanity checks:

  • IDs come from the data source (not UUID() in the view)
  • IDs don’t change when row content changes
  • Identity doesn’t depend on array position unless the list never reorders

Reduce re-renders by making row views cheaper

A long list often feels slow because each row does too much work every time SwiftUI re-evaluates its body. The target is simple: make each row cheap to rebuild.

A common hidden cost is passing “big” values into a row. Large structs, deeply nested models, or heavy computed properties can trigger extra work even when the UI looks unchanged. You may be rebuilding strings, parsing dates, resizing images, or producing complex layout trees more often than you realize.

Move expensive work out of body

If something is slow, don’t rebuild it inside the row body over and over. Precompute it when data arrives, cache it in your view model, or memoize it in a small helper.

Row-level costs that add up fast:

  • Creating a new DateFormatter or NumberFormatter per row
  • Heavy string formatting in body (joins, regex, markdown parsing)
  • Building derived arrays with .map or .filter inside body
  • Reading large blobs and converting them (like decoding JSON) in the view
  • Overly complex layout with many nested stacks and conditionals

A simple example: keep formatters static, and pass preformatted strings into the row.

enum Formatters {
    static let shortDate: DateFormatter = {
        let f = DateFormatter()
        f.dateStyle = .medium
        f.timeStyle = .none
        return f
    }()
}

struct OrderRow: View {
    let title: String
    let dateText: String

    var body: some View {
        HStack {
            Text(title)
            Spacer()
            Text(dateText).foregroundStyle(.secondary)
        }
    }
}

Split rows and use Equatable when it fits

If only one small part changes (like a badge count), isolate it into a subview so the rest of the row stays stable.

For truly value-driven UI, making a subview Equatable (or wrapping it with EquatableView) can help SwiftUI skip work when inputs didn’t change. Keep the equatable inputs small and specific - not the whole model.

Control state updates that trigger full list refreshes

Iterate without technical debt
Connect messaging and AI integrations without rewriting your app when requirements change.
Build Prototype

Sometimes the rows are fine, but something keeps telling SwiftUI to refresh the whole list. During scrolling, even small extra updates can turn into stutters, especially on older devices.

One common cause is recreating your model too often. If a parent view rebuilds and you used @ObservedObject for a view model that the view owns, SwiftUI may recreate it, reset subscriptions, and trigger fresh publishes. If the view owns the model, use @StateObject so it’s created once and stays stable. Use @ObservedObject for objects injected from outside.

Another quiet performance killer is publishing too frequently. Timers, Combine pipelines, and progress updates can fire many times per second. If a published property affects the list (or sits on a shared ObservableObject used by the screen), every tick can invalidate the list.

Example: you have a search field that updates query on each keystroke, then filters 5,000 items. If you filter immediately, the list re-diffs constantly while the user types. Debounce the query, and update the filtered array after a short pause.

Patterns that usually help:

  • Keep fast-changing values out of the object that drives the list (use smaller objects or local @State)
  • Debounce search and filtering so the list updates after typing pauses
  • Avoid high-frequency timer publishes; update less often or only when a value actually changes
  • Keep per-row state local (like @State in the row) instead of one global value that changes constantly
  • Split big models: one ObservableObject for list data, another for screen-level UI state

The idea is simple: make scrolling time quiet. If nothing important changed, the list shouldn’t be asked to do work.

Choose the right container: List vs LazyVStack

The container you choose affects how much work iOS does for you.

List is usually the safest pick when your UI looks like a standard table: rows with text, images, swipe actions, selection, separators, edit mode, and accessibility. Under the hood, it benefits from platform optimizations Apple has tuned for years.

A ScrollView with LazyVStack is great when you need custom layout: cards, mixed content blocks, special headers, or a feed-style design. “Lazy” means it builds rows as they come on screen, but it doesn’t give you the same behavior as List in every case. With very large datasets, that can mean higher memory use and choppier scrolling on older devices.

A simple decision rule:

  • Use List for classic table screens: settings, inboxes, orders, admin lists
  • Use ScrollView + LazyVStack for custom layouts and mixed content
  • If you have thousands of items and only need a table, start with List
  • If you need pixel-perfect control, try LazyVStack, then measure memory and frame drops

Also watch out for styling that quietly slows scrolling. Per-row effects like shadow, blur, and complex overlays can force extra rendering work. If you want depth, apply heavier effects to small elements (like an icon) instead of the entire row.

Concrete example: an “Orders” screen with 5,000 rows often stays smooth in List because rows get reused. If you switch to LazyVStack and build card-style rows with big shadows and multiple overlays, you may see jank even though the code looks clean.

Pagination that feels smooth and avoids memory spikes

Get identity right from day one
Design stable IDs and PostgreSQL tables visually before your SwiftUI UI ever renders a row.
Create Project

Pagination keeps long lists fast because you render fewer rows, hold fewer models in memory, and give SwiftUI less diffing work.

Start with a clear paging contract: a fixed page size (for example 30 to 60 items), a “no more results” flag, and a loading row that only appears while you’re fetching.

A common trap is triggering the next page only when the very last row appears. That’s often too late, so the user hits the end and sees a pause. Instead, start loading when one of the last few rows appears.

Here is a simple pattern:

@State private var items: [Item] = []
@State private var isLoading = false
@State private var reachedEnd = false

func loadNextPageIfNeeded(currentIndex: Int) {
    guard !isLoading, !reachedEnd else { return }
    let threshold = max(items.count - 5, 0)
    guard currentIndex >= threshold else { return }

    isLoading = true
    Task {
        let page = try await api.fetchPage(after: items.last?.id)
        await MainActor.run {
            let newUnique = page.filter { p in !items.contains(where: { $0.id == p.id }) }
            items.append(contentsOf: newUnique)
            reachedEnd = page.isEmpty
            isLoading = false
        }
    }
}

This avoids common problems like duplicate rows (overlapping API results), race conditions from multiple onAppear calls, and loading too much at once.

If your list supports pull to refresh, reset paging state carefully (clear items, reset reachedEnd, cancel in-flight tasks if possible). If you control the backend, stable IDs and cursor-based paging make the UI noticeably smoother.

Images, text, and layout: keep row rendering lightweight

Build faster than hand-tuning lists
Model your data and paging once, then generate iOS, web, and backend code together.
Try AppMaster

Long lists rarely feel slow because of the list container. Most of the time, it’s the row. Images are the usual culprit: decoding, resizing, and drawing can outrun your scroll speed, especially on older devices.

If you load remote images, make sure heavy work doesn’t happen on the main thread during scroll. Also avoid downloading full-resolution assets for a 44-80 pt thumbnail.

Example: a “Messages” screen with avatars. If each row downloads a 2000x2000 image, scales it down, and applies a blur or shadow, the list will stutter even if your data model is simple.

Keep image work predictable

High-impact habits:

  • Use server-side or pre-generated thumbnails close to the displayed size
  • Decode and resize off the main thread where possible
  • Cache thumbnails so fast scrolling doesn’t re-fetch or re-decode
  • Use a placeholder that matches the final size to prevent flicker and layout jumps
  • Avoid expensive modifiers on images in rows (heavy shadows, masks, blur)

Stabilize layout to avoid thrash

SwiftUI can spend more time measuring than drawing if row height keeps changing. Try to keep rows predictable: fixed frames for thumbnails, consistent line limits, and stable spacing. If text can expand, cap it (for example, 1 to 2 lines) so a single update doesn’t force extra measurement work.

Placeholders matter too. A gray circle that becomes an avatar later should occupy the same frame, so the row doesn’t reflow mid-scroll.

How to measure: Instruments checks that reveal real bottlenecks

Performance work is guessy if you only go by “it feels janky.” Instruments tells you what runs on the main thread, what gets allocated during fast scroll, and what causes dropped frames.

Define a baseline on a real device (an older one if you support it). Do one repeatable action: open the screen, scroll top to bottom fast, trigger load-more once, then scroll back up. Note the worst hitch points, memory peak, and whether the UI stays responsive.

The three Instruments views that pay off

Use these together:

  • Time Profiler: look for main-thread spikes while you scroll. Layout, text measurement, JSON parsing, and image decoding here often explain the hitch.
  • Allocations: watch for surges in temporary objects during fast scroll. That often points to repeated formatting, new attributed strings, or rebuilding per-row models.
  • Core Animation: confirm dropped frames and long frame times. This helps separate rendering pressure from slow data work.

When you find a spike, click into the call tree and ask: is this happening once per screen, or once per row, per scroll? The second one is what breaks smooth scrolling.

Add signposts for scroll and pagination events

Many apps do extra work during scrolling (image loads, pagination, filtering). Signposts help you see those moments on the timeline.

import os
let log = OSLog(subsystem: "com.yourapp", category: "list")

os_signpost(.begin, log: log, name: "LoadMore")
// fetch next page
os_signpost(.end, log: log, name: "LoadMore")

Re-test after each change, one at a time. If FPS improves but Allocations get worse, you may have traded stutter for memory pressure. Keep the baseline notes and only keep changes that move the numbers in the right direction.

Common mistakes that quietly kill list performance

Keep row work off the UI
Move formatting and business rules into backend logic so rows stay lightweight on-device.
Build App

Some issues are obvious (big images, huge datasets). Others only show up when the data grows, especially on older devices.

1) Unstable row IDs

A classic mistake is creating IDs inside the view, like id: \.self for reference types, or UUID() in the row body. SwiftUI uses identity to diff updates. If the ID changes, SwiftUI treats the row as new, rebuilds it, and may throw away cached layout.

Use a stable ID from your model (database primary key, server ID, or a stored UUID created once when the item is created). If you don’t have one, add one.

2) Heavy work inside onAppear

onAppear runs more often than people expect because rows come and go as you scroll. If each row starts image decoding, JSON parsing, or a database lookup in onAppear, you’ll get repeated spikes.

Move heavy work out of the row. Precompute what you can when data loads, cache results, and keep onAppear limited to cheap actions (like triggering pagination when you’re near the end).

3) Binding the whole list to row edits

When every row gets a @Binding into a large array, a small edit can look like a big change. That can cause many rows to re-evaluate, and sometimes the entire list refreshes.

Prefer passing immutable values into the row and sending changes back with a lightweight action (for example, “toggle favorite for id”). Keep per-row state inside the row only when it truly belongs there.

4) Too much animation while scrolling

Animations are expensive in a list because they can trigger extra layout passes. Applying animation(.default, value:) high up (on the whole list) or animating every tiny state change can make scrolling feel sticky.

Keep it simple:

  • Scope animations to the one row that changes
  • Avoid animating during fast scroll (especially for selection/highlight)
  • Be careful with implicit animations on frequently changing values
  • Prefer simple transitions over complex combined effects

A real example: a chat-style list where each row starts a network fetch in onAppear, uses UUID() for id, and animates “seen” status changes. That combination creates constant row churn. Fixing identity, caching work, and limiting animations often makes the same UI feel instantly smoother.

Quick checklist, a simple example, and next steps

If you only do a few things, start here:

  • Use a stable, unique id for each row (not the array index, not a freshly generated UUID)
  • Keep row work tiny: avoid heavy formatting, big view trees, and expensive computed properties in body
  • Control publishes: don’t let fast-changing state (timers, typing, network progress) invalidate the whole list
  • Load in pages and prefetch so memory stays flat
  • Measure before and after with Instruments so you’re not guessing

Picture a support inbox with 20,000 conversations. Each row shows a subject, last message preview, timestamp, unread badge, and an avatar. Users can search, and new messages arrive while they scroll. The slow version usually does a few things at once: it rebuilds rows on every keystroke, re-measures text too often, and fetches too many images too early.

A practical plan that doesn’t require tearing your codebase apart:

  • Baseline: record a short scroll and a search session in Instruments (Time Profiler + Core Animation).
  • Fix identity: ensure your model has a real id from the server/database, and ForEach uses it consistently.
  • Add paging: start with the newest 50 to 100 items, then load more when the user nears the end.
  • Optimize images: use smaller thumbnails, cache results, and avoid decoding on the main thread.
  • Re-measure: confirm fewer layout passes, fewer view updates, and steadier frame times on older devices.

If you’re building a complete product (iOS app plus backend and a web admin panel), it can also help to design the data model and paging contract early. Platforms like AppMaster (appmaster.io) are built for that full-stack workflow: you can define data and business logic visually, and still generate real source code you can deploy or self-host.

FAQ

What’s the fastest fix when my SwiftUI list scrolls with stutters?

Start by fixing row identity. Use a stable id from your model and avoid generating IDs in the view, because changing IDs forces SwiftUI to treat rows as brand new and rebuild far more than needed.

Is SwiftUI slow because it “re-renders” too much?

A body recompute is usually cheap; the expensive part is what it triggers. Heavy layout, text measurement, image decoding, and rebuilding lots of rows due to unstable identity are what typically cause frame drops.

How do I choose a stable `id` for `ForEach` and `List`?

Don’t use UUID() inside the row or rely on array indices for identity if the data can insert, delete, or reorder. Prefer a server/database ID or a UUID stored on the model when it’s created, so the ID stays the same across updates.

Can `id: \.self` make list performance worse?

It can, especially if the value’s hash changes when editable fields change, because SwiftUI may see it as a different row. If you need Hashable, base it on a single stable identifier rather than properties like name, isSelected, or derived text.

What should I avoid doing inside a row’s `body`?

Move expensive work out of body. Preformat dates and numbers, avoid creating new formatters per row, and don’t build large derived arrays with map/filter inside the view; compute once in the model or view model and pass small display-ready values to the row.

Why is my `onAppear` firing so often in a long list?

Because onAppear runs frequently as rows enter and leave the screen during scrolling. If each row starts heavy work there (image decoding, database reads, parsing), you’ll get repeated spikes; keep onAppear limited to cheap tasks like triggering pagination near the end.

What causes “update storms” that make scrolling feel sticky?

Any fast-changing published value shared with the list can invalidate it repeatedly, even if row data didn’t change. Keep timers, typing state, and progress updates out of the main object that drives the list, debounce search, and split large ObservableObjects into smaller ones when needed.

When should I use `List` vs `LazyVStack` for large datasets?

Use List when your UI is table-like (standard rows, swipe actions, selection, separators) and you want system optimizations. Use ScrollView + LazyVStack when you need custom layouts, but measure memory and frame drops because it’s easier to accidentally do extra layout work.

What’s a simple pagination approach that stays smooth?

Load earlier than the very last row by starting when the user reaches a threshold near the end, and guard against duplicate triggers. Keep page sizes reasonable, track isLoading and “reached end,” and dedupe results by stable IDs to prevent duplicate rows and extra diffs.

How do I measure what’s actually slowing my SwiftUI list down?

Baseline on a real device and use Instruments to find main-thread spikes and allocation surges during fast scroll. Time Profiler shows what blocks scrolling, Allocations reveals per-row churn, and Core Animation confirms dropped frames so you know whether the bottleneck is rendering or data work.

Easy to start
Create something amazing

Experiment with AppMaster with free plan.
When you will be ready you can choose the proper subscription.

Get Started