SwiftUI Isn't Slow — Your Code Is

Published: (February 1, 2026 at 11:53 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

SwiftUI Rendering: What’s Fast and What’s Slow

SwiftUI’s rendering engine is fast. What slows it down is the work you ask it to redo hundreds of times per second.

How SwiftUI Works

SwiftUI doesn’t have a viewDidLoad that fires once. Instead it uses a declarative diffing system:

  1. State changes – you update an @State, @Binding, @ObservedObject, or any other source of truth.
  2. body is called – SwiftUI re‑evaluates body for every view that depends on that state.
  3. Diffing happens – SwiftUI compares the new view tree with the previous one.
  4. Only differences are rendered – if nothing changed, nothing is redrawn.

This is elegant and efficient when body is cheap to evaluate.

The problem? SwiftUI can’t know whether your body computation is lightweight. If you put expensive work inside body, it will be re‑run on every single state change.

Example: A Personal‑Finance Dashboard

struct DashboardView: View {
    @State private var selectedPeriod: Period = .thisMonth
    @State private var showDetails = false
    let transactions: [Transaction]

    var body: some View {
        // ALL of this recalculates on EVERY body call
        // — even when the user just toggles `showDetails`!
        let filtered = transactions.filter {
            $0.date >= selectedPeriod.startDate &&
            $0.date  $1.value }

        ScrollView {
            VStack(spacing: 16) {
                PeriodPicker(selection: $selectedPeriod)
                SummaryCard(title: "Total", value: totalSpent)
                SummaryCard(title: "Daily Avg", value: dailyAverage)
                ForEach(byCategory, id: \.key) { cat, amt in
                    CategoryRow(name: cat, amount: amt)
                }
                Button("Details") { withAnimation { showDetails.toggle() } }
                if showDetails { MerchantList(transactions: filtered) }
            }
        }
    }
}

The user taps “Details” → showDetails changes → body re‑evaluates → every aggregation re‑runs, even though the transaction data hasn’t changed.

Fix: Pre‑compute When the Period Changes

struct DashboardView: View {
    @State private var selectedPeriod: Period = .thisMonth
    @State private var showDetails = false
    @State private var summary: DashboardSummary?
    let transactions: [Transaction]

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                PeriodPicker(selection: $selectedPeriod)

                if let summary {
                    SummaryCard(title: "Total", value: summary.totalSpent)
                    SummaryCard(title: "Daily Avg", value: summary.dailyAverage)

                    ForEach(summary.categoryBreakdown, id: \.category) { item in
                        CategoryRow(name: item.category, amount: item.amount)
                    }

                    Button("Details") { withAnimation { showDetails.toggle() } }
                    if showDetails {
                        MerchantList(merchants: summary.topMerchants)
                    }
                } else {
                    ProgressView()
                }
            }
        }
        .task(id: selectedPeriod) {
            summary = await computeSummary(
                from: transactions,
                for: selectedPeriod
            )
        }
    }
}

Now body only reads a struct and maps it to views—no filtering, grouping, or sorting. Toggling showDetails is instant, and the heavy work runs only when selectedPeriod changes (off the main thread).

Common Performance Tips

1. Cache Expensive Objects

// ❌ New DateFormatter on every body call
Text(DateFormatter.localizedString(from: date,
                                   dateStyle: .long,
                                   timeStyle: .short))

// ✅ Static – created once
private static let formatter: DateFormatter = {
    let f = DateFormatter()
    // configure f …
    return f
}()
Text(Self.formatter.string(from: date))

The same applies to NumberFormatter, JSONDecoder, regular‑expression patterns, etc.—anything expensive but stateless.

2. Avoid .sorted() / .filter() Directly in body

// ❌ Sorts on every evaluation
List(items.sorted(by: { $0.date > $1.date })) { ... }

// ✅ Sort when data changes
@State private var sortedItems: [Item] = []

.onChange(of: items) { _, new in
    sortedItems = new.sorted(by: { $0.date > $1.date })
}

If you see these inside body, it’s a code smell. Move them to .onChange, .task, or a dedicated view model and store the result in @State.

3. Keep Views Small

// ❌ AvatarImage rebuilds when unrelated state changes
struct ProfileView: View {
    @State private var isEditing = false
    var body: some View {
        VStack {
            AvatarImage(url: user.avatarURL)   // unnecessary rebuild!
            Toggle("Edit", isOn: $isEditing)
        }
    }
}

// ✅ Extract it – now it only rebuilds when `avatarURL` changes
struct AvatarImage: View {
    let url: URL
    var body: some View {
        AsyncImage(url: url)
            .frame(width: 80, height: 80)
            .clipShape(Circle())
    }
}

Smaller, isolated views mean fewer unnecessary recomputations.

4. Prefer @Observable Over @ObservableObject (iOS 17+)

// ❌ With ObservableObject, any @Published change forces a full view refresh
class UserModel: ObservableObject {
    @Published var name: String
    @Published var bio: String
}

// ✅ @Observable tracks per‑property, so only the views that read a changed
// property are refreshed
@Observable class UserModel {
    var name: String
    var bio: String
}

Granular tracking reduces the amount of UI that needs to be redrawn.

5. Use .task(id:) for Asynchronous Work

// Runs off the main thread, cancels automatically when `id` changes
.task(id: selectedPeriod) {
    summary = await computeSummary(from: transactions,
                                    for: selectedPeriod)
}

This keeps heavy work out of body and ensures it’s cancelled if the relevant input changes.

TL;DR

  • body should be cheap – avoid heavy calculations, sorting, filtering, or object creation inside it.
  • Cache or pre‑compute expensive results when their inputs change (using @State, .task, .onChange, etc.).
  • Break large views into smaller sub‑views so only the parts that truly need to update are recomputed.
  • Leverage @Observable for per‑property change tracking (iOS 17+).
  • Run async work with .task(id:) to keep the main thread responsive.

By following these patterns, you let SwiftUI’s diffing engine do what it does best—render only what actually changed.

SwiftUI Performance Tips

“Treat body like it’s called 100 times per second. Because sometimes it is.”

Common Pitfall

// Fires on every body evaluation
let _ = Task { await loadData() }

// ✅ Only runs when `selectedCategory` changes
.task(id: selectedCategory) { await loadData(for: selectedCategory) }

How to Diagnose

  1. Open Instruments → SwiftUI template → View Body invocations.
  2. If a view’s body is called 200 times when you expect 2, you’ve found the problem.

Never optimize based on intuition—let the profiler show you what’s happening.

Best Practices

  • Keep body pure and lightweight: it should only read pre‑computed state and map it to views.
  • Avoid expensive work inside body. Move any heavy lifting to task, onAppear, or view models.
  • Use .task(id:) (or similar modifiers) to run async work only when its dependencies change.

Bottom Line

SwiftUI isn’t slow; it penalizes expensive work done in the wrong place.
Once you understand the rendering cycle, performance problems disappear, letting you stop blaming the framework and start shipping smooth apps.


Found this helpful? Follow me for more SwiftUI deep‑dives and drop your performance war stories in the comments!

Back to Blog

Related posts

Read more »

[SUI] Barra de búsqueda

Barra de búsqueda en NavigationStack Un NavigationStack puede incluir una barra de búsqueda mediante el modificador searchable. Su firma es: swift searchable t...