Skip to content
← All guides iOS

SwiftUI Performance Optimization Guide

Practical SwiftUI performance techniques for 2026 — avoid common pitfalls, minimize redraws, and ship fast, smooth iOS apps from real production experience.

SwiftUI Performance Optimization Guide

SwiftUI performance is the number-one engineering concern we hear from teams migrating from UIKit — and for good reason. The declarative model is elegant, but it introduces new ways to accidentally hammer the render loop. This guide distills what we’ve learned shipping a dozen+ apps on the App Store: where SwiftUI slows down, why it happens, and the specific techniques that fix it without sacrificing the expressive code that makes SwiftUI worth using.

Why SwiftUI Performance Feels Unpredictable

SwiftUI re-renders a view whenever its state changes. Unlike UIKit, where you imperatively call setNeedsLayout() on exactly the view you want to update, SwiftUI’s diffing engine automatically figures out what changed — and that’s mostly a feature, not a bug. The problem appears when state is scoped too broadly: a single property change in a top-level @Observable object can trigger a full subtree redraw even when most of that subtree doesn’t care about the change.

The practical result: an app that feels perfectly smooth at fifty items in a list starts dropping frames at five hundred, not because SwiftUI is fundamentally slow, but because the state graph was never designed with performance in mind.

Technique 1 — Contain State at the Right Level

The single highest-impact fix for most SwiftUI performance problems is moving state closer to the views that actually need it.

Common mistake:

// A massive app-level object that every view observes
@Observable class AppState {
    var currentUser: User
    var feedItems: [FeedItem]
    var notificationCount: Int
    var mapRegion: MKCoordinateRegion
    // ...20 more properties
}

When mapRegion changes, every view observing AppState re-evaluates — including the feed list and notification badge, which don’t care about maps.

Fix: split objects by domain. Pass only what a child view needs, and prefer passing value types (struct snapshots) into leaf views so they only redraw when their data actually changes.

Technique 2 — Use equatable Views Wisely

SwiftUI skips re-rendering a view if its input is identical to the last render. For views that receive value-type props, this often works automatically. For views doing non-trivial layout work, conforming to Equatable and using .equatable() gives SwiftUI explicit permission to skip the render:

struct FeedCard: View, Equatable {
    let item: FeedItem
    // SwiftUI can now skip this view when item hasn't changed
}

This is especially valuable inside List or LazyVStack where hundreds of cells exist in memory simultaneously.

Technique 3 — LazyVStack vs VStack Is Not a Trivial Choice

ContainerBehaviourUse when
VStackRenders all children immediatelySmall, fixed lists (< ~20 items)
LazyVStackRenders children on demand as they scroll into viewLong, dynamic feeds
ListLazy + built-in cell recyclingNative look; best for very large data sets
LazyVGridLazy columnsPhoto grids, card layouts

A VStack with 300 items measures and renders every single one at load time. Replacing it with LazyVStack is usually a one-line change that cuts initial render time dramatically for any data-driven screen.

Tip: Don’t reach for List purely for its laziness. List comes with implicit styling and separator behavior that can complicate custom designs. LazyVStack inside a ScrollView gives you the same lazy loading with full visual control.

Technique 4 — Profile Before You Optimize

Every technique in this guide has a cost — usually in code complexity. Before applying any of them, measure with Instruments (the SwiftUI template, or Time Profiler). The Xcode 16 SwiftUI view body hit counter tells you exactly which views are re-rendering on each state change.

A workflow we use on every project:

  1. Build the feature to be correct first.
  2. Run it on a physical device (the simulator hides real-world performance gaps).
  3. Open Instruments → SwiftUI template → interact with the suspect screen.
  4. Find the views with the highest body evaluation counts; those are your targets.

Without this step it’s easy to optimize the wrong view.

Technique 5 — Debounce Expensive Computations

For search fields and live filters, every keystroke triggers a state update, which triggers a re-render plus whatever filtering logic runs inside body. Move heavy work off the main actor and debounce the trigger:

.onChange(of: searchText) { _, newValue in
    Task {
        try await Task.sleep(for: .milliseconds(250))
        await viewModel.filter(query: newValue)
    }
}

Combine or Swift Concurrency both work here. The key is that body should never do O(n) work on every render. Computed properties that sort or filter large arrays belong in the view model, cached, and updated asynchronously.

Technique 6 — Images and Assets

Images are a silent frame-rate killer. A few rules:

  • Cache decoded images. SwiftUI’s AsyncImage re-decodes from scratch on every appearance unless you cache the decoded UIImage yourself or use a library like Kingfisher / Nuke.
  • Match display size. Downloading a 1200×1200 image and displaying it at 60×60 wastes memory and decoding time. Resize on the server or use thumbnail APIs.
  • Use .drawingGroup() sparingly. It rasterizes a view hierarchy to a Metal texture, which can help with complex shadows and blurs — but it flattens interactivity and increases memory usage.

Technique 7 — Animations and the Render Loop

SwiftUI animations are GPU-accelerated and generally free, but there are traps:

  • Animating a property that forces layout (e.g., frame changes on many sibling views) can still cause CPU work.
  • Deeply nested withAnimation blocks accumulate. Keep animation scopes tight.
  • Use .animation(.none) explicitly to opt specific sub-views out of a parent animation when they shouldn’t be part of it.

How This Fits Into Architecture

These techniques aren’t bolt-on patches — they work best when performance is baked into architecture from day one. The patterns we consistently apply across our iOS and cross-platform projects are:

  • Single-responsibility observable objects scoped to their feature, not the whole app.
  • View models that own all data transformation and expose simple, Equatable value types to the view layer.
  • Lazy loading as the default for any list with unknown or unbounded content.

A well-scoped architecture makes the SwiftUI engine’s diffing genuinely efficient rather than fighting it at every screen.

Quick Reference

ProblemFix
Whole screen re-renders on small state changeSplit @Observable objects by domain
Long list stutters on scrollSwitch VStackLazyVStack or List
Search triggers janky filterDebounce + async filter in view model
Image cells drop framesCache decoded UIImage, resize to display size
Complex blur/shadow is slowTry .drawingGroup() on the affected view
Not sure what’s slowProfile with Instruments SwiftUI template first

What We’ve Seen in Production

In Launchcast — our space-launch tracker — a feed of upcoming launches with live countdown timers and rich cards initially caused visible stuttering because a top-level timer object was updating every second and re-rendering every card. The fix was isolating the timer state to each card’s own observable, so only the countdown label re-rendered, not the entire feed. Frame rate went from ~45 fps to a steady 60 fps with a single architectural change and no visual regression.

That pattern — isolate, measure, fix — is faster than any amount of speculative optimization.


Building a SwiftUI app and running into performance issues, or want to get the architecture right from the start? Tell us about it — we’re happy to take a look.

Building something like this?

Fera Tech ships iOS & full-stack apps end-to-end. Tell us about your project.

Start a project
Call us Open business Telegram