The Swift Kit logoThe Swift Kit
Guide

SwiftUI Performance Optimization — 15 Tips for Buttery Smooth 60fps Apps

Your SwiftUI app should never stutter, hitch, or drop frames. These 15 battle-tested optimization techniques cover everything from lazy loading and view identity to Instruments profiling and memory management — with before/after code for every tip.

Ahmed GaganAhmed Gagan
13 min read

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

ProblemSymptomFixTip
VStack with 1000+ itemsSlow launch, memory spikeUse LazyVStack/LazyHStack1
Unstable view IDsScroll hitches, broken animationsUse stable Identifiable IDs2
Parent state redraws childrenUnnecessary body evaluationsExtract subviews into structs3
ObservableObject triggers all viewsGlobal redraws on any changeMigrate to @Observable4
Full-res images in listsMemory spikes, choppy scrollDownsample and cache7
Heavy computation in bodyDropped framesMove to ViewModel / .task14
Broad EnvironmentObjectCascade redrawsScope narrowly15

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 VM

After (Fast)

@Observable
class SettingsVM {
    var username = ""
    var darkMode = false
    var fontSize: Double = 16
}
// Changing darkMode ONLY redraws views that read darkMode

5. 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 image

After (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 layers

After (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 layer

Do 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:

InstrumentWhat It MeasuresWhen to UseKey Metrics
SwiftUI InstrumentsBody evaluations, update counts, identity changesFirst stop for any SwiftUI issueBody count, update duration
Time ProfilerCPU time per function, call treesGeneral slownessWeight %, self time
Core AnimationFPS, offscreen rendering, blendingScroll hitches, animation dropsFPS counter, color overlays
AllocationsMemory usage, object lifetimesMemory growth during scrollingLive bytes, transient allocations
LeaksRetain cycles, leaked objectsMemory grows but never shrinksLeak count, object graph
Animation HitchesCommit and render hitch durationAnimations feel jankyHitch duration (ms)
NetworkRequest timing, payload sizesSlow data loading blocking UILatency, 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 eval

After (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:

  1. Lazy containers for all scrollable dynamic data
  2. Stable Identifiable IDs on all ForEach items
  3. Extracted subviews — complex views broken into focused structs
  4. @Observable — migrated from ObservableObject
  5. Equatable views on charts, Canvas, and custom drawing
  6. .task over .onAppear with load guards
  7. Downsampled + cached images at display size
  8. Lightweight list rows with pre-computed values
  9. drawingGroup() on complex overlapping renders
  10. Instruments profiling on a real device
  11. [weak self] in all stored closures
  12. Background processing for heavy computation
  13. Pre-computed markdown, dates, and formatting
  14. Simple view bodies under 30-40 lines
  15. 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.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit