home / skills / kaakati / rails-enterprise-dev / performance-optimization

This skill helps you make informed performance decisions for iOS/tvOS by profiling, selecting tools, and balancing optimization with user impact.

npx playbooks add skill kaakati/rails-enterprise-dev --skill performance-optimization

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
14.2 KB
---
name: performance-optimization
description: "Expert performance decisions for iOS/tvOS: when to optimize vs premature optimization, profiling tool selection, SwiftUI view identity trade-offs, and memory management strategies. Use when debugging performance issues, optimizing slow screens, or reducing memory usage. Trigger keywords: performance, Instruments, Time Profiler, Allocations, memory leak, view identity, lazy loading, @StateObject, retain cycle, image caching, faulting, batch operations"
version: "3.0.0"
---

# Performance Optimization — Expert Decisions

Expert decision frameworks for performance choices. Claude knows lazy loading and async basics — this skill provides judgment calls for when to optimize and which tool to use.

---

## Decision Trees

### Should You Optimize?

```
When should you invest in optimization?
├─ User-facing latency issue (visible stutter/delay)
│  └─ YES — Profile and fix
│     Measure first, optimize second
│
├─ Premature concern ("this might be slow")
│  └─ NO — Wait for evidence
│     Write clean code, profile later
│
├─ Battery drain complaints
│  └─ YES — Use Energy Diagnostics
│     Focus on background work, location, network
│
├─ Memory warnings / crashes
│  └─ YES — Use Allocations + Leaks
│     Find retain cycles, unbounded caches
│
└─ App store reviews mention slowness
   └─ YES — Profile real scenarios
      User perception matters
```

**The trap**: Optimizing based on assumptions. Always profile first. The bottleneck is rarely where you think.

### Profiling Tool Selection

```
What are you measuring?
├─ Slow UI / frame drops
│  └─ Time Profiler + View Debugger
│     Find expensive work on main thread
│
├─ Memory growth / leaks
│  └─ Allocations + Leaks instruments
│     Track object lifetimes, find cycles
│
├─ Network performance
│  └─ Network instrument + Charles/Proxyman
│     Latency, payload size, request count
│
├─ Disk I/O issues
│  └─ File Activity instrument
│     Excessive reads/writes
│
├─ Battery drain
│  └─ Energy Log instrument
│     CPU wake, location, networking
│
└─ GPU / rendering
   └─ Core Animation instrument
      Offscreen rendering, overdraw
```

### SwiftUI View Update Strategy

```
View is re-rendering too often?
├─ Caused by parent state changes
│  └─ Extract to separate view
│     Child doesn't depend on changing state
│
├─ Complex computed body
│  └─ Cache expensive computations
│     Use ViewModel or memoization
│
├─ List items all updating
│  └─ Check view identity
│     Use stable IDs, not indices
│
├─ Observable causing cascading updates
│  └─ Split into multiple @Published
│     Or use computed properties
│
└─ Animation causing constant redraws
   └─ Use drawingGroup() or limit scope
      Rasterize stable content
```

### Memory Management Decision

```
How to fix memory issues?
├─ Steady growth during use
│  └─ Check caches and collections
│     Add eviction, use NSCache
│
├─ Growth tied to navigation
│  └─ Check retain cycles
│     weak self in closures, delegates
│
├─ Large spikes on specific screens
│  └─ Downsample images
│     Load at display size, not full resolution
│
├─ Memory not released after screen dismissal
│  └─ Debug object lifecycle
│     deinit not called = retain cycle
│
└─ Background memory pressure
   └─ Respond to didReceiveMemoryWarning
      Clear caches, release non-essential data
```

---

## NEVER Do

### View Identity

**NEVER** use indices as identifiers:
```swift
// ❌ Identity changes when array mutates
List(items.indices, id: \.self) { index in
    ItemRow(item: items[index])
}
// Insert at index 0 → all views recreated!

// ✅ Use stable identifiers
List(items) { item in
    ItemRow(item: item)
        .id(item.id)  // Stable across mutations
}
```

**NEVER** compute expensive values in body:
```swift
// ❌ Called on every render
var body: some View {
    let sortedItems = items.sorted { $0.date > $1.date }  // O(n log n) per render!
    let filtered = sortedItems.filter { $0.isActive }

    List(filtered) { item in
        ItemRow(item: item)
    }
}

// ✅ Compute in ViewModel or use computed property
@MainActor
class ViewModel: ObservableObject {
    @Published var items: [Item] = []

    var displayItems: [Item] {
        items.filter(\.isActive).sorted { $0.date > $1.date }
    }
}
```

### State Management

**NEVER** use @StateObject for passed objects:
```swift
// ❌ Creates new instance on every parent update
struct ChildView: View {
    @StateObject var viewModel: ChildViewModel  // Wrong!

    var body: some View { ... }
}

// ✅ Use @ObservedObject for passed objects
struct ChildView: View {
    @ObservedObject var viewModel: ChildViewModel  // Parent owns it

    var body: some View { ... }
}
```

**NEVER** make everything @Published:
```swift
// ❌ Every property change triggers view updates
class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    @Published var internalCache: [String: Data] = [:]  // UI doesn't need this!
    @Published var isProcessing = false  // Maybe internal only
}

// ✅ Only publish what UI observes
class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    @Published var isLoading = false

    private var internalCache: [String: Data] = [:]  // Not @Published
    private var isProcessing = false  // Private state
}
```

### Memory Leaks

**NEVER** capture self strongly in escaping closures:
```swift
// ❌ Retain cycle — never deallocates
class ViewModel {
    var timer: Timer?

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            self.tick()  // Strong capture!
        }
    }
}

// ✅ Weak capture + invalidation
class ViewModel {
    var timer: Timer?

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }

    deinit {
        timer?.invalidate()
    }
}
```

**NEVER** forget to remove observers:
```swift
// ❌ Leaks observer and potentially self
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleNotification),
            name: .userLoggedIn,
            object: nil
        )
        // Never removed!
    }
}

// ✅ Remove in deinit or use modern API
class ViewController: UIViewController {
    private var observer: NSObjectProtocol?

    override func viewDidLoad() {
        super.viewDidLoad()
        observer = NotificationCenter.default.addObserver(
            forName: .userLoggedIn,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            self?.handleNotification()
        }
    }

    deinit {
        if let observer { NotificationCenter.default.removeObserver(observer) }
    }
}
```

### Image Loading

**NEVER** load full resolution for thumbnails:
```swift
// ❌ 4000×3000 image for 80×80 thumbnail
let image = UIImage(contentsOfFile: path)  // Full resolution in memory!
imageView.image = image

// ✅ Downsample to display size
func downsampledImage(at url: URL, to size: CGSize) -> UIImage? {
    let options: [CFString: Any] = [
        kCGImageSourceShouldCache: false,
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) * UIScreen.main.scale
    ]

    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
          let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
        return nil
    }
    return UIImage(cgImage: cgImage)
}
```

**NEVER** cache images without limits:
```swift
// ❌ Unbounded memory growth
class ImageLoader {
    private var cache: [URL: UIImage] = [:]  // Grows forever!

    func image(for url: URL) -> UIImage? {
        if let cached = cache[url] { return cached }
        let image = loadImage(url)
        cache[url] = image  // Never evicted
        return image
    }
}

// ✅ Use NSCache with limits
class ImageLoader {
    private let cache = NSCache<NSURL, UIImage>()

    init() {
        cache.countLimit = 100
        cache.totalCostLimit = 50 * 1024 * 1024  // 50 MB
    }

    func image(for url: URL) -> UIImage? {
        if let cached = cache.object(forKey: url as NSURL) { return cached }
        guard let image = loadImage(url) else { return nil }
        cache.setObject(image, forKey: url as NSURL, cost: image.jpegData(compressionQuality: 1)?.count ?? 0)
        return image
    }
}
```

