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.

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\.selffor 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
DateFormatterorNumberFormatterper row - Heavy string formatting in
body(joins, regex, markdown parsing) - Building derived arrays with
.mapor.filterinsidebody - 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
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
@Statein the row) instead of one global value that changes constantly - Split big models: one
ObservableObjectfor 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
Listfor classic table screens: settings, inboxes, orders, admin lists - Use
ScrollView+LazyVStackfor 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
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
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
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
ForEachuses 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.


