Why SwiftUI Performance Is Not Free
SwiftUI feels fast out of the box — until your app grows past a few screens. Once you have real data, complex view hierarchies, network images, and state flowing through parent-child views, performance problems creep in. Dropped frames. Scroll hitches. Slow transitions.
The good news: every performance problem in SwiftUI has a fix, and most are surprisingly simple. These 15 tips represent the patterns that make the biggest difference in production apps.
The 60fps rule: You have 16.67 milliseconds per frame. Every millisecond your view body takes beyond that is a dropped frame your users can feel.
Quick Reference: Common Issues and Fixes
| Problem | Symptom | Fix | Tip |
|---|---|---|---|
| VStack with 1000+ items | Slow launch, memory spike | Use LazyVStack/LazyHStack | 1 |
| Unstable view IDs | Scroll hitches, broken animations | Use stable Identifiable IDs | 2 |
| Parent state redraws children | Unnecessary body evaluations | Extract subviews into structs | 3 |
| ObservableObject triggers all views | Global redraws on any change | Migrate to @Observable | 4 |
| Full-res images in lists | Memory spikes, choppy scroll | Downsample and cache | 7 |
| Heavy computation in body | Dropped frames | Move to ViewModel / .task | 14 |
| Broad EnvironmentObject | Cascade redraws | Scope narrowly | 15 |
1. Use LazyVStack and LazyHStack for Large Collections
A regular VStack creates every child view immediately. For a feed with 500 items, that means 500 view bodies evaluated on launch. LazyVStack only creates views as they scroll into the visible area.
Before (Slow)
ScrollView {
VStack(spacing: 12) {
ForEach(posts) { post in
PostCard(post: post) // All 500 created immediately
}
}
}After (Fast)
ScrollView {
LazyVStack(spacing: 12) {
ForEach(posts) { post in
PostCard(post: post) // Only visible cards created
}
}
}When to stay with VStack: Lists under 20-30 simple items. For dynamic data of unknown length, always go lazy. For 10,000+ items, prefer List which uses UITableView cell recycling under the hood.
2. Use Stable, Unique View Identifiers
SwiftUI uses view identity to decide which views to update vs recreate. Using .id(UUID()) or array indices forces SwiftUI to treat views as new — destroying animations and cell reuse.
Before (Slow)
ForEach(items) { item in
ItemRow(item: item)
.id(UUID()) // New identity every render = full recreate
}After (Fast)
struct Item: Identifiable {
let id: String // Stable, server-provided ID
var title: String
}
ForEach(items) { item in
ItemRow(item: item) // SwiftUI tracks by stable id
}3. Extract Subviews to Reduce Unnecessary Redraws
When SwiftUI evaluates body, it evaluates the entire body. If your parent holds @State and contains 15 child views, changing that state re-evaluates all 15 — even if only one depends on the changed state. Extract children into separate structs so SwiftUI can skip unchanged ones.
Before (Slow)
struct ProfileScreen: View {
@State private var isEditing = false
let user: UserProfile
var body: some View {
VStack {
AvatarView(url: user.avatarURL) // Re-evaluates
Text(user.displayName).font(.title) // Re-evaluates
StatsGrid(user: user) // Re-evaluates
Button(isEditing ? "Done" : "Edit") { isEditing.toggle() }
}
}
}After (Fast)
struct ProfileScreen: View {
@State private var isEditing = false
let user: UserProfile
var body: some View {
VStack {
ProfileHeader(user: user) // Skipped — inputs unchanged
ProfileStats(user: user) // Skipped — inputs unchanged
EditButton(isEditing: $isEditing)
}
}
}This is not just code organization — it is a performance optimization. Each extracted struct is an independent comparison point.
4. Migrate from ObservableObject to @Observable
ObservableObject notifies every subscribing view when any @Published property changes. The @Observable macro tracks which properties each view reads and only triggers redraws for those specific properties. This single migration can cut unnecessary re-evaluations by 50-80%.
Before (Slow)
class SettingsVM: ObservableObject {
@Published var username = ""
@Published var darkMode = false
@Published var fontSize: Double = 16
}
// Changing darkMode redraws ALL views using this VMAfter (Fast)
@Observable
class SettingsVM {
var username = ""
var darkMode = false
var fontSize: Double = 16
}
// Changing darkMode ONLY redraws views that read darkMode5. Make Expensive Views Equatable
For views with heavy body computations — charts, Canvas drawing, maps — conforming to Equatable gives SwiftUI an explicit fast path to skip re-evaluation.
struct ExpensiveChart: View, Equatable {
let dataPoints: [DataPoint]
let config: ChartConfig
static func == (lhs: ExpensiveChart, rhs: ExpensiveChart) -> Bool {
lhs.dataPoints.count == rhs.dataPoints.count &&
lhs.config == rhs.config
}
var body: some View {
Canvas { context, size in
// Heavy drawing — only called when data actually changes
}
}
}
// Wrap with EquatableView to activate
EquatableView(content: ExpensiveChart(dataPoints: data, config: config))6. Optimize .task and .onAppear for Data Loading
.onAppear fires every time the view appears — including back navigation. Use.task instead: it ties work to view identity and auto-cancels on disappear.
Before (Slow)
List(posts) { PostRow(post: $0) }
.onAppear {
Task { posts = try await api.fetchPosts() } // Fires on every appear
}After (Fast)
List(posts) { PostRow(post: $0) }
.task {
guard !hasLoaded else { return }
posts = (try? await api.fetchPosts()) ?? []
hasLoaded = true
}
// Bonus: re-fetch when a value changes
.task(id: selectedUserID) {
profile = try? await api.fetchProfile(id: selectedUserID)
}7. Downsample and Cache Images
A single 4000x3000 photo consumes ~48MB of uncompressed memory. Displaying these at 80x80 points in a list will spike past 1GB. Downsample at the CGImage level and cache with NSCache.
Before (Slow)
AsyncImage(url: imageURL) { image in
image.resizable().frame(width: 80, height: 80).clipShape(Circle())
} placeholder: { ProgressView() }
// Full 4000x3000 decoded, then downscaled — 48MB per imageAfter (Fast)
func downsample(url: URL, pointSize: CGSize, scale: CGFloat) -> UIImage? {
let maxDim = max(pointSize.width, pointSize.height) * scale
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDim
]
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
else { return nil }
return UIImage(cgImage: cg)
}
// Pair with NSCache for automatic memory-pressure eviction
actor ImageCache {
static let shared = ImageCache()
private let cache = NSCache<NSString, UIImage>()
func image(for url: URL, size: CGSize, scale: CGFloat) -> UIImage? {
let key = "\(url.absoluteString)-\(size.width)x\(size.height)" as NSString
if let cached = cache.object(forKey: key) { return cached }
guard let img = downsample(url: url, pointSize: size, scale: scale) else { return nil }
cache.setObject(img, forKey: key)
return img
}
}8. Optimize List Performance with Large Datasets
For 1,000+ item lists, the difference between smooth and janky comes down to: lightweight row views, pre-computed values, and cached images.
Before (Slow)
List {
ForEach(allItems) { item in
VStack(alignment: .leading) {
AsyncImage(url: item.imageURL).frame(height: 200)
Text(item.title).font(.headline)
Text(formattedDate(item.createdAt)) // Formatted in body
}
}
}After (Fast)
List {
ForEach(allItems) { item in
ItemRow(item: item) // Extracted struct
}
}
.listStyle(.plain)
struct ItemRow: View {
let item: Item
var body: some View {
HStack(spacing: 12) {
CachedImage(url: item.imageURL, size: CGSize(width: 60, height: 60))
VStack(alignment: .leading, spacing: 4) {
Text(item.title).font(.subheadline.weight(.semibold)).lineLimit(2)
Text(item.formattedDate).font(.caption).foregroundStyle(.secondary)
}
}
}
}9. Use drawingGroup() for Complex Custom Rendering
SwiftUI renders each view through individual Core Animation layers. For views with hundreds of overlapping elements (charts, particle effects), drawingGroup() flattens everything into a single Metal-backed texture.
Before (Slow)
ZStack {
ForEach(0..<500) { i in
Circle()
.fill(colors[i % colors.count])
.frame(width: sizes[i], height: sizes[i])
.offset(x: offsets[i].x, y: offsets[i].y)
}
} // 500 individual Core Animation layersAfter (Fast)
ZStack {
ForEach(0..<500) { i in
Circle()
.fill(colors[i % colors.count])
.frame(width: sizes[i], height: sizes[i])
.offset(x: offsets[i].x, y: offsets[i].y)
}
}
.drawingGroup() // 1 Metal-backed layerDo not use on standard UI with text fields and buttons — it disables some text rendering optimizations and accessibility features.
10. Profile with Instruments
You cannot optimize what you cannot measure. Instruments has several tools specifically designed for SwiftUI performance analysis:
| Instrument | What It Measures | When to Use | Key Metrics |
|---|---|---|---|
| SwiftUI Instruments | Body evaluations, update counts, identity changes | First stop for any SwiftUI issue | Body count, update duration |
| Time Profiler | CPU time per function, call trees | General slowness | Weight %, self time |
| Core Animation | FPS, offscreen rendering, blending | Scroll hitches, animation drops | FPS counter, color overlays |
| Allocations | Memory usage, object lifetimes | Memory growth during scrolling | Live bytes, transient allocations |
| Leaks | Retain cycles, leaked objects | Memory grows but never shrinks | Leak count, object graph |
| Animation Hitches | Commit and render hitch duration | Animations feel janky | Hitch duration (ms) |
| Network | Request timing, payload sizes | Slow data loading blocking UI | Latency, throughput |
Debug Body Evaluations in Code
struct FeedView: View {
var body: some View {
let _ = Self._printChanges() // Prints which properties triggered re-eval
List { /* ... */ }
}
}Self._printChanges() is a built-in SwiftUI diagnostic — the fastest way to find outwhy a view is re-rendering. Use during development, remove before shipping.
11. Avoid Retain Cycles in Closures
Memory leaks are silent performance killers. The most common cause in SwiftUI apps is strongself references in stored closures (timers, Combine, NotificationCenter).
Before (Leak)
@Observable class ChatVM {
var messages: [Message] = []
private var timer: Timer?
func startPolling() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
Task { self.messages = try await self.api.fetchMessages() }
} // Strong self = retain cycle. deinit never called.
}
}After (No Leak)
@Observable class ChatVM {
var messages: [Message] = []
private var timer: Timer?
func startPolling() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
Task { [weak self] in
guard let self else { return }
self.messages = try await self.api.fetchMessages()
}
}
}
}Rule: Stored closures (timers, observers, subscriptions) need [weak self]. One-shot closures (.task, button actions) are fine with strong self.
12. Move Heavy Work Off the Main Thread
The main thread is for UI rendering. JSON parsing, image processing, sorting large arrays — all belong on a background thread.
Before (Blocks Main Thread)
TextField("Search...", text: $query)
.onChange(of: query) { _, newValue in
// Filtering 10,000 items on main thread
results = allItems.filter {
$0.title.localizedCaseInsensitiveContains(newValue)
}.sorted { $0.score > $1.score }
}After (Background Thread)
TextField("Search...", text: $query)
.onChange(of: query) { _, newValue in
searchTask?.cancel() // Debounce via cancellation
searchTask = Task.detached(priority: .userInitiated) {
let filtered = allItems.filter {
$0.title.localizedCaseInsensitiveContains(newValue)
}.sorted { $0.score > $1.score }
await MainActor.run { results = filtered }
}
}The searchTask?.cancel() pattern is debouncing via task cancellation. Only the final search executes — intermediate keystrokes are cancelled before they waste CPU.
13. Pre-render and Cache Expensive Computations
Anything computed inside body runs on every evaluation. Move expensive work like markdown parsing and date formatting onto the model.
Before (Recomputed every render)
Text(try! AttributedString(markdown: message.content)) // Parsed every eval
Text(message.createdAt.formatted(.relative(presentation: .named))) // Formatted every evalAfter (Pre-computed)
struct Message: Identifiable {
let id: String
let content: String
let createdAt: Date
lazy var attributedContent: AttributedString = {
(try? AttributedString(markdown: content)) ?? AttributedString(content)
}()
lazy var relativeDate: String = {
createdAt.formatted(.relative(presentation: .named))
}()
}
// In the view — already computed, zero cost
Text(message.attributedContent)
Text(message.relativeDate)14. Reduce View Body Complexity
Complex bodies with nested conditionals slow both compilation and runtime. Keep each body under 30-40 lines and use a ViewState enum instead of if/else chains.
Before (Complex body)
var body: some View {
ScrollView {
if vm.isLoading {
ProgressView()
} else if let error = vm.error {
Text(error.localizedDescription)
} else {
// 80+ lines of nested HStacks, VStacks, conditionals...
}
}
}After (Clean body)
var body: some View {
ScrollView {
VStack(spacing: 20) {
switch vm.viewState {
case .loading: LoadingIndicator()
case .error(let msg): ErrorBanner(message: msg)
case .loaded:
StatsRow(stats: vm.stats)
RevenueChart(data: vm.revenueData)
ActivityList(items: vm.activities)
}
}
}
.task { await vm.load() }
}
enum ViewState { case loading, error(String), loaded }15. Scope Environment Objects Narrowly
Injecting @Observable objects at the app root means every view in the tree is checked when that object changes. Inject at the lowest common ancestor of views that actually need the data.
Before (Broad scope)
@main struct MyApp: App {
@State private var cart = CartManager()
@State private var theme = ThemeManager()
var body: some Scene {
WindowGroup {
TabView { HomeTab(); SearchTab(); CartTab(); ProfileTab() }
.environment(cart) // Every tab sees cart
.environment(theme) // Every tab sees theme
}
}
}After (Narrow scope)
@main struct MyApp: App {
@State private var theme = ThemeManager()
var body: some Scene {
WindowGroup {
TabView { HomeTab(); SearchTab(); CartTab(); ProfileTab() }
.environment(theme) // Theme is truly global — fine
}
}
}
struct CartTab: View {
@State private var cart = CartManager() // Scoped to cart only
var body: some View {
CartScreen().environment(cart)
}
}Theme and auth that every view needs? App-level is fine. Cart state only the cart tab uses? Scope it there. The narrower the scope, the fewer views SwiftUI checks on state changes.
The Performance Optimization Checklist
Run through this before shipping. It takes 30 minutes and prevents user-facing jank:
- Lazy containers for all scrollable dynamic data
- Stable Identifiable IDs on all ForEach items
- Extracted subviews — complex views broken into focused structs
- @Observable — migrated from ObservableObject
- Equatable views on charts, Canvas, and custom drawing
- .task over .onAppear with load guards
- Downsampled + cached images at display size
- Lightweight list rows with pre-computed values
- drawingGroup() on complex overlapping renders
- Instruments profiling on a real device
- [weak self] in all stored closures
- Background processing for heavy computation
- Pre-computed markdown, dates, and formatting
- Simple view bodies under 30-40 lines
- Scoped environment at lowest necessary level
Always profile on a real device. The Simulator uses your Mac's CPU and does not represent iPhone performance. An app smooth in the Simulator can drop frames on an iPhone SE.
Ship a Pre-Optimized SwiftUI App
Every optimization in this guide is already baked into The Swift Kit. The boilerplate uses LazyVStack for all scrollable content, @Observablethroughout, properly scoped environment objects, background data loading with .task, downsampled images, extracted subviews, and a clean MVVM architecture that keeps view bodies simple by default.
Instead of retrofitting performance into an app that is already built, start with a foundation optimized from day one. See the features page for the full breakdown, check the documentation for architecture details, or grab it from pricing and start building on a 60fps foundation.
For more on how these patterns fit into a broader architecture, read our MVVM architecture guide, explore the design tokens system, or see the boilerplate guide for the full feature list.