From ios-craft
Practical performance guide for SwiftUI apps. Use when the app feels slow, scrolling janks, or launch takes too long. Focuses on the 20% of knowledge that fixes 80% of performance issues. Measure first, then optimize.
npx claudepluginhub ildunari/kosta-plugins --plugin ios-craftThis skill uses the workspace's default tool permissions.
Guide the user through diagnosing and fixing common performance issues in SwiftUI apps. The core principle: measure first, then optimize. Never guess at performance problems.
Provides Ktor server patterns for routing DSL, plugins (auth, CORS, serialization), Koin DI, WebSockets, services, and testApplication testing.
Conducts multi-source web research with firecrawl and exa MCPs: searches, scrapes pages, synthesizes cited reports. For deep dives, competitive analysis, tech evaluations, or due diligence.
Provides demand forecasting, safety stock optimization, replenishment planning, and promotional lift estimation for multi-location retailers managing 300-800 SKUs.
Guide the user through diagnosing and fixing common performance issues in SwiftUI apps. The core principle: measure first, then optimize. Never guess at performance problems.
Before optimizing anything, confirm the problem exists and locate it.
Quick measurement:
// Add to any view body to see how often it re-evaluates
let _ = Self._printChanges() // Prints which properties triggered a re-render
Xcode performance gauges (visible during debugging):
Rule of thumb: If the user can't notice the slowness, don't optimize. Premature optimization makes code harder to maintain for no user benefit.
The single most important SwiftUI performance concept: the body property should be fast, pure, and free of side effects.
// BAD — expensive work in body
var body: some View {
let filtered = items.filter { $0.isActive } // Re-runs every render
let sorted = filtered.sorted { $0.date > $1.date } // Re-runs every render
List(sorted) { item in
ItemRow(item: item)
}
}
// GOOD — compute once, store in state
@State private var displayItems: [Item] = []
var body: some View {
List(displayItems) { item in
ItemRow(item: item)
}
.onChange(of: items) { _, newItems in
displayItems = newItems.filter(\.isActive).sorted { $0.date > $1.date }
}
}
What NOT to do in body:
_printChanges() during debugging)Breaking large views into smaller components helps SwiftUI's diffing engine. When a parent re-renders, only child views whose inputs changed will re-render.
// BAD — one massive body, everything re-renders together
var body: some View {
VStack {
Text(title) // Re-renders when count changes
Text("\(count)") // Triggers re-render
Image(image) // Re-renders when count changes (unnecessary)
LargeChart(data) // Re-renders when count changes (expensive!)
}
}
// GOOD — extracted views only re-render when their inputs change
var body: some View {
VStack {
TitleView(title: title)
CounterView(count: count)
ImageView(image: image) // Only re-renders if image changes
ChartView(data: data) // Only re-renders if data changes
}
}
When to extract:
VStack creates ALL child views immediately. LazyVStack creates them on demand as they scroll into view.
// BAD — creates 10,000 views at once
ScrollView {
VStack {
ForEach(items) { item in // All 10,000 rows rendered immediately
ItemRow(item: item)
}
}
}
// GOOD — creates views only as needed
ScrollView {
LazyVStack {
ForEach(items) { item in // Only visible rows + buffer rendered
ItemRow(item: item)
}
}
}
When to use which:
| Container | Use when |
|---|---|
VStack | < 50 items, or items are cheap to create |
LazyVStack | > 50 items, or items are expensive (images, complex layouts) |
List | Need selection, swipe actions, or platform-native styling |
LazyVStack gotcha: Don't wrap it in a GeometryReader or use .frame(height:) on individual rows — this can defeat the laziness.
Images are the most common performance bottleneck in iOS apps.
// BAD — loads full-resolution image for a thumbnail
AsyncImage(url: imageURL) { image in
image.resizable().frame(width: 80, height: 80)
} placeholder: {
ProgressView()
}
// Problem: downloads and decodes a 4000x3000 image, then scales it down
// GOOD — request thumbnail size from server (if API supports it)
AsyncImage(url: thumbnailURL) { image in
image.resizable().frame(width: 80, height: 80)
} placeholder: {
ProgressView()
}
// GOOD — downscale on decode for local images
func downsampledImage(at url: URL, to size: CGSize, scale: CGFloat) -> UIImage? {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else { return nil }
let maxDimension = max(size.width, size.height) * scale
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
]
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
return UIImage(cgImage: thumbnail)
}
Image performance rules:
.resizable() + .frame() to set display sizeSwiftUI uses identity to track which items changed. Wrong identity = unnecessary work.
// BAD — using array index as identity
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
ItemRow(item: item)
}
// Problem: inserting at top makes SwiftUI think EVERY row changed
// GOOD — use stable, unique identity
ForEach(items) { item in // Uses item.id (Identifiable conformance)
ItemRow(item: item)
}
// GOOD — explicit stable ID
ForEach(items, id: \.uniqueID) { item in
ItemRow(item: item)
}
Identity rules:
Identifiable with a stable UUID or database IDMove heavy work off the main thread:
// BAD — blocks the UI
func loadData() {
let processed = heavyProcessing(rawData) // Main thread blocked
self.data = processed
}
// GOOD — background processing
func loadData() async {
let raw = rawData
let processed = await Task.detached(priority: .userInitiated) {
heavyProcessing(raw) // Runs on background thread
}.value
self.data = processed // Back on main thread (in @MainActor context)
}
What counts as "expensive":
Target: < 400ms to first meaningful content.
Quick wins:
@AppStorage for simple preferences instead of loading a config fileApp struct@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
// Defer non-critical work
await initializeAnalytics()
await prefetchSecondaryData()
}
}
}
}
Measure launch time:
DYLD_PRINT_STATISTICS to your scheme's environment variablesSee references/instruments-5-minute-guide.md for a step-by-step walkthrough.
The two instruments that solve 90% of performance issues:
See references/swiftui-performance-quick-wins.md for 10 high-impact fixes with before/after code.
Summary:
LazyVStack/LazyHStack for long lists_printChanges() to find unnecessary re-rendersForEach@Observable (iOS 17+) instead of @ObservedObject for finer-grained updates.task or onChange