
Swiftui Performance
- 3.2k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
swiftui-performance is a skill for auditing and improving SwiftUI runtime performance through code review, Instruments traces, and targeted remediation patterns.
About
SwiftUI Performance provides an end-to-end audit workflow from code-first review through Instruments profiling, diagnosis, remediation, and verified before-after metrics. The decision tree starts with supplied view code and data flow or asks for minimal reproduction context when only symptoms are described. Code-first review targets view invalidation storms from broad observable state, unstable ForEach identity including UUID per render, top-level if-else root swapping, heavy sorting or formatting inside body, layout thrash from deep stacks and GeometryReader chains, undownsized images, and over-animated hierarchies. Instruments guidance uses the SwiftUI template on Release builds with real devices, capturing SwiftUI View Body lanes, View Properties state tracking, Time Profiler, and Hangs during exact scroll or navigation reproduction. Remediation patterns narrow state scope with leaf-level State or Observable, stabilize list identities, precompute filtered collections on change, apply equatable wrappers, and downsample images off the main thread. Outputs include a metrics table, top issues ordered by impact, and proposed fixes with effort estimates.
- Five-phase workflow: code review, Instruments profiling, diagnosis, remediation, and verified metrics.
- Detects invalidation storms, unstable ForEach identity, and heavy work inside body evaluations.
- Instruments guidance targets Release builds on real devices with SwiftUI and Time Profiler lanes.
- Fixes include leaf-scoped state, precomputed collections, equatable wrappers, and image downsampling.
- Documents common smells like formatters in body, sorting in ForEach, and root view swapping.
Swiftui Performance by the numbers
- 3,153 all-time installs (skills.sh)
- +151 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #44 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
swiftui-performance capabilities & compatibility
- Capabilities
- code first swiftui performance review with smell · instruments swiftui and time profiler capture gu · identity stabilization and state scope narrowing · body evaluation cost reduction via precompute an · before after metrics comparison and prioritized
- Use cases
- debugging · testing · frontend
- Platforms
- macOS
What swiftui-performance says it does
Audit SwiftUI view performance end-to-end, from instrumentation and baselining to root-cause analysis
DON'T: sorts or filters on every body evaluation
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-performanceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.2k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
Why is my SwiftUI view janky, scrolling slowly, or using excessive CPU and how do I find and fix the root cause?
Audit SwiftUI rendering performance via code review, Instruments profiling, identity stabilization, and remediation of body-evaluation and layout thrash issues.
Who is it for?
iOS developers debugging slow SwiftUI rendering, scroll jank, or high CPU on real device Release builds.
Skip if: Skip for UIKit-only apps, Android development, or greenfield feature implementation without performance symptoms.
When should I use this skill?
User reports slow SwiftUI rendering, janky scrolling, high CPU, memory usage, layout thrash, or requests a performance audit.
What you get
Prioritized issue list with code references, applied fixes for identity and body cost, and before-after metrics from repeated Instruments captures.
- prioritized performance issue report
- code remediation patches
- before-after metrics table
By the numbers
- 5-step audit workflow from code review through verify
- 12-item SwiftUI performance review checklist
- 8 documented common SwiftUI performance mistakes
Files
SwiftUI Performance
Audit SwiftUI view performance end-to-end, from instrumentation and baselining to root-cause analysis and concrete remediation steps.
Contents
- Workflow Decision Tree
- 1. Code-First Review
- 2. Guide the User to Profile
- 3. Analyze and Diagnose
- 4. Remediate
- Common Code Smells (and Fixes)
- 5. Verify
- Outputs
- Instruments Profiling
- Identity and Lifetime
- Lazy Loading Patterns
- State and Observation Optimization
- Common Mistakes
- Review Checklist
- References
Workflow Decision Tree
- If the user provides code, start with "Code-First Review."
- If the user only describes symptoms, ask for minimal code/context, then do "Code-First Review."
- If code review is inconclusive, go to "Guide the User to Profile" and ask for a trace or screenshots.
1. Code-First Review
Collect:
- Target view/feature code.
- Data flow: state, environment, observable models.
- Symptoms and reproduction steps.
Focus on:
- View invalidation storms from broad state changes.
- Unstable identity in lists (
idchurn,UUID()per render). - Top-level conditional view swapping (
if/elsereturning different root branches). - Heavy work in
body(formatting, sorting, image decoding). - Layout thrash (deep stacks,
GeometryReader, preference chains). - Large images without downsampling or resizing.
- Over-animated hierarchies (implicit animations on large trees).
Provide:
- Likely root causes with code references.
- Suggested fixes and refactors.
- If needed, a minimal repro or instrumentation suggestion.
2. Guide the User to Profile
Explain how to collect data with Instruments:
- Use the SwiftUI template in Instruments.
- Profile a Release build on a real device when possible.
- Reproduce the exact interaction (scroll, navigation, animation).
- Capture SwiftUI timeline and Time Profiler.
- Export or screenshot the relevant lanes and the call tree.
Ask for:
- Trace export or screenshots of SwiftUI lanes + Time Profiler call tree.
- Device/OS/build configuration.
3. Analyze and Diagnose
Prioritize likely SwiftUI culprits:
- View invalidation storms from broad state changes.
- Unstable identity in lists (
idchurn,UUID()per render). - Top-level conditional view swapping (
if/elsereturning different root branches). - Heavy work in
body(formatting, sorting, image decoding). - Layout thrash (deep stacks,
GeometryReader, preference chains). - Large images without downsampling or resizing.
- Over-animated hierarchies (implicit animations on large trees).
Summarize findings with evidence from traces/logs.
4. Remediate
Apply targeted fixes:
- Narrow state scope (
@State/@Observablecloser to leaf views). - Stabilize identities for
ForEachand lists. - Move heavy work out of
body(precompute, cache,@State). - Use
equatable()or value wrappers for expensive subtrees. - Downsample images before rendering.
- Reduce layout complexity or use fixed sizing where possible.
Common Code Smells (and Fixes)
Look for these patterns during code review.
Expensive formatters in body
var body: some View {
let number = NumberFormatter() // slow allocation
let measure = MeasurementFormatter() // slow allocation
Text(measure.string(from: .init(value: meters, unit: .meters)))
}Prefer cached formatters in a model or a dedicated helper:
final class DistanceFormatter {
static let shared = DistanceFormatter()
let number = NumberFormatter()
let measure = MeasurementFormatter()
}Computed properties that do heavy work
var filtered: [Item] {
items.filter { $0.isEnabled } // runs on every body eval
}Prefer precompute or cache on change:
@State private var filtered: [Item] = []
// update filtered when inputs changeSorting/filtering in body or ForEach
// DON'T: sorts or filters on every body evaluation
ForEach(items.sorted(by: sortRule)) { item in Row(item) }
ForEach(items.filter { $0.isEnabled }) { item in Row(item) }Prefer precomputed, cached collections with stable identity. Update on input change, not in body.
Unstable identity
ForEach(items, id: \.self) { item in
Row(item)
}Avoid id: \.self for non-stable values; use a stable ID.
Top-level conditional view swapping
var content: some View {
if isEditing {
editingView
} else {
readOnlyView
}
}Prefer one stable base view and localize conditions to sections/modifiers (for example inside toolbar, row content, overlay, or disabled). This reduces root identity churn and helps SwiftUI diffing stay efficient.
Image decoding on the main thread
Image(uiImage: UIImage(data: data)!)Prefer decode/downsample off the main thread and store the result.
Broad dependencies in observable models
@Observable class Model {
var items: [Item] = []
}
var body: some View {
Row(isFavorite: model.items.contains(item))
}Prefer granular view models or per-item state to reduce update fan-out.
5. Verify
Ask the user to re-run the same capture and compare with baseline metrics. Summarize the delta (CPU, frame drops, memory peak) if provided.
Outputs
Provide:
- A short metrics table (before/after if available).
- Top issues (ordered by impact).
- Proposed fixes with estimated effort.
Instruments Profiling
Use the SwiftUI instrument template in Xcode (Cmd+I to profile). Key instruments: SwiftUI View Body (body evaluation counts), SwiftUI View Properties (state change tracking), Time Profiler, and Hangs.
Add Self._printChanges() in debug builds to log which property triggered a view update:
var body: some View {
#if DEBUG
let _ = Self._printChanges() // "MyView: @self, _count changed."
#endif
Text("Count: \(count)")
}See references/optimizing-swiftui-performance-instruments.md for the full profiling workflow.
Identity and Lifetime
Structural Identity vs Explicit Identity
SwiftUI assigns every view an identity used to track its lifetime, state, and animations.
- Structural identity (default): determined by the view's position in the view hierarchy. SwiftUI uses the call-site location in
bodyto distinguish views. - Explicit identity: you assign with
.id(_:)modifier orForEach(items, id: \.stableID).
// Structural identity: SwiftUI knows these are different views by position
VStack {
Text("First") // position 0
Text("Second") // position 1
}How Identity Tracks View Lifetime
When a view's identity changes, SwiftUI treats it as a new view:
- All
@Stateis reset. onAppearfires again.- Animations may restart.
- Transition animations play (if defined).
When identity stays the same, SwiftUI updates the existing view in place, preserving state and providing smooth transitions.
AnyView and Identity Reset
AnyView erases type information, forcing SwiftUI to fall back to less efficient diffing:
// DON'T: AnyView destroys type identity
func makeView(for item: Item) -> AnyView {
if item.isPremium {
return AnyView(PremiumRow(item: item))
} else {
return AnyView(StandardRow(item: item))
}
}
// DO: use @ViewBuilder to preserve structural identity
@ViewBuilder
func makeView(for item: Item) -> some View {
if item.isPremium {
PremiumRow(item: item)
} else {
StandardRow(item: item)
}
}AnyView also prevents SwiftUI from detecting which branch changed, causing full subtree replacement instead of targeted updates.
Ternary Modifiers Preserve Structural Identity
if/else in a view builder creates _ConditionalContent — two separate view branches with distinct identities. When the condition changes, SwiftUI destroys one branch and creates the other, resetting all @State.
For toggling modifiers on the same view, use a ternary expression instead:
// DON'T: if/else creates two separate Text views with different identities
if isHighlighted {
Text(title).foregroundStyle(.yellow)
} else {
Text(title).foregroundStyle(.primary)
}
// DO: ternary keeps one Text view, just changes the modifier value
Text(title)
.foregroundStyle(isHighlighted ? .yellow : .primary)This preserves the view's identity (and its state) across the condition change, and SwiftUI can animate the transition smoothly.
Use if/else when the view type itself differs between branches. Use ternary when only a property or modifier changes.
id() Modifier Impacts
The .id() modifier assigns explicit identity. Changing the value destroys and recreates the view:
// DON'T: UUID() changes every render, destroying and recreating the view each time
ScrollView {
LazyVStack {
ForEach(items) { item in
Row(item: item)
.id(UUID()) // kills performance -- new identity every render
}
}
}
// DO: use a stable identifier
ForEach(items) { item in
Row(item: item)
.id(item.stableID) // identity only changes when the item actually changes
}Intentional .id() change is useful for resetting state (e.g., .id(selectedTab) to reset a scroll position when switching tabs).
Lazy Loading Patterns
LazyVStack and LazyHStack
Lazy stacks only create views for items currently visible on screen. Off-screen items are not evaluated until scrolled into view.
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}Key behaviors:
- Views are created lazily but not destroyed when scrolled off screen (they remain in memory).
onAppearfires when the view first enters the visible area.onDisappearfires when it leaves, but the view is still alive.
LazyVGrid and LazyHGrid
Use lazy grids for multi-column layouts:
// Adaptive: as many columns as fit with minimum width
let columns = [GridItem(.adaptive(minimum: 150))]
ScrollView {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
PhotoThumbnail(photo: photo)
}
}
}
// Fixed: exact number of equal columns
let fixedColumns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
]When to Use Lazy vs Eager Stacks
| Scenario | Use |
|---|---|
| < 50 items | VStack / HStack (eager is fine) |
| 50-100 items | Either works; prefer Lazy if items are complex |
| > 100 items | LazyVStack / LazyHStack (required for performance) |
| Always-visible content | VStack (no benefit to lazy) |
| Scrollable lists | LazyVStack inside ScrollView, or List |
Important: Do not nest GeometryReader inside lazy containers. It forces eager measurement and defeats lazy loading. Use .onGeometryChange (iOS 16+) instead.
State and Observation Optimization
@Observable Granular Tracking
@Observable (Observation framework, iOS 17+) tracks property access at the per-property level. A view only re-evaluates when properties it actually read in body change:
@Observable class UserProfile {
var name: String = ""
var avatarURL: URL?
var biography: String = ""
}
// This view ONLY re-renders when `name` changes -- not when
// biography or avatarURL change, because it only reads `name`
struct NameLabel: View {
let profile: UserProfile
var body: some View {
Text(profile.name)
}
}This is a significant improvement over ObservableObject + @Published, which invalidates all observing views when any published property changes.
Avoiding Observation Scope Pollution
If a view reads many properties from an @Observable model in body, it re-renders when any of those properties change. Push reads into child views to narrow the scope:
// DON'T: reads name, email, avatar, and settings in one body
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
Text(model.name) // tracks name
Text(model.email) // tracks email
AsyncImage(url: model.avatar) // tracks avatar
SettingsForm(model.settings) // tracks settings
}
}
}
// DO: split into child views so each only tracks what it reads
struct ProfileView: View {
let model: ProfileModel
var body: some View {
VStack {
NameRow(model: model) // only tracks name
EmailRow(model: model) // only tracks email
AvatarView(model: model) // only tracks avatar
SettingsForm(model: model) // only tracks settings
}
}
}Computed Properties for Derived State
Use computed properties on @Observable models to derive state without introducing extra stored properties that widen observation scope:
@Observable class ShoppingCart {
var items: [CartItem] = []
// Views reading `total` only re-render when `items` changes
var total: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
}Common Mistakes
1. Profiling Debug builds. Debug builds include extra runtime checks and disable optimizations, producing misleading perf data. Profile Release builds on a real device. 2. Observing an entire model when only one property is needed. Break large @Observable models into focused ones, or use computed properties/closures to narrow observation scope. 3. Using `GeometryReader` inside ScrollView items. GeometryReader forces eager sizing and defeats lazy loading. Prefer .onGeometryChange (iOS 16+) or measure outside the lazy container. 4. Calling `DateFormatter()` or `NumberFormatter()` inside `body`. These are expensive to create. Make them static or move them outside the view. 5. Animating non-equatable state. If SwiftUI cannot determine equality, it redraws every frame. Conform state to Equatable, then use .animation(_:value:) for simple value-bound changes or .animation(_:body:) for narrower modifier-scoped implicit animation. 6. Large flat `List` without identifiers. Use id: or make items Identifiable so SwiftUI can diff efficiently instead of rebuilding the entire list. 7. Unnecessary `@State` wrapper objects. Wrapping a simple value type in a class for @State defeats value semantics. Use plain @State with structs. 8. Blocking `MainActor` with synchronous I/O. File reads, JSON parsing of large payloads, and image decoding should happen off the main actor. Use Task.detached or a custom actor.
Review Checklist
- [ ] No
DateFormatter/NumberFormatterallocations insidebody - [ ] Large lists use
Identifiableitems or explicitid: - [ ]
@Observablemodels expose only the properties views actually read - [ ] Heavy computation is off
MainActor(image processing, parsing) - [ ]
GeometryReaderis not inside aLazyVStack/LazyHStack/List - [ ] Implicit animations use
.animation(_:value:)for value-bound changes or.animation(_:body:)for narrower modifier scope - [ ] No synchronous network/file I/O on the main thread
- [ ] Profiling done on Release build, real device
- [ ]
@Observableview models are@MainActor-isolated; types crossing concurrency boundaries areSendable
References
- Demystify SwiftUI performance (WWDC23): references/demystify-swiftui-performance-wwdc23.md
- Optimizing SwiftUI performance with Instruments: references/optimizing-swiftui-performance-instruments.md
- Understanding hangs in your app: references/understanding-hangs-in-your-app.md
- Understanding and improving SwiftUI performance: references/understanding-improving-swiftui-performance.md
- WWDC transcript sources: references/wwdc-session-sources.md
Demystify SwiftUI Performance (WWDC23) (Summary)
Context: WWDC23 session on building a mental model for SwiftUI performance and triaging hangs, hitches, and excessive update work.
Contents
- Core mental model
- Dependencies and invalidation
- Expensive body work
- Identity rules for lists and tables
- Initialization and lifecycle pitfalls
- Debugging tools
- Fix patterns
- What to verify after a change
Core mental model
SwiftUI performance starts with one rule: only work that is required for the current state should happen for the current frame.
A slow screen usually means one of these is false:
- too much work happens per update
- updates happen too often
- identity is unstable, so SwiftUI redoes work it could have reused
The session's practical loop is:
- Measure
- Identify
- Optimize
- Re-measure
Do not skip the last step. SwiftUI optimizations are easy to misjudge by eye.
Dependencies and invalidation
A view updates when one of its dependencies changes.
Common dependency sources:
@State@Binding@Observable/@ObservedObject@Environment- container-derived identity (
ForEach,List,Table)
The performance goal is not "fewer dependencies" in the abstract. The goal is precise dependencies so only the view that needs to update actually updates.
Practical implications
- Avoid a row depending on a whole collection if it only needs one element.
- Avoid broad environment-driven updates for fast-changing values.
- Extract subviews when a smaller view can read a smaller state surface.
Debug-only dependency inspection
Self._printChanges() is useful in debug builds when you are not sure why a view keeps updating.
Use it to answer:
- which property changed?
- which parent view re-rendered?
- is the view reacting to state it should not care about?
Do not treat _printChanges() output as a shipping-time profiling tool.
Expensive body work
View bodies need to stay cheap.
Typical mistakes:
- string formatting in
body - array filtering and sorting in
body - expensive image work in
body - constructing large attributed strings during render
- initializing heavy models in-line with view creation
// DON'T
var body: some View {
List(items.filter(shouldShow).sorted(by: sortRule)) { item in
Text(numberFormatter.string(from: item.value as NSNumber) ?? "")
}
}
// DO
var body: some View {
List(viewModel.visibleItems) { item in
Text(item.formattedValue)
}
}The winning pattern is precomputation at the model boundary, not clever work in body.
Identity rules for lists and tables
Identity is one of the biggest hidden performance levers in SwiftUI.
Stable identity matters
Use stable IDs that survive refreshes and sorting. If identity churns, SwiftUI cannot reuse rows, preserve animations, or diff efficiently.
Constant row count matters
Inside ForEach, SwiftUI expects a predictable mapping between data elements and rendered views.
Avoid patterns like:
ForEach(items) { item in
if item.isVisible {
Row(item: item)
}
}Prefer:
ForEach(visibleItems) { item in
Row(item: item)
}Avoid AnyView in hot list rows
Type erasure can hide useful structural information and increase work in large lists or tables.
Table-specific note
TableRow resolves to a single row. Keep row structure predictable and use the streamlined Table APIs when possible.
Initialization and lifecycle pitfalls
Heavy model creation in view init/body
Keep view initialization lightweight. Start async work with .task or from a model object.
// DON'T
struct DetailView: View {
let loader = BigLoader() // heavy construction
}
// DO
struct DetailView: View {
@State private var model: DetailModel?
var body: some View {
content
.task {
model = await loadDetailModel()
}
}
}Hidden work from computed properties
A computed property can still be body work if it runs during render. If it is expensive, treat it like body work and precompute it.
Debugging tools
Instruments
Use Instruments for hangs, hitches, update counts, and expensive frames.
_printChanges()
Use it in debug to inspect dependency behavior.
Release-build validation
A debug build can make SwiftUI performance look worse or different than a shipping build. Validate important performance changes in Release on device.
Fix patterns
Split views by dependency boundary
If one small piece of state changes frequently, isolate the subview that reads it.
Pre-filter and cache collections
Do filtering, mapping, sorting, and grouping before rendering.
Avoid broad environment reads in hot paths
Environment is convenient but not free. Keep fast-changing values local unless multiple subtrees truly need them.
Reduce hidden allocations
Move formatters, bundle lookups, and derived strings out of repeated body paths.
What to verify after a change
After optimizing, check all of the following:
- the target interaction feels smoother
- update counts dropped in Instruments
- row identity stayed stable across reloads
- animation behavior still matches product intent
- no correctness bugs were introduced by caching or splitting views
A performance fix that breaks state ownership or animation correctness is not a real fix.
Optimizing SwiftUI Performance with Instruments (Summary)
Context: WWDC session introducing the SwiftUI Instrument in Instruments 26 and how to diagnose SwiftUI-specific bottlenecks.
Contents
- When to use the SwiftUI Instrument
- Recommended capture setup
- Reading the SwiftUI timeline
- Workflow for long view body updates
- Workflow for frequent updates
- Cause and Effect Graph usage
- Common hotspots
- Fix patterns
- Re-measure checklist
When to use the SwiftUI Instrument
Use the SwiftUI Instrument when the symptom is clearly tied to view updates, layout, rendering, or state fan-out.
Good fits:
- scrolling stutters in a SwiftUI-heavy screen
- pushing a destination causes a pause during view construction
- an animation feels inconsistent even without obvious main-thread blocking
- a timer or observable object causes too many updates
If the symptom is a broad app freeze with no clear UI source, start with Hangs or Time Profiler first, then move to SwiftUI-specific tools.
Recommended capture setup
Profile with the same assumptions you use for shipping code:
- Release build
- real device
- reproducible interaction sequence
- minimal debug logging
- enough repetitions to show a pattern, not a one-off spike
A good baseline template is:
- SwiftUI Instrument
- Time Profiler
- Hangs or Animation Hitches when relevant
Reading the SwiftUI timeline
Key lanes to understand:
Update Groups
High-level buckets of SwiftUI work over time. Useful for spotting bursts of activity even when no single update is individually slow.
Long View Body Updates
Use this lane when a view body itself is expensive.
Interpretation:
- orange indicates notable cost
- red indicates clearly too-slow view updates
Long Platform View Updates
This usually points to UIKit/AppKit work hosted inside SwiftUI, including:
UIViewRepresentableUIViewControllerRepresentable- heavy
List/table bridging - embedded media or web views
Other Long Updates
Catches expensive work outside pure body computation, such as:
- text layout
- geometry
- list diffing
- update coordination
Workflow for long view body updates
1. Find a red or orange update
Start with one obviously expensive update rather than a long trace window.
2. Set inspection range
Select the update window so Time Profiler aligns with the same time slice.
3. Inspect the call tree or flame graph
Look for work that should not be happening during body evaluation.
Common surprises:
- formatter allocation
- sorting or filtering collections
- image decoding
- string building
- model initialization
- synchronous persistence reads
4. Move heavy work out of body
// DON'T
var body: some View {
Text(items.sorted(by: \.date).map(\.title).joined(separator: ", "))
}
// DO
var body: some View {
Text(viewModel.joinedTitles)
}5. Re-record the same flow
If the trace still shows long view body updates, the expensive work likely just moved into a child view or another dependency path.
Workflow for frequent updates
Some screens feel slow because updates happen too often, not because any one update is catastrophic.
1. Use Update Groups first
Look for long active ranges with many updates but no large red spikes.
2. Inspect counts, not just duration
A screen doing cheap work 200 times can still feel worse than one doing one moderately expensive update.
3. Open Cause and Effect Graph
This is often the fastest way to answer:
- what changed?
- which dependency triggered the update?
- why did that change fan out to unrelated views?
4. Narrow the dependency scope
Typical fixes:
- extract subviews so they only read the state they need
- replace broad shared models with narrower derived state
- avoid environment values for fast-changing data
- split large
@Observablemodels if unrelated properties change together
Cause and Effect Graph usage
Use it when backtraces are misleading.
SwiftUI is declarative, so the expensive work may not appear near the code that caused the update chain.
Good uses:
- a selection change updates an unrelated sidebar
- a timer invalidates a full list
- geometry updates ripple through many subviews
- a global favorites model causes every row to re-evaluate
Common hotspots
Formatter allocation
Avoid constructing DateFormatter, NumberFormatter, or MeasurementFormatter in body or per-row views.
Filtering or sorting in the view tree
Precompute filtered collections before render.
Geometry-driven layout churn
Avoid feeding raw geometry values through broad observable state.
Identity instability
Lists and tables get expensive when identity changes or row counts vary in ways SwiftUI cannot efficiently diff.
Platform view bridging
Audit representables for repeated configuration work in updateUIView or updateUIViewController.
Fix patterns
Cache presentation data
@Observable
final class TripPresentationModel {
var formattedDistance: String = ""
func update(distance: Measurement<UnitLength>, formatter: MeasurementFormatter) {
formattedDistance = formatter.string(from: distance)
}
}Scope observable dependencies
Prefer models where a row reads only row-local state.
Keep representable updates idempotent
Only mutate the hosted platform view when input values actually changed.
Gate noisy signals
A geometry or timer signal may need thresholding, debouncing, or coalescing.
Re-measure checklist
After every change:
- record the same flow again
- compare update count, not just total runtime
- verify hitch frequency went down
- verify no new Long Platform View Updates appeared
- verify behavior in Release on device
If a change makes one screen faster but causes more updates elsewhere, keep following the dependency graph until the fan-out is truly reduced.
Understanding Hangs in Your App (Summary)
Context: Apple guidance on identifying hangs caused by long-running main-thread work and understanding the main run loop.
Contents
- What a hang is
- Main-thread work stages
- Triage workflow
- Common root causes
- Fix patterns
- Verification checklist
- Related SwiftUI implications
What a hang is
A hang is a noticeable delay in a discrete interaction. Apple commonly frames this as main-thread busy time long enough for the user to feel the UI stop responding.
Practical thresholds:
- Under ~100 ms usually feels immediate.
- Around 100–250 ms starts to feel sticky.
- Above ~250 ms is a likely hang candidate.
- Multi-second stalls are usually obvious product bugs, not subtle perf issues.
The important point is not the exact number. A hang is user-perceived blocked interaction, and the main thread is usually where the problem lives.
Main-thread work stages
A typical interaction flows through three stages on the main thread:
1. Event delivery to the target view or responder 2. Your code mutating state, computing values, and scheduling UI changes 3. Core Animation committing the frame tree to the render server
If any one of those stages runs too long, the main run loop cannot get back to sleep and cannot service the next event in time.
Main run loop model
The main run loop is a good mental model for hangs:
- Healthy apps spend most of their time idle, waiting for work.
- A busy run loop means new taps, gestures, timers, and redraw work queue up.
- Main-actor tasks still execute on the main thread. Moving code to
@MainActor
is a correctness tool, not a performance optimization.
If the UI is stalled, assume the main thread is overloaded until profiling proves otherwise.
Triage workflow
1. Reproduce a single concrete interaction
Start with one specific symptom:
- tapping a button does nothing for half a second
- pushing a detail screen pauses before animating
- dismissing a sheet freezes scrolling underneath
Avoid broad goals like "the app feels slow" until you isolate a single path.
2. Record with Instruments
Use the Hangs instrument, and pair it with Time Profiler when needed.
Good capture setup:
- Release build
- real device
- repeatable interaction path
- enough repetitions to confirm the same stall pattern
3. Inspect busy windows on the main thread
Look for long busy periods instead of staring at total CPU first.
Questions to answer:
- Is the main thread blocked in app code?
- Is it blocked in synchronous I/O?
- Is it repeatedly recalculating layout or view state?
- Is there a lock or actor hop forcing serialization?
4. Reduce the work, not just the symptom
A useful fix removes or re-locates expensive work. A weak fix only hides the stall behind a spinner while the main thread still does too much.
Common root causes
Synchronous I/O on the main thread
Typical offenders:
- file reads
- JSON decoding for large payloads
- image decoding
- database fetches
- Keychain work done inline with UI gestures
// DON'T
Button("Open") {
let data = try? Data(contentsOf: fileURL)
model = parse(data)
}
// DO
Button("Open") {
Task {
let data = try await loadFileData()
let parsed = try await parseModel(from: data)
await MainActor.run { model = parsed }
}
}Heavy work in event handlers
A tap handler should kick off work, not do all the work inline.
// DON'T
func didTapRefresh() {
items = expensiveRebuildOfEntireList()
}
// DO
func didTapRefresh() {
Task {
let rebuilt = await rebuildList()
await MainActor.run { items = rebuilt }
}
}Main-thread contention from layout or rendering
SwiftUI and UIKit/AppKit can both stall if the view tree triggers too much work per interaction.
Watch for:
- repeated formatter creation
- image resizing in
body - expensive attributed string generation during scroll
- layout invalidations triggered by frequent geometry changes
Locking and serialization
A hang may show up as main-thread waiting, not main-thread computing.
Examples:
- a lock held by background work
- synchronous dispatch back to main
- a main-actor method waiting on another main-actor path
Priority inversion
If high-priority UI work is waiting on lower-priority work that holds a needed resource, the UI still feels hung even if the main thread stack looks shallow.
Fix patterns
Keep main-thread work small and deterministic
Prefer this split:
- main thread: input, state wiring, view invalidation
- background work: parsing, formatting batches, image prep, persistence
- main thread again: commit the final result
Precompute instead of recompute
// DON'T
Text(distanceFormatter.string(from: trip.distance))
// DO
Text(trip.formattedDistance)If the value changes rarely, compute it at the model boundary.
Stream results instead of blocking for all results
When practical, render partial state first and append or replace as work finishes.
Cancel stale work aggressively
A common hang pattern is doing unnecessary work for content the user already navigated away from.
.task(id: searchQuery) {
results = []
results = await search(query: searchQuery)
}Pair this with cancellation inside the async work.
Verification checklist
After a fix, confirm all of the following:
- the same interaction no longer triggers a Hangs event
- main-thread busy windows are shorter
- the fix works in Release on device
- repeated interaction does not regress frame pacing
- cancellation works when navigating away mid-task
If the hang disappears but scrolling or animation gets worse elsewhere, the work likely just moved rather than improved.
Related SwiftUI implications
SwiftUI-specific hangs often come from:
- long view body updates
- broad observable dependencies
- list identity churn
- hidden work in formatting or filtering
Use the SwiftUI Instrument when the symptom is tied to view updates or layout. Use the Hangs instrument when the symptom is broad UI unresponsiveness and you first need to confirm the main thread is blocked.
Understanding and Improving SwiftUI Performance (Summary)
Context: Apple guidance on diagnosing SwiftUI performance with Instruments and applying design patterns to reduce long or frequent updates.
Contents
- Core concepts
- Instruments workflow
- Reading SwiftUI timeline lanes
- Diagnosing long updates
- Diagnosing frequent updates
- Common remediation patterns
- Verification loop
- Practical guardrails
Core concepts
SwiftUI is declarative. Performance problems are usually one of two categories:
- Long updates: one update takes too much time
- Frequent updates: many small updates happen too often
Both feel bad to users, but they require different fixes.
Long updates
These usually come from expensive body evaluation, expensive layout, or hosted platform-view work.
Frequent updates
These usually come from dependency fan-out, noisy observable state, or geometry and timer signals invalidating more of the tree than intended.
Instruments workflow
A practical workflow:
1. Profile in Release on a real device. 2. Choose the SwiftUI template. 3. Reproduce one interaction repeatedly. 4. Inspect the SwiftUI track first, then correlate with Time Profiler. 5. Change one thing at a time and re-record.
If you cannot reproduce the exact interaction on demand, your trace will be much harder to interpret.
Reading SwiftUI timeline lanes
Update Groups
Shows clusters of update activity over time. Good for spotting periods where the screen keeps re-evaluating even when no single event stands out.
Long View Body Updates
Highlights expensive body work. These often point straight to code you can simplify or move out of render paths.
Long Platform View Updates
Useful when SwiftUI hosts UIKit/AppKit content. If this lane is hot, inspect:
UIViewRepresentable/UIViewControllerRepresentableListrow content with embedded platform views- media, map, or web components
Other Long Updates
Captures non-body SwiftUI work like layout, geometry, and update coordination.
Hitches
Frame misses. These are the symptom users feel, not always the root cause.
Diagnosing long updates
Start with the red/orange update window
Do not begin from total runtime. Begin from the expensive update itself.
Correlate with Time Profiler
Set inspection range on the slow update and inspect hot frames.
Common findings:
- formatting in
body - array transformations in
body - image decoding during scroll
- representable updates doing too much work every pass
Typical fix shape
// DON'T
Text(Self.formatter.string(from: total as NSNumber) ?? "")
// DO
Text(viewModel.formattedTotal)The right fix is usually architectural, not micro-optimizing the same body code.
Diagnosing frequent updates
Watch Update Groups and Cause Graph together
Frequent updates often look like "the screen keeps waking up" rather than "one frame is red."
Look for noisy dependencies
Typical offenders:
- a timer injected high in the tree
- geometry state stored in a shared model
- a broad
@Observableroot model - environment values changing more often than needed
Reduce fan-out
Good strategies:
- split shared models by domain
- move fast-changing state lower in the tree
- derive narrow child state instead of passing whole parents
- gate updates by thresholds when continuous values are noisy
Common remediation patterns
Precompute presentation values
Cache strings, sorted arrays, and other display-ready values before render.
Make dependencies narrower
A row should depend on row state, not screen state.
Keep representables idempotent
Only touch hosted UIKit/AppKit views when actual inputs changed.
Avoid layout feedback loops
Be careful when geometry changes trigger state changes that trigger layout again.
Make lists identity-friendly
Use stable IDs, pre-filtered data, and consistent row counts.
Verification loop
After every performance change:
- record the same trace again
- compare update counts
- compare hitch frequency
- compare the target lane you were trying to cool down
- verify behavior still matches product requirements
Performance fixes can accidentally change animation timing, placeholder states, or navigation behavior. Verify the UX, not just the graph.
Practical guardrails
Use these as default heuristics:
- keep work out of
body - prefer smaller dependency surfaces
- cache display data near the model layer
- avoid hidden per-row allocations in lists
- profile Release builds, not just debug builds
- treat frequent updates and long updates as different bugs
When a screen still feels slow after obvious body cleanups, the next place to look is usually dependency fan-out, not isolated hot code.
WWDC Session Sources
Contents
- Optimizing SwiftUI performance with Instruments
- Demystify SwiftUI performance
- Understanding and improving SwiftUI performance
- Understanding hangs in your app
Optimizing SwiftUI performance with Instruments
- Transcript: https://sosumi.ai/videos/play/wwdc2025/306
- Use this when validating the newer SwiftUI Instrument workflow, Cause and Effect Graph usage, and Instruments 26-specific terminology.
Demystify SwiftUI performance
- Transcript: https://sosumi.ai/videos/play/wwdc2023/10160
- Use this when checking SwiftUI dependency fan-out, identity rules, and list/table guidance.
Understanding and improving SwiftUI performance
- Transcript: https://sosumi.ai/videos/play/wwdc2024/10160
- Use this when checking current SwiftUI Instrument lane names and remediation patterns.
Understanding hangs in your app
- Transcript: https://sosumi.ai/videos/play/wwdc2023/10248
- Use this when checking main run loop and Hangs instrument guidance.
Related skills
How it compares
Pick swiftui-performance over generic iOS skills when you need WWDC23-based SwiftUI hang and hitch triage with Instruments and identity-fix patterns.
FAQ
When should profiling start?
After code-first review if inconclusive; use Instruments SwiftUI template on a Release build with the exact reproduction interaction.
Why avoid sorting inside ForEach in body?
Sorting or filtering in body runs on every view evaluation; precompute and cache collections when inputs change instead.
How should list identity be stabilized?
Avoid id self for non-stable values; use stable identifiers and localize if-else conditions instead of swapping root view branches.
Is Swiftui Performance safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.