### Heavy Operations

**NEVER** do heavy work on main thread:
```swift
// ❌ UI frozen during processing
func loadData() {
    let data = try! Data(contentsOf: largeFileURL)  // Blocks main thread!
    let parsed = parseData(data)  // Still blocking!
    self.items = parsed
}

// ✅ Use background thread, update on main
func loadData() async {
    let items = await Task.detached(priority: .userInitiated) {
        let data = try! Data(contentsOf: largeFileURL)
        return parseData(data)
    }.value

    await MainActor.run {
        self.items = items
    }
}
```

---

## Essential Patterns

### Efficient List View

```swift
struct EfficientListView: View {
    let items: [Item]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12) {  // Lazy = on-demand creation
                ForEach(items) { item in
                    ItemRow(item: item)
                        .id(item.id)  // Stable identity
                }
            }
        }
    }
}

// Equatable row prevents unnecessary updates
struct ItemRow: View, Equatable {
    let item: Item

    var body: some View {
        HStack {
            AsyncImage(url: item.imageURL) { image in
                image.resizable().aspectRatio(contentMode: .fill)
            } placeholder: {
                Color.gray.opacity(0.3)
            }
            .frame(width: 60, height: 60)
            .clipShape(RoundedRectangle(cornerRadius: 8))

            VStack(alignment: .leading) {
                Text(item.title).font(.headline)
                Text(item.subtitle).font(.caption).foregroundColor(.secondary)
            }
        }
    }

    static func == (lhs: ItemRow, rhs: ItemRow) -> Bool {
        lhs.item.id == rhs.item.id &&
        lhs.item.title == rhs.item.title &&
        lhs.item.subtitle == rhs.item.subtitle
    }
}
```

### Memory-Safe ViewModel

```swift
@MainActor
final class ViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    @Published private(set) var isLoading = false

    private var cancellables = Set<AnyCancellable>()
    private var loadTask: Task<Void, Never>?

    func load() {
        loadTask?.cancel()  // Cancel previous

        loadTask = Task {
            guard !Task.isCancelled else { return }

            isLoading = true
            defer { isLoading = false }

            do {
                let items = try await API.fetchItems()
                guard !Task.isCancelled else { return }
                self.items = items
            } catch {
                // Handle error
            }
        }
    }

    deinit {
        loadTask?.cancel()
        cancellables.removeAll()
    }
}
```

### Debounced Search

```swift
@MainActor
final class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published private(set) var results: [Item] = []

    private var searchTask: Task<Void, Never>?

    init() {
        // Debounce search
        $searchText
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .sink { [weak self] text in
                self?.performSearch(text)
            }
            .store(in: &cancellables)
    }

    private func performSearch(_ query: String) {
        searchTask?.cancel()

        guard !query.isEmpty else {
            results = []
            return
        }

        searchTask = Task {
            do {
                let results = try await API.search(query: query)
                guard !Task.isCancelled else { return }
                self.results = results
            } catch {
                // Handle error
            }
        }
    }
}
```

---

## Quick Reference

### Instruments Selection

| Issue | Instrument | What to Look For |
|-------|------------|------------------|
| Slow UI | Time Profiler | Heavy main thread work |
| Memory leak | Leaks | Leaked objects |
| Memory growth | Allocations | Growing categories |
| Battery | Energy Log | Wake frequency |
| Network | Network | Request count, size |
| Disk | File Activity | Excessive I/O |
| GPU | Core Animation | Offscreen renders |

### SwiftUI Performance Checklist

| Issue | Solution |
|-------|----------|
| Slow list scrolling | Use LazyVStack/LazyVGrid |
| All items re-render | Stable IDs, Equatable rows |
| Heavy body computation | Move to ViewModel |
| Cascading @Published updates | Split or use computed |
| Animation jank | Use drawingGroup() |

