SwiftUI's rendering engine is fast. What's slow is the work you're asking it to redo hundreds of times per second.
How SwiftUI Actually Renders Your Views
SwiftUI doesn't work like UIKit. There's no viewDidLoad that fires once. Instead, SwiftUI uses a declarative diffing system:
-
State changes. You update an
@State,@Binding,@ObservedObject, or any other source of truth. -
bodyis called. SwiftUI re-evaluatesbodyfor every view that depends on that state. - Diffing happens. SwiftUI compares the new view tree with the previous one.
- Only differences are rendered. If nothing changed, nothing is redrawn.
This is elegant and efficient — when body is cheap to evaluate.
The problem? SwiftUI has no way to know whether your body computation is lightweight or not. It trusts you. If you put expensive work inside body, it will faithfully re-run that work on every. single. state change.
The #1 Performance Killer: Expensive Work in body
Imagine a personal finance dashboard — total spent, daily average, category breakdown, top merchants. The user can switch between "This Month" and "Last Month," and expand a details section.
The Slow Version
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 <= selectedPeriod.endDate
}
let totalSpent = filtered.reduce(0) { $0 + $1.amount }
let dailyAverage = totalSpent / Double(selectedPeriod.numberOfDays)
let byCategory = Dictionary(grouping: filtered, by: \.category)
.mapValues { $0.reduce(0) { $0 + $1.amount } }
.sorted { $0.value > $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. The user just wanted to expand a section and paid for a full recalculation.
The Fast Version
Precompute once when the period changes. Let body just read the results.
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 just reads a struct and maps it to views — no filtering, no grouping, no sorting. Toggling showDetails is instant. The heavy work only re-runs when selectedPeriod changes, off the main thread.
Quick Tips to Keep body Fast
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 = { /* configure */ }()
Text(Self.formatter.string(from: date))
Same goes for NumberFormatter, JSONDecoder, regex patterns — anything expensive but stateless.
2. No .sorted() or .filter() in body
If you see these inside body, it's a code smell. Move them to .onChange or .task and store the result in @State.
// Sorts on every evaluation
List(items.sorted(by: { $0.date > $1.date })) { ... }
// Sort when data changes
.onChange(of: items) { _, new in sortedItems = new.sorted(by: { $0.date > $1.date }) }
3. Break large views into small ones
SwiftUI only re-evaluates body for views whose observed state changed. Smaller views = fewer unnecessary recomputations.
// AvatarImage rebuilds when isEditing 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()) }
}
4. Prefer @Observable over @ObservableObject
With ObservableObject, any @Published change re-evaluates every observing view. @Observable (iOS 17+) tracks per-property, so views only update when the properties they actually read change.
// Changing bio re-renders views that only read name
class UserModel: ObservableObject {
@Published var name: String
@Published var bio: String
}
// Granular tracking
@Observable class UserModel {
var name: String
var bio: String
}
5. Use .task(id:) for async work
It runs off body, cancels automatically on id change, and keeps your view clean.
// Fires on every body evaluation
let _ = Task { await loadData() }
// ✅ Only runs when selectedCategory changes
.task(id: selectedCategory) { await loadData(for: selectedCategory) }
6. Profile before you guess
Open Instruments → SwiftUI template → View Body invocations. If a view's body is called 200 times when you expect 2, you've found your problem. Don't optimize based on intuition — let the profiler show you.
The Mental Model
Treat
bodylike it's called 100 times per second. Because sometimes it is.
Your body should be a pure, lightweight function that reads precomputed state and maps it to views. Nothing more.
SwiftUI isn't slow. It just punishes you for doing expensive things in the wrong place. Once you understand the rendering cycle, the performance problems disappear — and you can stop blaming the framework and start shipping smooth apps.
Found this helpful? Follow me for more SwiftUI deep-dives. Drop your performance war stories in the comments.
Top comments (2)
Your body should be a pure, lightweight function that reads precomputed state and maps it to views. Nothing more.
This is good information to know swift template