iOS Performance Optimization Skill
Core Rules
- Use
[weak self] in escaping closures by default — use [unowned self] only when the closure's lifetime is strictly shorter than the captured object (e.g., parent owns child, child's closure references parent).
- Non-escaping closures do NOT need
[weak self] — map, filter, forEach, reduce, compactMap, sorted(by:) are non-escaping. The closure executes synchronously and releases captures immediately.
- Delegates must be
weak var — the delegate protocol must conform to AnyObject (or be marked @objc). Strong delegates create retain cycles between owner and delegate.
- Use
@Observable over ObservableObject (iOS 17+) — @Observable tracks property access per-view, so only views reading a changed property re-evaluate. ObservableObject with @Published invalidates ALL observing views on ANY published property change.
- Break SwiftUI views into small subviews — each subview localizes its state dependency, so changes only re-evaluate the subview, not the entire parent body.
- Use
LazyVStack/LazyHStack inside ScrollView for large collections — VStack evaluates ALL children upfront. Use List for very large datasets (10,000+) since it reuses cells.
- Use
.task modifier instead of .onAppear + Task {} — .task automatically cancels when the view disappears. Use .task(id:) to restart when a dependency changes.
- Profile with Instruments BEFORE optimizing — measure first, optimize second. Never guess where the bottleneck is.
- Never block the main thread — all file I/O, network calls, JSON decoding of large payloads, image processing, and database queries must run off the main thread.
- Reuse expensive objects —
URLSession, DateFormatter, JSONDecoder, NumberFormatter, NSRegularExpression are expensive to create. Use shared instances or caches.
Performance Targets
| Metric |
Target |
Critical Threshold |
| Cold launch |
<400ms to first frame |
>2s triggers watchdog kill |
| Warm launch |
<200ms |
>1s feels broken |
| Frame rate |
60 FPS (120 on ProMotion) |
<45 FPS is noticeable jank |
| Frame budget |
16.67ms (8.33ms at 120Hz) |
>33ms = visible dropped frame |
| Memory (typical) |
<100MB resident |
>500MB risks jetsam on older devices |
| Memory (spike) |
<200MB peak |
Varies by device class |
| CPU idle |
<3% |
>5% drains battery |
| CPU active task |
<80% sustained |
100% = thermal throttling |
| API response (perceived) |
<200ms |
>1s needs loading indicator |
| App size (download) |
<50MB (OTA limit: 200MB) |
>200MB requires Wi-Fi |
| Disk writes |
<1MB/min sustained |
Excessive writes degrade flash |
Quick Diagnosis Guide
| Symptom |
Instrument / Tool |
Likely Cause |
First Action |
| UI freezes / hangs |
Time Profiler |
Main thread blocking (sync I/O, heavy computation) |
Check main thread call stack |
| Memory grows over time |
Allocations + Memory Graph |
Retain cycle or unbounded cache |
Take heap snapshots, compare generations |
| Sudden memory spike |
Allocations (VM Tracker) |
Large image decode, bulk data load |
Check transient allocations |
| Purple "memory" warning |
Memory Graph Debugger |
Retain cycle between objects |
Trace reference chains |
| Dropped frames |
Core Animation instrument |
Offscreen rendering, layer blending |
Enable "Color Blended Layers" |
| Battery drain |
Energy Log |
Excessive CPU, location, network polling |
Check CPU/network wake frequency |
| Slow cold launch |
App Launch instrument |
Too many dylibs, heavy +load/init, sync main |
Profile pre-main vs post-main |
| Slow scrolling (SwiftUI) |
SwiftUI instrument |
Non-lazy stacks, excessive redraws |
Check body evaluation count |
| Slow scrolling (UIKit) |
Time Profiler + Core Animation |
Cell height calculation, offscreen rendering |
Profile cellForRow time |
| Network slow |
Network instrument |
No HTTP/2 reuse, large payloads, no compression |
Check connection count and sizes |
| Build slow |
Xcode build timeline |
Type inference, large files, no parallelism |
Add -warn-long-function-bodies |
Memory Management Quick Reference
// CORRECT: [weak self] in escaping closure
func fetchData() {
networkService.fetch { [weak self] result in
guard let self else { return }
self.update(with: result)
}
}
// CORRECT: [unowned self] ONLY when lifetime is guaranteed
class Parent {
lazy var handler: () -> Void = { [unowned self] in
self.doSomething() // Parent always outlives its own lazy property
}
}
// NOT NEEDED: Non-escaping closure — no capture cycle possible
let names = users.map { $0.name } // map is non-escaping
let adults = users.filter { $0.age >= 18 } // filter is non-escaping
items.forEach { print($0) } // forEach is non-escaping
// CORRECT: Weak delegate
protocol DataServiceDelegate: AnyObject {
func didUpdate(_ data: Data)
}
class DataService {
weak var delegate: DataServiceDelegate?
}
SwiftUI Performance Quick Reference
// BAD: One large view — any state change re-evaluates entire body
struct ProfileView: View {
@State private var name = ""
@State private var bio = ""
@State private var avatarURL: URL?
@State private var posts: [Post] = []
var body: some View {
ScrollView {
avatarSection // Change to name re-evaluates avatar too
bioSection
postsSection // All 500 post cells re-evaluated
}
}
}
// GOOD: Extracted subviews — state changes localized
struct ProfileView: View {
var body: some View {
ScrollView {
AvatarSection() // Only re-evaluates when avatar changes
BioSection() // Only re-evaluates when bio changes
PostsSection() // Only re-evaluates when posts change
}
}
}
// GOOD: LazyVStack for scrollable content
struct PostsSection: View {
let posts: [Post]
var body: some View {
LazyVStack { // Only creates visible cells + prefetch buffer
ForEach(posts) { post in
PostRow(post: post)
}
}
}
}
// GOOD: .task with auto-cancellation
struct UserDetailView: View {
let userID: String
@State private var user: User?
var body: some View {
content
.task(id: userID) { // Cancels & restarts if userID changes
user = try? await api.fetchUser(userID)
}
}
}
@Observable vs ObservableObject
// OLD (iOS 14+): ObservableObject — ALL views re-evaluate on ANY change
class UserViewModel: ObservableObject {
@Published var name = "" // Change triggers ALL observers
@Published var email = "" // Change triggers ALL observers
@Published var avatarURL: URL? // Change triggers ALL observers
}
// NEW (iOS 17+): @Observable — only views reading changed property re-evaluate
@Observable
class UserViewModel {
var name = "" // Only views reading `name` re-evaluate
var email = "" // Only views reading `email` re-evaluate
var avatarURL: URL? // Only views reading `avatarURL` re-evaluate
}
Instruments Workflow
Step 1: Profile, Don't Debug
Always profile on a real device (not Simulator). Use Release configuration for accurate measurements.
Step 2: Choose the Right Instrument
- Time Profiler — CPU bottlenecks, main thread blocking
- Allocations — memory growth, leaks, allocation hotspots
- Leaks — automatic retain cycle detection (periodic snapshots)
- Memory Graph Debugger (Xcode, not Instruments) — visual reference chains
- Core Animation — rendering performance, blended layers, offscreen rendering
- Energy Log — battery drain causes (CPU, network, GPS, Bluetooth)
- Network — HTTP request/response analysis
- App Launch — cold/warm launch breakdown
- SwiftUI (Xcode 16+) — view body evaluations, cause & effect graph
Step 3: Time Profiler Settings (Always Set These)
- Invert Call Tree — shows heaviest leaf functions first
- Separate by Thread — isolates main thread work
- Hide System Libraries — focuses on your code
- Separate by State — shows running vs blocked time
Step 4: Record, Reproduce, Analyze
- Record for 10-30 seconds covering the problematic interaction
- Select the time range of interest
- Look at the heaviest stack traces
- Focus on main thread first (Thread 1)
App Launch Optimization Checklist
Pre-main Phase (<200ms target)
Post-main Phase (<200ms target)
Common Anti-Patterns
1. Main Thread Blocking
// BAD: Synchronous file read on main thread
let data = try! Data(contentsOf: largeFileURL)
// GOOD: Async file read
let data = try await Task.detached {
try Data(contentsOf: largeFileURL)
}.value
2. Excessive Allocations in Loops
// BAD: Creates new DateFormatter per iteration (expensive!)
for event in events {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
labels.append(formatter.string(from: event.date))
}
// GOOD: Reuse formatter
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
for event in events {
labels.append(formatter.string(from: event.date))
}
3. VStack for Large Collections
// BAD: Creates ALL 10,000 views upfront
ScrollView {
VStack {
ForEach(items) { item in // 10,000 items = 10,000 views in memory
ItemRow(item: item)
}
}
}
// GOOD: Only creates visible views
ScrollView {
LazyVStack {
ForEach(items) { item in // ~20 views in memory at a time
ItemRow(item: item)
}
}
}
4. Retaining Self in Timers
// BAD: Timer retains self, self retains timer → cycle
class PollingService {
var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
self.poll() // Strong capture → retain cycle
}
}
}
// GOOD: Weak capture
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
self?.poll()
}
}
5. Not Cancelling Tasks
// BAD: Task keeps running after view disappears
.onAppear {
Task {
while !Task.isCancelled {
await refresh()
try await Task.sleep(for: .seconds(30))
}
}
}
// GOOD: .task auto-cancels on disappear
.task {
while !Task.isCancelled {
await refresh()
try? await Task.sleep(for: .seconds(30))
}
}
Reference Files
For deep dives, see:
references/memory.md — ARC, retain cycles, value vs reference types, copy-on-write, debugging
references/swiftui-perf.md — View identity, @Observable, lazy containers, images, .task
references/instruments.md — Time Profiler, Allocations, Leaks, Energy, Core Animation, SwiftUI instrument
references/optimization.md — App launch, network, battery, build performance, anti-patterns
Related Skills
instruments-profiling — Instruments profiling
swiftui-performance-audit — SwiftUI performance
ios-testing — performance tests
GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal.
Index path: /Users/localuser/.claude/skills/ios-performance/.gitnexus
Last indexed: 2026-05-23
1---2name: ios-performance3description: iOS performance optimization expert skill covering memory management (ARC, retain cycles, weak/unowned, value vs reference types, copy-on-write), SwiftUI performance (view identity, @Observable vs ObservableObject, lazy containers, EquatableView, image caching), Instruments profiling (Time Profiler, Allocations, Leaks, Energy Log, Core Animation, SwiftUI instrument), app launch optimization, network performance, battery optimization, build performance, and common anti-patterns. Use this skill whenever the user optimizes iOS app performance, investigates memory leaks, profiles with Instruments, improves launch time, or fixes frame drops. Triggers on: performance, memory leak, retain cycle, ARC, weak self, profiling, Instruments, Time Profiler, Allocations, frame rate, FPS, launch time, battery, energy, optimization, slow, lag, freeze, hang, jank, memory pressure, CPU usage, build time, compile time, app size, or any iOS performance question.4---56# iOS Performance Optimization Skill78## Core Rules9101. **Use `[weak self]` in escaping closures by default** — use `[unowned self]` only when the closure's lifetime is strictly shorter than the captured object (e.g., parent owns child, child's closure references parent).112. **Non-escaping closures do NOT need `[weak self]`** — `map`, `filter`, `forEach`, `reduce`, `compactMap`, `sorted(by:)` are non-escaping. The closure executes synchronously and releases captures immediately.123. **Delegates must be `weak var`** — the delegate protocol must conform to `AnyObject` (or be marked `@objc`). Strong delegates create retain cycles between owner and delegate.134. **Use `@Observable` over `ObservableObject`** (iOS 17+) — `@Observable` tracks property access per-view, so only views reading a changed property re-evaluate. `ObservableObject` with `@Published` invalidates ALL observing views on ANY published property change.145. **Break SwiftUI views into small subviews** — each subview localizes its state dependency, so changes only re-evaluate the subview, not the entire parent body.156. **Use `LazyVStack`/`LazyHStack` inside `ScrollView` for large collections** — `VStack` evaluates ALL children upfront. Use `List` for very large datasets (10,000+) since it reuses cells.167. **Use `.task` modifier instead of `.onAppear` + `Task {}`** — `.task` automatically cancels when the view disappears. Use `.task(id:)` to restart when a dependency changes.178. **Profile with Instruments BEFORE optimizing** — measure first, optimize second. Never guess where the bottleneck is.189. **Never block the main thread** — all file I/O, network calls, JSON decoding of large payloads, image processing, and database queries must run off the main thread.1910. **Reuse expensive objects** — `URLSession`, `DateFormatter`, `JSONDecoder`, `NumberFormatter`, `NSRegularExpression` are expensive to create. Use shared instances or caches.2021## Performance Targets2223| Metric | Target | Critical Threshold |24|--------|--------|--------------------|25| Cold launch | <400ms to first frame | >2s triggers watchdog kill |26| Warm launch | <200ms | >1s feels broken |27| Frame rate | 60 FPS (120 on ProMotion) | <45 FPS is noticeable jank |28| Frame budget | 16.67ms (8.33ms at 120Hz) | >33ms = visible dropped frame |29| Memory (typical) | <100MB resident | >500MB risks jetsam on older devices |30| Memory (spike) | <200MB peak | Varies by device class |31| CPU idle | <3% | >5% drains battery |32| CPU active task | <80% sustained | 100% = thermal throttling |33| API response (perceived) | <200ms | >1s needs loading indicator |34| App size (download) | <50MB (OTA limit: 200MB) | >200MB requires Wi-Fi |35| Disk writes | <1MB/min sustained | Excessive writes degrade flash |3637## Quick Diagnosis Guide3839| Symptom | Instrument / Tool | Likely Cause | First Action |40|---------|-------------------|-------------|--------------|41| UI freezes / hangs | Time Profiler | Main thread blocking (sync I/O, heavy computation) | Check main thread call stack |42| Memory grows over time | Allocations + Memory Graph | Retain cycle or unbounded cache | Take heap snapshots, compare generations |43| Sudden memory spike | Allocations (VM Tracker) | Large image decode, bulk data load | Check transient allocations |44| Purple "memory" warning | Memory Graph Debugger | Retain cycle between objects | Trace reference chains |45| Dropped frames | Core Animation instrument | Offscreen rendering, layer blending | Enable "Color Blended Layers" |46| Battery drain | Energy Log | Excessive CPU, location, network polling | Check CPU/network wake frequency |47| Slow cold launch | App Launch instrument | Too many dylibs, heavy `+load`/`init`, sync main | Profile pre-main vs post-main |48| Slow scrolling (SwiftUI) | SwiftUI instrument | Non-lazy stacks, excessive redraws | Check body evaluation count |49| Slow scrolling (UIKit) | Time Profiler + Core Animation | Cell height calculation, offscreen rendering | Profile `cellForRow` time |50| Network slow | Network instrument | No HTTP/2 reuse, large payloads, no compression | Check connection count and sizes |51| Build slow | Xcode build timeline | Type inference, large files, no parallelism | Add `-warn-long-function-bodies` |5253## Memory Management Quick Reference5455```swift56// CORRECT: [weak self] in escaping closure57func fetchData() {58 networkService.fetch { [weak self] result in59 guard let self else { return }60 self.update(with: result)61 }62}6364// CORRECT: [unowned self] ONLY when lifetime is guaranteed65class Parent {66 lazy var handler: () -> Void = { [unowned self] in67 self.doSomething() // Parent always outlives its own lazy property68 }69}7071// NOT NEEDED: Non-escaping closure — no capture cycle possible72let names = users.map { $0.name } // map is non-escaping73let adults = users.filter { $0.age >= 18 } // filter is non-escaping74items.forEach { print($0) } // forEach is non-escaping7576// CORRECT: Weak delegate77protocol DataServiceDelegate: AnyObject {78 func didUpdate(_ data: Data)79}8081class DataService {82 weak var delegate: DataServiceDelegate?83}84```8586## SwiftUI Performance Quick Reference8788```swift89// BAD: One large view — any state change re-evaluates entire body90struct ProfileView: View {91 @State private var name = ""92 @State private var bio = ""93 @State private var avatarURL: URL?94 @State private var posts: [Post] = []9596 var body: some View {97 ScrollView {98 avatarSection // Change to name re-evaluates avatar too99 bioSection100 postsSection // All 500 post cells re-evaluated101 }102 }103}104105// GOOD: Extracted subviews — state changes localized106struct ProfileView: View {107 var body: some View {108 ScrollView {109 AvatarSection() // Only re-evaluates when avatar changes110 BioSection() // Only re-evaluates when bio changes111 PostsSection() // Only re-evaluates when posts change112 }113 }114}115116// GOOD: LazyVStack for scrollable content117struct PostsSection: View {118 let posts: [Post]119120 var body: some View {121 LazyVStack { // Only creates visible cells + prefetch buffer122 ForEach(posts) { post in123 PostRow(post: post)124 }125 }126 }127}128129// GOOD: .task with auto-cancellation130struct UserDetailView: View {131 let userID: String132 @State private var user: User?133134 var body: some View {135 content136 .task(id: userID) { // Cancels & restarts if userID changes137 user = try? await api.fetchUser(userID)138 }139 }140}141```142143## @Observable vs ObservableObject144145```swift146// OLD (iOS 14+): ObservableObject — ALL views re-evaluate on ANY change147class UserViewModel: ObservableObject {148 @Published var name = "" // Change triggers ALL observers149 @Published var email = "" // Change triggers ALL observers150 @Published var avatarURL: URL? // Change triggers ALL observers151}152153// NEW (iOS 17+): @Observable — only views reading changed property re-evaluate154@Observable155class UserViewModel {156 var name = "" // Only views reading `name` re-evaluate157 var email = "" // Only views reading `email` re-evaluate158 var avatarURL: URL? // Only views reading `avatarURL` re-evaluate159}160```161162## Instruments Workflow163164### Step 1: Profile, Don't Debug165Always profile on a **real device** (not Simulator). Use **Release** configuration for accurate measurements.166167### Step 2: Choose the Right Instrument168- **Time Profiler** — CPU bottlenecks, main thread blocking169- **Allocations** — memory growth, leaks, allocation hotspots170- **Leaks** — automatic retain cycle detection (periodic snapshots)171- **Memory Graph Debugger** (Xcode, not Instruments) — visual reference chains172- **Core Animation** — rendering performance, blended layers, offscreen rendering173- **Energy Log** — battery drain causes (CPU, network, GPS, Bluetooth)174- **Network** — HTTP request/response analysis175- **App Launch** — cold/warm launch breakdown176- **SwiftUI** (Xcode 16+) — view body evaluations, cause & effect graph177178### Step 3: Time Profiler Settings (Always Set These)1791. **Invert Call Tree** — shows heaviest leaf functions first1802. **Separate by Thread** — isolates main thread work1813. **Hide System Libraries** — focuses on your code1824. **Separate by State** — shows running vs blocked time183184### Step 4: Record, Reproduce, Analyze1851. Record for 10-30 seconds covering the problematic interaction1862. Select the time range of interest1873. Look at the heaviest stack traces1884. Focus on main thread first (Thread 1)189190## App Launch Optimization Checklist191192### Pre-main Phase (<200ms target)193- [ ] Max 6 non-system dynamic frameworks (each adds ~10-20ms)194- [ ] No `+load` methods in ObjC code (move to `+initialize` or lazy init)195- [ ] Minimize static initializers (C++ globals, `__attribute__((constructor))`)196- [ ] Use static linking where possible (SPM default)197198### Post-main Phase (<200ms target)199- [ ] Defer non-essential initialization (analytics, logging, feature flags)200- [ ] Use `lazy var` for expensive properties201- [ ] Load first screen data from cache, then refresh from network202- [ ] Avoid synchronous network calls at launch203- [ ] Minimize work in `application(_:didFinishLaunchingWithOptions:)`204- [ ] Use `Scene` phase detection instead of heavy AppDelegate setup205206## Common Anti-Patterns207208### 1. Main Thread Blocking209```swift210// BAD: Synchronous file read on main thread211let data = try! Data(contentsOf: largeFileURL)212213// GOOD: Async file read214let data = try await Task.detached {215 try Data(contentsOf: largeFileURL)216}.value217```218219### 2. Excessive Allocations in Loops220```swift221// BAD: Creates new DateFormatter per iteration (expensive!)222for event in events {223 let formatter = DateFormatter()224 formatter.dateFormat = "yyyy-MM-dd"225 labels.append(formatter.string(from: event.date))226}227228// GOOD: Reuse formatter229let formatter = DateFormatter()230formatter.dateFormat = "yyyy-MM-dd"231for event in events {232 labels.append(formatter.string(from: event.date))233}234```235236### 3. VStack for Large Collections237```swift238// BAD: Creates ALL 10,000 views upfront239ScrollView {240 VStack {241 ForEach(items) { item in // 10,000 items = 10,000 views in memory242 ItemRow(item: item)243 }244 }245}246247// GOOD: Only creates visible views248ScrollView {249 LazyVStack {250 ForEach(items) { item in // ~20 views in memory at a time251 ItemRow(item: item)252 }253 }254}255```256257### 4. Retaining Self in Timers258```swift259// BAD: Timer retains self, self retains timer → cycle260class PollingService {261 var timer: Timer?262263 func start() {264 timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in265 self.poll() // Strong capture → retain cycle266 }267 }268}269270// GOOD: Weak capture271func start() {272 timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in273 self?.poll()274 }275}276```277278### 5. Not Cancelling Tasks279```swift280// BAD: Task keeps running after view disappears281.onAppear {282 Task {283 while !Task.isCancelled {284 await refresh()285 try await Task.sleep(for: .seconds(30))286 }287 }288}289290// GOOD: .task auto-cancels on disappear291.task {292 while !Task.isCancelled {293 await refresh()294 try? await Task.sleep(for: .seconds(30))295 }296}297```298299## Reference Files300301For deep dives, see:302- `references/memory.md` — ARC, retain cycles, value vs reference types, copy-on-write, debugging303- `references/swiftui-perf.md` — View identity, @Observable, lazy containers, images, .task304- `references/instruments.md` — Time Profiler, Allocations, Leaks, Energy, Core Animation, SwiftUI instrument305- `references/optimization.md` — App launch, network, battery, build performance, anti-patterns306307## Related Skills308- `instruments-profiling` — Instruments profiling309- `swiftui-performance-audit` — SwiftUI performance310- `ios-testing` — performance tests311312## GitNexus Index313This skill is indexed by GitNexus for knowledge graph traversal.314Index path: /Users/localuser/.claude/skills/ios-performance/.gitnexus315Last indexed: 2026-05-23