### Memory Management

| Pattern | Prevent Issue |
|---------|---------------|
| [weak self] in closures | Retain cycles |
| Timer.invalidate() in deinit | Timer leaks |
| Remove observers in deinit | Observer leaks |
| NSCache with limits | Unbounded cache growth |
| Image downsampling | Memory spikes |

### os_signpost for Custom Profiling

```swift
import os.signpost

let log = OSLog(subsystem: "com.app", category: .pointsOfInterest)

os_signpost(.begin, log: log, name: "DataProcessing")
// Expensive work
os_signpost(.end, log: log, name: "DataProcessing")
```

### Red Flags

| Smell | Problem | Fix |
|-------|---------|-----|
| Indices as List IDs | Views recreated on mutation | Use stable identifiers |
| Expensive body computation | Runs every render | Move to ViewModel |
| @StateObject for passed object | Creates new instance | Use @ObservedObject |
| Strong self in Timer/closure | Retain cycle | Use [weak self] |
| Full-res images for thumbnails | Memory explosion | Downsample to display size |
| Unbounded dictionary cache | Memory growth | Use NSCache with limits |
| Heavy work without Task.detached | Blocks main thread | Use background priority |

Overview

This skill provides concise, expert guidance for iOS and tvOS performance decisions: when to optimize, which profiling tools to run, SwiftUI view identity and update strategies, and practical memory management fixes. It focuses on measurable outcomes—reduce UI jank, stop memory growth, and improve battery life—by encouraging profiling-first workflows. Useable as a checklist and decision tree during debugging and design reviews.

How this skill works

It codifies decision trees for whether to optimize, maps common symptoms to the right Instruments tool, and gives concrete SwiftUI and memory fixes. The skill inspects symptoms (stutter, memory growth, battery drain, crashes) and recommends targeted actions: Time Profiler for main-thread work, Allocations/Leaks for memory, Core Animation for GPU issues, etc. It also supplies patterns to avoid (wrong identifiers, excessive @Published, strong self captures) and safe replacements (NSCache, downsampling, background tasks).

When to use it

  • You observe visible stutter, dropped frames, or slow screen transitions
  • App store reviews or users report slowness or high battery usage
  • Memory warnings, crashes, or steady memory growth during use
  • Before adding micro-optimizations—use when profiling evidence exists
  • When SwiftUI lists re-render unexpectedly or list performance degrades

Best practices

  • Profile first, optimize second—measure bottlenecks with Instruments before changing code
  • Use stable IDs and Equatable rows for lists; never use indices as identifiers
  • Move expensive computations out of View.body into ViewModel or cached properties
  • Avoid strong self in escaping closures; invalidate timers and remove observers in deinit
  • Use NSCache with count/size limits and downsample images to display size
  • Run heavy work off the main thread (Task.detached / background queue) and update UI on MainActor

Example use cases

  • Investigate a screen that drops frames while scrolling and trace heavy main-thread work with Time Profiler and Core Animation
  • Fix memory growth after navigating through screens by checking retain cycles with Allocations and Leaks and converting strong captures to [weak self]
  • Reduce app startup CPU and energy by profiling launch with Time Profiler and Energy Log and deferring non-essential work
  • Optimize image-heavy list by downsampling images, using NSCache with limits, and lazy list containers
  • Debounce search input and cancel previous tasks to prevent cascading network and UI updates

FAQ

When should I avoid optimizing?

Avoid changes when you only have a hunch. If no user-facing issue or measurement shows a bottleneck, prefer clean code and add profiling hooks to gather evidence later.

Which Instruments for memory leaks?

Start with Allocations to view growth and object lifetimes, then run Leaks to find leaked objects. Use object graph and retain path to locate strong reference cycles.

How do I prevent SwiftUI list re-renders?

Give rows stable IDs (not indices), make row views Equatable when possible, and move expensive transforms into a ViewModel so body is cheap.