Use when UI is slow, scrolling lags, animations stutter, or when asking 'why is my SwiftUI view slow', 'how do I optimize List performance', 'my app drops frames', 'view body is called too often', 'List is laggy' - SwiftUI performance optimization with Instruments 26 and WWDC 2025 patterns
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Use when:
These are real questions developers ask that this skill is designed to answer:
→ The skill shows how to use the new SwiftUI Instrument in Instruments 26 to identify if SwiftUI is the bottleneck vs other layers
→ The skill covers the Cause & Effect Graph patterns that show data flow through your app and which state changes trigger expensive updates
→ The skill demonstrates unnecessary update detection and Identity troubleshooting with the visual timeline
→ The skill covers performance patterns: breaking down view hierarchies, minimizing body complexity, and using the @Sendable optimization checklist
→ The skill provides the decision tree for prioritizing optimizations and understands pressure scenarios with professional guidance for trade-offs
Core Principle: Ensure your view bodies update quickly and only when needed to achieve great SwiftUI performance.
NEW in WWDC 2025: Next-generation SwiftUI instrument in Instruments 26 provides comprehensive performance analysis with:
Key Performance Problems:
"Performance improvements to the framework benefit apps across all of Apple's platforms, from our app to yours." — WWDC 2025-256
SwiftUI in iOS 26 includes major performance wins that benefit all apps automatically. These improvements work alongside the new profiling tools to make SwiftUI faster out of the box.
List(trips) { trip in // 100k+ items
TripRow(trip: trip)
}
// iOS 26: Loads 6x faster, updates 16x faster on macOS
// All platforms benefit from performance improvements
SwiftUI has improved scheduling of user interface updates on iOS and macOS. This improves responsiveness and lets SwiftUI do even more work to prepare for upcoming frames. All in all, it reduces the chance of your app dropping a frame while scrolling quickly at high frame rates.
ScrollView(.horizontal) {
LazyHStack {
ForEach(photoSets) { photoSet in
ScrollView(.vertical) {
LazyVStack {
ForEach(photoSet.photos) { photo in
PhotoView(photo: photo)
}
}
}
}
}
}
// iOS 26: Nested scrollviews now properly delay loading with lazy stacks
// Great for photo carousels, Netflix-style layouts, multi-axis content
Before iOS 26 Nested ScrollViews didn't properly delay loading lazy stack content, causing all nested content to load immediately.
After iOS 26 Lazy stacks inside nested ScrollViews now delay loading until content is about to appear, matching the behavior of single-level ScrollViews.
The SwiftUI instrument now includes dedicated lanes for:
These lanes are covered in detail in the next section.
No code changes required — rebuild with iOS 26 SDK to get these improvements.
Cross-reference SwiftUI 26 Features — Comprehensive guide to all iOS 26 SwiftUI changes
Requirements:
Launch:
The SwiftUI template includes three instruments:
body property takes too longUpdates shown in orange and red based on likelihood to cause hitches:
Note: Whether updates actually result in hitches depends on device conditions, but red updates are the highest priority.
Frame 1:
├─ Handle events (touches, key presses)
├─ Update UI (run view bodies)
│ └─ Complete before frame deadline ✅
├─ Hand off to system
└─ System renders → Visible on screen
Frame 2:
├─ Handle events
├─ Update UI
│ └─ Complete before frame deadline ✅
├─ Hand off to system
└─ System renders → Visible on screen
Result: Smooth, fluid animations
Frame 1:
├─ Handle events
├─ Update UI
│ └─ ONE VIEW BODY TOO SLOW
│ └─ Runs past frame deadline ❌
├─ Miss deadline
└─ Previous frame stays visible (HITCH)
Frame 2: (Delayed)
├─ Handle events (delayed by 1 frame)
├─ Update UI
├─ Hand off to system
└─ System renders → Finally visible
Result: Previous frame visible for 2+ frames = animation stutter
Frame 1:
├─ Handle events
├─ Update UI
│ ├─ Update 1 (fast)
│ ├─ Update 2 (fast)
│ ├─ Update 3 (fast)
│ ├─ ... (100 more fast updates)
│ └─ Total time exceeds deadline ❌
├─ Miss deadline
└─ Previous frame stays visible (HITCH)
Result: Many small updates add up to miss deadline
Key Insight: View body runtime matters because missing frame deadlines causes hitches, making animations less fluid.
Reference:
Workflow:
What you see:
Finding the bottleneck:
❌ WRONG - Creating formatters in view body:
struct LandmarkListItemView: View {
let landmark: Landmark
@State private var userLocation: CLLocation
var distance: String {
// ❌ Creating formatters every time body runs
let numberFormatter = NumberFormatter()
numberFormatter.maximumFractionDigits = 1
let measurementFormatter = MeasurementFormatter()
measurementFormatter.numberFormatter = numberFormatter
let meters = userLocation.distance(from: landmark.location)
let measurement = Measurement(value: meters, unit: UnitLength.meters)
return measurementFormatter.string(from: measurement)
}
var body: some View {
HStack {
Text(landmark.name)
Text(distance) // Calls expensive distance property
}
}
}
Why it's slow:
✅ CORRECT - Cache formatters centrally:
@Observable
class LocationFinder {
private let formatter: MeasurementFormatter
private let landmarks: [Landmark]
private var distanceCache: [Landmark.ID: String] = [:]
init(landmarks: [Landmark]) {
self.landmarks = landmarks
// Create formatters ONCE during initialization
let numberFormatter = NumberFormatter()
numberFormatter.maximumFractionDigits = 1
self.formatter = MeasurementFormatter()
self.formatter.numberFormatter = numberFormatter
updateDistances()
}
func didUpdateLocations(_ locations: [CLLocation]) {
guard let location = locations.last else { return }
updateDistances(from: location)
}
private func updateDistances(from location: CLLocation? = nil) {
guard let location else { return }
for landmark in landmarks {
let meters = location.distance(from: landmark.location)
let measurement = Measurement(value: meters, unit: UnitLength.meters)
distanceCache[landmark.id] = formatter.string(from: measurement)
}
}
func distanceString(for landmarkID: Landmark.ID) -> String {
distanceCache[landmarkID] ?? "Unknown"
}
}
struct LandmarkListItemView: View {
let landmark: Landmark
@Environment(LocationFinder.self) private var locationFinder
var body: some View {
HStack {
Text(landmark.name)
Text(locationFinder.distanceString(for: landmark.id)) // ✅ Fast lookup
}
}
}
Benefits:
Complex Calculations:
// ❌ Don't calculate in view body
var body: some View {
let result = expensiveAlgorithm(data) // Complex math, sorting, etc.
Text("\(result)")
}
// ✅ Calculate in model, cache result
@Observable
class ViewModel {
private(set) var result: Int = 0
func updateData(_ data: [Int]) {
result = expensiveAlgorithm(data) // Calculate once
}
}
Network/File I/O:
// ❌ NEVER do I/O in view body
var body: some View {
let data = try? Data(contentsOf: fileURL) // ❌ Synchronous I/O
// ...
}
// ✅ Load asynchronously, store in state
@State private var data: Data?
var body: some View {
// Just read state
}
.task {
data = try? await loadData() // Async loading
}
Image Processing:
// ❌ Don't process images in view body
var body: some View {
let thumbnail = image.resized(to: CGSize(width: 100, height: 100))
Image(uiImage: thumbnail)
}
// ✅ Process images in background, cache
.task {
await processThumbnails()
}
After implementing fix:
Note: Updates at app launch may still be long (building initial view hierarchy) — this is normal and won't cause hitches during scrolling.
Even if individual updates are fast, too many updates add up:
100 fast updates × 2ms each = 200ms total
→ Misses 16.67ms frame deadline
→ Hitch
Scenario: Tapping a favorite button on one item updates ALL items in a list.
Expected: Only the tapped item updates. Actual: All visible items update.
How to find:
SwiftUI uses AttributeGraph to define dependencies and avoid re-running views unnecessarily.
struct OnOffView: View {
@State private var isOn: Bool = false
var body: some View {
Text(isOn ? "On" : "Off")
}
}
What SwiftUI creates:
isOn value (persists entire view lifetime)When state changes:
Purpose: Visualize what marked your view body as outdated.
Example graph:
[Gesture] → [State Change] → [View Body Update]
↓
[Other View Bodies]
Node types:
Selecting nodes:
Accessing graph:
Problem:
@Observable
class ModelData {
var favoritesCollection: Collection // Contains array of favorites
func isFavorite(_ landmark: Landmark) -> Bool {
favoritesCollection.landmarks.contains(landmark) // ❌ Depends on whole array
}
}
struct LandmarkListItemView: View {
let landmark: Landmark
@Environment(ModelData.self) private var modelData
var body: some View {
HStack {
Text(landmark.name)
Button {
modelData.toggleFavorite(landmark) // Modifies array
} label: {
Image(systemName: modelData.isFavorite(landmark) ? "heart.fill" : "heart")
}
}
}
}
What happens:
isFavorite(), accessing favoritesCollection.landmarks array@Observable creates dependency: Each view depends on entire arraytoggleFavorite(), modifying arrayCause & Effect Graph shows:
[Gesture] → [favoritesCollection.landmarks array change] → [All LandmarkListItemViews update]
✅ Solution — Granular Dependencies:
@Observable
class LandmarkViewModel {
var isFavorite: Bool = false
func toggleFavorite() {
isFavorite.toggle()
}
}
@Observable
class ModelData {
private(set) var viewModels: [Landmark.ID: LandmarkViewModel] = [:]
init(landmarks: [Landmark]) {
for landmark in landmarks {
viewModels[landmark.id] = LandmarkViewModel()
}
}
func viewModel(for landmarkID: Landmark.ID) -> LandmarkViewModel? {
viewModels[landmarkID]
}
}
struct LandmarkListItemView: View {
let landmark: Landmark
@Environment(ModelData.self) private var modelData
var body: some View {
if let viewModel = modelData.viewModel(for: landmark.id) {
HStack {
Text(landmark.name)
Button {
viewModel.toggleFavorite() // ✅ Only modifies this view model
} label: {
Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart")
}
}
}
}
}
Result:
Cause & Effect Graph shows:
[Gesture] → [Single LandmarkViewModel change] → [Single LandmarkListItemView update]
struct EnvironmentValues {
// Dictionary-like value type
var colorScheme: ColorScheme
var locale: Locale
// ... many more values
}
Each view has dependency on entire EnvironmentValues struct via @Environment property wrapper.
@Environment dependency notifiedCost: Even when body doesn't run, there's still cost of checking for updates.
Two types:
.environment() modifierExample:
View1 reads colorScheme:
[External Environment] → [View1 body runs] ✅
View2 reads locale (doesn't read colorScheme):
[External Environment] → [View2 body check] (body doesn't run - dimmed icon)
Same update shows as multiple nodes: Hover/click any node for same update → all highlight together.
⚠️ AVOID storing frequently-changing values in environment:
// ❌ DON'T DO THIS
struct ContentView: View {
@State private var scrollOffset: CGFloat = 0
var body: some View {
ScrollView {
// Content
}
.environment(\.scrollOffset, scrollOffset) // ❌ Updates on every scroll frame
.onPreferenceChange(ScrollOffsetKey.self) { offset in
scrollOffset = offset
}
}
}
Why it's bad:
✅ Better approach:
// Pass via parameter or @Observable model
struct ContentView: View {
@State private var scrollViewModel = ScrollViewModel()
var body: some View {
ScrollView {
ChildView(scrollViewModel: scrollViewModel) // Direct parameter
}
}
}
Environment is great for:
When performance issues appear in production, you face competing pressures:
.compositingGroup(), disable animation, simplify view)The issue: Quick fixes based on guesses fail 80% of the time and waste your deployment window.
If you hear ANY of these under deadline pressure, STOP and use SwiftUI Instrument:
Under production pressure, one good diagnostic recording beats random fixes:
Time Budget:
Total: 25 minutes to know EXACTLY what's slow
Then:
Total time: 1 hour 15 minutes for diagnosis + fix, leaving 4+ hours for edge case testing.
Time cost of being wrong:
Pressure scenario:
Bad approach (Option A):
Junior suggests: "Add .compositingGroup() to TabView"
You: "Sure, let's try it"
Result: Ships without profiling
Outcome: Doesn't fix issue (compositing wasn't the problem)
Next: 24 hours until next deploy window
VP update: "Users still complaining"
Good approach (Option B):
"Running one SwiftUI Instrument recording of tab transition"
[25 minutes later]
"SwiftUI Instrument shows Long View Body Updates in ProductGridView during transition.
Cause & Effect Graph shows ProductList rebuilding entire grid unnecessarily.
Applying view identity fix (`.id()`) to prevent unnecessary updates"
[30 minutes to implement and test]
"Deployed at 1.5 hours. Verified with Instruments. Tab transitions now smooth."
Sometimes managers are right to push for speed. Accept the pressure IF:
Document your decision:
Slack to VP + team:
"Completed diagnostic: ProductGridView rebuilding unnecessarily during
tab transitions (confirmed in SwiftUI Instrument, Long View Body Updates).
Applied view identity fix. Verified in Instruments - transitions now 16.67ms.
Deploying now."
This shows:
Honest admission:
"SwiftUI Instrument showed ProductGridView was the bottleneck.
Applied view identity fix, but performance didn't improve as expected.
Root cause is deeper than expected. Requiring architectural change.
Shipping animation disable (.animation(nil) on TabView) as mitigation.
Proper fix queued for next release cycle."
This is different from guessing:
| Question | Answer Yes? | Action |
|---|---|---|
| Have you run SwiftUI Instrument? | No | STOP - 25 min diagnostic |
| Do you know which view is expensive? | No | STOP - review Cause & Effect Graph |
| Can you explain in one sentence why the fix helps? | No | STOP - you're guessing |
| Have you verified the fix in Instruments? | No | STOP - test before shipping |
| Did you consider simpler explanations? | No | STOP - check documentation first |
Answer YES to all five → Ship with confidence
Problem: Updating one item updates entire list
Solution: Per-item view models with granular dependencies
// ❌ Shared dependency
@Observable
class ListViewModel {
var items: [Item] // All views depend on whole array
}
// ✅ Granular dependencies
@Observable
class ListViewModel {
private(set) var itemViewModels: [Item.ID: ItemViewModel]
}
@Observable
class ItemViewModel {
var item: Item // Each view depends only on its item
}
Problem: Expensive computation runs every render
Solution: Move to model, cache result
// ❌ Compute in view
struct MyView: View {
let data: [Int]
var body: some View {
Text("\(data.sorted().last ?? 0)") // Sorts every render
}
}
// ✅ Compute in model
@Observable
class ViewModel {
var data: [Int] {
didSet {
maxValue = data.max() ?? 0 // Compute once when data changes
}
}
private(set) var maxValue: Int = 0
}
struct MyView: View {
@Environment(ViewModel.self) private var viewModel
var body: some View {
Text("\(viewModel.maxValue)") // Just read cached value
}
}
Problem: Creating formatters repeatedly
Solution: Create once, reuse
// ❌ Create every time
var body: some View {
let formatter = DateFormatter()
formatter.dateStyle = .short
Text(formatter.string(from: date))
}
// ✅ Reuse formatter
class Formatters {
static let shortDate: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .short
return f
}()
}
var body: some View {
Text(Formatters.shortDate.string(from: date))
}
Problem: Rapidly-changing environment values
Solution: Use direct parameters or models
// ❌ Frequently changing in environment
.environment(\.scrollPosition, scrollPosition) // 60+ updates/second
// ✅ Direct parameter or model
ChildView(scrollPosition: scrollPosition)
Automatic improvements when building with Xcode 26 (no code changes needed):
Problem likely elsewhere:
Next steps:
Before optimization:
After optimization:
Improvements:
WWDC 2025 Sessions:
Related Documentation:
Other Skills:
swiftui-debugging-diag skillswiftui-debugging skillmemory-debugging skillxcode-debugging skillXcode: 26+ Platforms: iOS 26+, iPadOS 26+, macOS Tahoe+, visionOS 3+ History: See git log for changes