
Performance Profiling
- 81 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Helps with ai & agent building tasks.
About
performance-profiling is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- performance-profiling
- AI & Agent Building
- AI-coding skill
Performance Profiling by the numbers
- 81 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #5,216 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill performance-profilingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Performance Profiling
Systematic guide for profiling Apple platform apps using Instruments, Xcode diagnostics, and MetricKit. Covers CPU, memory, launch time, and energy analysis with actionable fix patterns.
When This Skill Activates
Use this skill when the user:
- Reports app hangs, stutters, or dropped frames
- Needs to profile CPU usage or find hot code paths
- Has memory leaks, high memory usage, or OOM crashes
- Wants to optimize app launch time
- Needs to reduce battery/energy impact
- Asks about Instruments, Time Profiler, Allocations, or Leaks
- Wants to add
os_signpostor performance measurement to code - Is preparing for App Store review and needs performance validation
Decision Tree
What performance problem are you investigating?
│
├─ App hangs / stutters / dropped frames / slow UI
│ └─ Read time-profiler.md
│
├─ High memory / leaks / OOM crashes / growing footprint
│ └─ Read memory-profiling.md
│
├─ Slow app launch / time to first frame
│ └─ Read launch-optimization.md
│
├─ Battery drain / thermal throttling / background energy
│ └─ Read energy-diagnostics.md
│
├─ General "app feels slow" (unknown cause)
│ └─ Start with time-profiler.md, then memory-profiling.md
│
└─ Pre-release performance audit
└─ Read ALL reference files, use Review Checklist belowQuick Reference
| Problem | Instrument / Tool | Key Metric | Reference |
|---|---|---|---|
| UI hangs > 250ms | Time Profiler + Hangs | Hang duration, main thread stack | time-profiler.md |
| High CPU usage | Time Profiler | CPU % by function, call tree weight | time-profiler.md |
| Memory leak | Leaks + Memory Graph | Leaked bytes, retain cycle paths | memory-profiling.md |
| Memory growth | Allocations | Live bytes, generation analysis | memory-profiling.md |
| Slow launch | App Launch | Time to first frame (pre-main + post-main) | launch-optimization.md |
| Battery drain | Energy Log | Energy Impact score, CPU/GPU/network | energy-diagnostics.md |
| Thermal issues | Activity Monitor | Thermal state transitions | energy-diagnostics.md |
| Network waste | Network profiler | Redundant fetches, large payloads | energy-diagnostics.md |
Process
1. Identify the Problem Category
Ask the user or inspect their description to classify the issue:
- Responsiveness: Hangs, stutters, animation drops
- Memory: Leaks, growth, OOM crashes
- Launch: Slow cold/warm start
- Energy: Battery drain, thermal throttling
2. Read the Appropriate Reference File
Each file contains:
- Which Instruments template to use
- Step-by-step profiling workflow
- How to interpret results
- Common fix patterns with code examples
3. Profile on Real Hardware
Always remind users:
- Profile on device, not Simulator (Simulator uses host CPU/memory)
- Use Release build configuration (optimizations change behavior)
- Profile with representative data (empty databases hide real perf)
- Close other apps to reduce noise
4. Apply Fixes and Verify
After identifying bottlenecks:
- Apply targeted fix from the reference file
- Re-profile to confirm improvement
- Add
os_signpostmarkers for ongoing monitoring
Xcode Diagnostic Settings
Recommend enabling these in Scheme > Run > Diagnostics:
| Setting | What It Catches |
|---|---|
| Main Thread Checker | UI work off main thread |
| Thread Sanitizer | Data races |
| Address Sanitizer | Buffer overflows, use-after-free |
| Malloc Stack Logging | Memory allocation call stacks |
| Zombie Objects | Messages to deallocated objects |
MetricKit Integration
For production monitoring, recommend MetricKit:
import MetricKit
final class PerformanceReporter: NSObject, MXMetricManagerSubscriber {
func startCollecting() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
// Launch time
if let launch = payload.applicationLaunchMetrics {
log("Resume time: \(launch.histogrammedResumeTime)")
}
// Hang rate
if let responsiveness = payload.applicationResponsivenessMetrics {
log("Hang time: \(responsiveness.histogrammedApplicationHangTime)")
}
// Memory
if let memory = payload.memoryMetrics {
log("Peak memory: \(memory.peakMemoryUsage)")
}
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
if let hangs = payload.hangDiagnostics {
for hang in hangs {
log("Hang: \(hang.callStackTree)")
}
}
}
}
}Review Checklist
Responsiveness
- [ ] No synchronous work on main thread > 100ms
- [ ] No file I/O or network calls on main thread
- [ ] Core Data / SwiftData fetches use background contexts for large queries
- [ ] Images decoded off main thread (use
.preparingThumbnailor async decoding) - [ ]
@MainActoronly on code that truly needs UI access
Memory
- [ ] No retain cycles (check delegate patterns, closures with
self) - [ ] Large resources freed when not visible (images, caches)
- [ ] Collections don't grow unbounded (capped caches, pagination)
- [ ]
autoreleasepoolused in tight loops creating ObjC objects
Launch Time
- [ ] No heavy work in
init()of@main Appstruct - [ ] Deferred non-essential initialization (analytics, prefetch)
- [ ] Minimal dynamic frameworks (prefer static linking)
- [ ] No synchronous network calls at launch
Energy
- [ ] Background tasks use
BGProcessingTaskRequestappropriately - [ ] Location accuracy matches actual need (not always
.best) - [ ] Timers use
toleranceto allow coalescing - [ ] Network requests batched where possible
References
- time-profiler.md — CPU profiling, hang detection, signpost API
- memory-profiling.md — Allocations, Leaks, memory graph debugger
- launch-optimization.md — App launch phases, cold/warm start optimization
- energy-diagnostics.md — Battery, thermal state, network efficiency
- WWDC: Ultimate Application Performance Survival Guide
- WWDC: Analyze Hangs with Instruments
- WWDC: Detect and Diagnose Memory Issues
Energy Diagnostics
Why Energy Matters
- Battery drain is the #1 reason users delete apps
- Excessive energy use triggers App Store review flags
- iOS throttles apps with high energy impact in the background
- Thermal throttling degrades performance for all apps, not just yours
Energy Impact Instrument
Setup
1. Xcode > Product > Profile (Cmd+I) 2. Choose Energy Log template (iOS) or Activity Monitor template (macOS) 3. Record on physical device (energy profiling requires real hardware) 4. Reproduce typical usage patterns
Reading the Energy Log
The Energy Impact gauge shows a composite score:
| Level | Score | Meaning |
|---|---|---|
| Low | 0-3 | Good — minimal battery drain |
| Medium | 3-8 | Acceptable for active use, not idle |
| High | 8+ | Investigate — significant drain |
| Overhead | Red bar | System overhead from your app |
Component Breakdown
| Component | What It Measures |
|---|---|
| CPU | Processing time (biggest energy consumer) |
| GPU | Graphics rendering, Metal, animations |
| Network | Radio usage (cellular is most expensive) |
| Location | GPS, Wi-Fi, cell tower ranging |
| Display | Screen brightness influence (indirect) |
| Overhead | System services your app triggers |
Common Energy Drains and Fixes
Excessive Timer Usage
// Bad — timer fires every second even when app is idle
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.checkForUpdates() // Keeps CPU awake
}
// Good — use tolerance for coalescing, stop when not needed
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.updateTimestamp()
}
timer.tolerance = 0.5 // System can defer ±0.5s to coalesce with other work
// Better — use system notifications instead of polling
NotificationCenter.default.addObserver(
forName: .significantTimeChange, // System fires this at midnight, timezone change
object: nil, queue: .main
) { _ in self.updateDate() }Location Accuracy Over-specification
// Bad — GPS accuracy when you only need city-level
let manager = CLLocationManager()
manager.desiredAccuracy = kCLLocationAccuracyBest // GPS radio stays on
manager.startUpdatingLocation() // Continuous updates
// Good — match accuracy to need
// For weather/city features:
manager.desiredAccuracy = kCLLocationAccuracyKilometer
manager.requestLocation() // Single update, not continuous
// For navigation:
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
manager.activityType = .automotiveNavigation // Optimizes for driving
manager.allowsBackgroundLocationUpdates = true
manager.pausesLocationUpdatesAutomatically = true // Pauses when stationary
// For "nearby" features:
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
manager.distanceFilter = 100 // Only notify on 100m movementNetwork Request Inefficiency
// Bad — separate requests that could be batched
func refreshAll() async {
let profile = try? await api.fetchProfile()
let posts = try? await api.fetchPosts()
let notifications = try? await api.fetchNotifications()
let settings = try? await api.fetchSettings()
// 4 separate radio activations
}
// Good — batch into single request or parallel group
func refreshAll() async {
async let profile = api.fetchProfile()
async let posts = api.fetchPosts()
async let notifications = api.fetchNotifications()
// 3 requests but radio stays active for one burst
let results = await (profile, posts, notifications)
}
// Better — single batch endpoint
func refreshAll() async {
let batch = try? await api.fetchDashboard()
// 1 request with all data
}Cellular vs WiFi Awareness
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.usesInterfaceType(.cellular) {
// Reduce data usage
self.imageQuality = .low
self.prefetchEnabled = false
self.analyticsFlushInterval = 300 // 5 min instead of 30s
} else if path.usesInterfaceType(.wifi) {
self.imageQuality = .high
self.prefetchEnabled = true
self.analyticsFlushInterval = 30
}
}
monitor.start(queue: .main)Background Task Energy
// Bad — long-running background work without proper task
func applicationDidEnterBackground(_ application: UIApplication) {
syncAllData() // May be killed, wastes energy if interrupted
}
// Good — use BGTaskScheduler for deferrable work
import BackgroundTasks
func scheduleSync() {
let request = BGProcessingTaskRequest(identifier: "com.app.sync")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false // Set true for heavy work
try? BGTaskScheduler.shared.submit(request)
}
func handleSync(task: BGProcessingTask) {
let syncTask = Task {
await performSync()
task.setTaskCompleted(success: true)
}
task.expirationHandler = {
syncTask.cancel()
task.setTaskCompleted(success: false)
}
}Animation Energy
// Bad — continuous animation running when not visible
struct PulsingView: View {
@State private var isPulsing = false
var body: some View {
Circle()
.scaleEffect(isPulsing ? 1.2 : 1.0)
.animation(.easeInOut(duration: 1).repeatForever(), value: isPulsing)
.onAppear { isPulsing = true }
}
}
// Good — stop animation when not visible
struct PulsingView: View {
@State private var isPulsing = false
@Environment(\.scenePhase) var scenePhase
var body: some View {
Circle()
.scaleEffect(isPulsing ? 1.2 : 1.0)
.animation(
isPulsing ? .easeInOut(duration: 1).repeatForever() : .default,
value: isPulsing
)
.onChange(of: scenePhase) { _, phase in
isPulsing = (phase == .active)
}
}
}Thermal State Monitoring
import Foundation
func monitorThermalState() {
NotificationCenter.default.addObserver(
forName: ProcessInfo.thermalStateDidChangeNotification,
object: nil, queue: .main
) { _ in
handleThermalState(ProcessInfo.processInfo.thermalState)
}
}
func handleThermalState(_ state: ProcessInfo.ThermalState) {
switch state {
case .nominal:
// Full performance
enableAllFeatures()
case .fair:
// Slightly warm — reduce optional work
disablePreloading()
case .serious:
// Hot — reduce significantly
reduceAnimations()
lowerImageQuality()
pauseBackgroundSync()
case .critical:
// Thermal throttling imminent — minimize everything
stopNonEssentialWork()
showThermalWarningIfAppropriate()
@unknown default:
break
}
}MetricKit Energy Metrics
Production energy data:
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
// CPU usage
if let cpu = payload.cpuMetrics {
log("Cumulative CPU time: \(cpu.cumulativeCPUTime)")
log("CPU instructions: \(cpu.cumulativeCPUInstructions)")
}
// GPU usage
if let gpu = payload.gpuMetrics {
log("Cumulative GPU time: \(gpu.cumulativeGPUTime)")
}
// Cellular condition
if let cellular = payload.cellularConditionMetrics {
log("Cell condition time: \(cellular.histogrammedCellularConditionTime)")
}
// Network transfer
if let network = payload.networkTransferMetrics {
log("WiFi upload: \(network.cumulativeWifiUpload)")
log("WiFi download: \(network.cumulativeWifiDownload)")
log("Cellular upload: \(network.cumulativeCellularUpload)")
log("Cellular download: \(network.cumulativeCellularDownload)")
}
// Location activity
if let location = payload.locationActivityMetrics {
log("Best accuracy time: \(location.cumulativeBestAccuracyTime)")
log("10m accuracy time: \(location.cumulativeBestAccuracyForNavigationTime)")
}
}
}Xcode Energy Organizer
Access in Xcode > Window > Organizer > Energy:
- Shows energy reports from real users via TestFlight and App Store
- Breaks down by: CPU, Location, Display, Network, GPU, Accessories
- Filter by app version to track regressions
- Shows background energy separately — critical for battery complaints
Energy Optimization Checklist
CPU
- [ ] No polling timers without
toleranceset - [ ] Background work uses
BGTaskScheduler, not continuous timers - [ ] Heavy computation offloaded and cancellable
- [ ] Idle state truly idle — no periodic wake-ups without reason
Network
- [ ] Requests batched when possible (single endpoint > multiple calls)
- [ ] Reduced data on cellular (lower image quality, less prefetch)
- [ ] No redundant requests (proper caching with
URLCache/ ETags) - [ ] Background uploads use
URLSessionbackground configuration
Location
- [ ] Accuracy matches actual need (
kCLLocationAccuracyKilometerfor weather) - [ ]
requestLocation()for one-shot needs, notstartUpdatingLocation() - [ ]
distanceFilterset to avoid unnecessary updates - [ ]
pausesLocationUpdatesAutomatically = truewhen appropriate
GPU / Animations
- [ ] Animations pause when app enters background
- [ ] No offscreen rendering (shadow, cornerRadius + clipsToBounds)
- [ ] Metal workloads respect thermal state
- [ ] Continuous animations stop when not visible
Thermal
- [ ] App monitors
ProcessInfo.thermalState - [ ] Graceful degradation at
.seriousand.criticalstates - [ ] Heavy features disabled proactively, not reactively
Launch Time Optimization
App Launch Phases
Cold Launch Timeline
────────────────────────────────────────────────────────
│ Pre-main │ Post-main │
│ │ │
│ DYLD Runtime │ App Init First Frame │
│ loading init │ UIKit/SwiftUI setup │
│ ────────── ────────── │ ──────────── ────────── │
│ dylibs +load │ AppDelegate viewDidLoad│
│ rebasing static │ @main App .body │
│ binding initializers│ scene setup layout │
│ ObjC setup │ root view render │
────────────────────────────────────────────────────────
↑
First Frame Rendered
(launch complete)Target Budgets
| Launch Type | Good | Acceptable | Poor |
|---|---|---|---|
| Cold launch | < 400ms | < 1s | > 2s |
| Warm launch | < 200ms | < 500ms | > 1s |
| Resume | < 100ms | < 200ms | > 500ms |
Cold launch: App not in memory — full DYLD load, runtime init, UI setup. Warm launch: App recently terminated but system cached some data. Resume: App was suspended in background.
App Launch Instrument
Setup
1. Xcode > Product > Profile (Cmd+I) 2. Choose App Launch template 3. Record — the app launches and Instruments captures the entire timeline 4. Stop after app is fully interactive
Reading the Results
- Process Lifecycle lane shows app state transitions
- Thread State lane shows main thread activity during launch
- Time Profiler lane shows CPU work during launch
- Focus on the region between process start and first frame
Key Measurements
- Time to first frame: Total duration from process start to first CA commit
- Pre-main time: From process start to
main()/@mainentry - Post-main time: From
main()to first frame rendered
Pre-main Optimization
Reduce Dynamic Frameworks
Each dynamic framework adds ~10-30ms to launch:
Before: 15 dynamic frameworks → ~300ms DYLD loading
After: 3 dynamic frameworks → ~50ms DYLD loadingHow to fix:
- Convert dynamic frameworks to static libraries where possible
- Use Swift Package Manager (static by default) instead of dynamic frameworks
- Merge small frameworks into fewer larger ones
- Check with:
otool -L YourApp.app/YourAppto list linked dylibs
Remove Static Initializers
Static initializers (+load, __attribute__((constructor))) run before main():
// Bad — runs before main(), delays launch
class LegacyManager: NSObject {
override class func load() { // ObjC +load
setup()
}
}
// Bad — C-style constructor
@_cdecl("initEarly")
func initEarly() { /* runs before main */ }
// Good — defer to first use
class LegacyManager {
static let shared = LegacyManager() // Lazy, created on first access
}Minimize ObjC Metadata
- Large ObjC class hierarchies increase rebasing/binding time
- Swift classes without
@objcare more efficient - Reduce ObjC category usage (each category adds metadata)
Post-main Optimization
Defer Non-Essential Initialization
@main
struct MyApp: App {
init() {
// Bad — all of this delays first frame
AnalyticsManager.shared.configure()
CrashReporter.shared.start()
RemoteConfig.shared.fetch()
ImageCache.shared.warmUp()
DatabaseMigrator.run()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
// Good — only essential work at init, defer the rest
@main
struct MyApp: App {
init() {
// Only crash reporter is truly needed immediately
CrashReporter.shared.start()
}
var body: some Scene {
WindowGroup {
ContentView()
.task {
// Defer everything else to after first frame
await deferredSetup()
}
}
}
private func deferredSetup() async {
AnalyticsManager.shared.configure()
RemoteConfig.shared.fetch()
ImageCache.shared.warmUp()
}
}Lazy View Loading
// Bad — all tabs initialize their full view hierarchy at launch
TabView {
HomeView() // Heavy: fetches data, builds complex layout
SearchView() // Heavy: initializes search index
ProfileView() // Heavy: loads user data
}
// Good — use lazy containers, each tab builds only when selected
TabView {
NavigationStack {
HomeView()
}
.tabItem { Label("Home", systemImage: "house") }
NavigationStack {
SearchView()
}
.tabItem { Label("Search", systemImage: "magnifyingglass") }
}
// SwiftUI NavigationStack already lazy-loads destination views
// For custom containers, use LazyVStack instead of VStackReduce Initial View Complexity
// Bad — loading everything at once
struct ContentView: View {
@State private var allItems: [Item] = []
var body: some View {
List(allItems) { item in
ComplexItemView(item: item)
}
.onAppear {
allItems = try! ModelContext(container).fetch(
FetchDescriptor<Item>(sortBy: [SortDescriptor(\.date)])
)
}
}
}
// Good — show skeleton immediately, load data async
struct ContentView: View {
@State private var items: [Item]?
var body: some View {
Group {
if let items {
List(items) { item in
ItemRow(item: item)
}
} else {
SkeletonListView() // Lightweight placeholder
}
}
.task {
items = await loadItems()
}
}
}Database Migration at Launch
// Bad — blocking migration on main thread
let container = try ModelContainer(for: Item.self)
// If migration runs, this blocks until complete
// Good — show migration UI if needed
struct AppEntry: View {
@State private var container: ModelContainer?
@State private var isMigrating = false
var body: some View {
Group {
if let container {
ContentView()
.modelContainer(container)
} else if isMigrating {
MigrationProgressView()
} else {
ProgressView()
}
}
.task {
await setupContainer()
}
}
private func setupContainer() async {
// Check if migration needed
if await needsMigration() {
isMigrating = true
}
container = try? await Task.detached {
try ModelContainer(for: Item.self)
}.value
isMigrating = false
}
}Measuring Launch Time in Code
Using os_signpost
import os
@main
struct MyApp: App {
static let launchLog = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Launch")
static let launchSignpost = OSSignpostID(log: launchLog)
init() {
os_signpost(.begin, log: Self.launchLog, name: "AppLaunch", signpostID: Self.launchSignpost)
}
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
os_signpost(.end, log: Self.launchLog, name: "AppLaunch", signpostID: Self.launchSignpost)
}
}
}
}Using CFAbsoluteTimeGetCurrent
// Quick and dirty launch measurement
@main
struct MyApp: App {
static let launchStart = CFAbsoluteTimeGetCurrent()
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
let elapsed = CFAbsoluteTimeGetCurrent() - Self.launchStart
print("Launch time: \(String(format: "%.3f", elapsed))s")
}
}
}
}MetricKit Launch Metrics
Production launch time data from real users:
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
if let launch = payload.applicationLaunchMetrics {
// Histogram of time-to-first-draw
let histogram = launch.histogrammedTimeToFirstDraw
for bucket in histogram.bucketEnumerator {
guard let bucket = bucket as? MXHistogramBucket<UnitDuration> else { continue }
print("[\(bucket.bucketStart) - \(bucket.bucketEnd)]: \(bucket.bucketCount) launches")
}
// Resume time histogram
let resumeHistogram = launch.histogrammedResumeTime
// Same enumeration pattern
}
}
}Launch Optimization Checklist
Pre-main
- [ ] Minimize dynamic frameworks (prefer static linking)
- [ ] No
+loadmethods or static initializers - [ ] Remove unused frameworks from Link Binary With Libraries
- [ ] Strip unused architectures in release builds
Post-main (App Init)
- [ ] Only essential setup in
App.init()orAppDelegate(crash reporter only) - [ ] Analytics, remote config, and prefetch deferred to
.task {}after first frame - [ ] No synchronous network calls at launch
- [ ] No blocking database migrations on main thread
First Frame
- [ ] Initial view is lightweight (skeleton/placeholder)
- [ ] Data loading is async with
.task {} - [ ] Heavy views (maps, web views, complex lists) lazy-loaded
- [ ] Images use thumbnails, not full resolution
Verification
- [ ] Measure with App Launch Instrument on oldest supported device
- [ ] Cold launch < 400ms on target device
- [ ] No regressions after changes (compare Instruments traces)
- [ ] MetricKit showing stable or improving launch times in production
Memory Profiling
Instruments Setup
Allocations Instrument
1. Xcode > Product > Profile (Cmd+I) 2. Choose Allocations template 3. Record, reproduce the issue, stop
Leaks Instrument
- Included in the Allocations template by default
- Runs periodic scans for unreachable memory
- Red crosses in the Leaks lane indicate detected leaks
Allocations Instrument
Key Metrics
| Metric | Meaning |
|---|---|
| All Heap Allocations | Total memory allocated on the heap |
| Live Bytes | Currently allocated (not freed) memory |
| Transient Bytes | Allocated and freed during recording |
| # Living | Count of live allocation objects |
| # Transient | Count of freed allocations |
Reading the Results
- Sort by Live Bytes to find largest memory consumers
- Sort by # Living to find most numerous allocations
- Click a category to see individual allocations with stack traces
- Persistent column shows objects that survive across generations
Generation Analysis (Mark Generation)
Track memory growth over repeated operations:
1. Start recording 2. Click Mark Generation to baseline 3. Perform an operation (open screen, load data, etc.) 4. Click Mark Generation again 5. Repeat the same operation 6. Compare generations — growth between identical operations = leak or unbounded cache
What to look for:
- Stable: Each generation has similar Live Bytes
- Leaking: Each generation grows — objects from previous generations persist
- Caching: Growth that plateaus after a few generations (may be intentional)
Common Memory Issues
Unbounded Cache Growth
// Bad — cache grows forever
class ImageCache {
var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
return cache[url]
}
}
// Good — use NSCache for automatic eviction
class ImageCache {
private let cache = NSCache<NSURL, UIImage>()
init() {
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
}
func image(for url: URL) -> UIImage? {
return cache.object(forKey: url as NSURL)
}
func store(_ image: UIImage, for url: URL) {
let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0
cache.setObject(image, forKey: url as NSURL, cost: cost)
}
}Large Images Not Downsampled
// Bad — full resolution image loaded into memory
let image = UIImage(contentsOfFile: photoPath)
// 4032x3024 photo = ~48 MB in memory
// Good — downsample to display size
func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
]
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
else { return nil }
return UIImage(cgImage: cgImage)
}Leaks Instrument
How Leaks Detection Works
- Scans heap at intervals for objects with no root references
- Detects unreachable memory — allocated but no path from stack/globals
- Does NOT detect logical leaks (reachable but never used, like unbounded caches)
Common Retain Cycle Patterns
Closure Capturing Self
// Bad — retain cycle: self -> timer -> closure -> self
class ViewModel {
var timer: Timer?
func startPolling() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
self.refresh() // Strong capture of self
}
}
}
// Good — weak capture
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
self?.refresh()
}Delegate Retain Cycle
// Bad — strong delegate creates cycle
protocol ServiceDelegate: AnyObject {
func didComplete()
}
class Service {
var delegate: ServiceDelegate? // Strong reference
}
class ViewModel: ServiceDelegate {
let service = Service() // ViewModel -> Service -> ViewModel
init() { service.delegate = self }
}
// Good — weak delegate
class Service {
weak var delegate: ServiceDelegate?
}Closure in Collection
// Bad — closures in array capture self strongly
class Coordinator {
var handlers: [() -> Void] = []
func register() {
handlers.append {
self.handleEvent() // Each closure retains self
}
}
}
// Good — weak capture in each closure
handlers.append { [weak self] in
self?.handleEvent()
}NotificationCenter (Pre-iOS 9 / Manual)
// Modern iOS/macOS: system auto-removes observers on dealloc
// But if using block-based API, the token retains the closure:
// Bad — token keeps closure alive
let token = NotificationCenter.default.addObserver(
forName: .dataChanged, object: nil, queue: .main
) { _ in
self.reload() // Retained by token
}
// Good — store and remove, or use weak self
let token = NotificationCenter.default.addObserver(
forName: .dataChanged, object: nil, queue: .main
) { [weak self] _ in
self?.reload()
}Xcode Memory Graph Debugger
When to Use
- Leaks instrument shows leaks but you need to see why objects are retained
- You suspect retain cycles but can't find them in code review
- You need to see the full ownership graph of an object
How to Use
1. Run app in Debug mode (not Profile) 2. Reproduce the state where objects should be deallocated 3. Click the Memory Graph button in Xcode's debug bar (looks like interconnected nodes) 4. Xcode pauses and captures the heap
Reading the Graph
- Left sidebar: List of all live objects grouped by type
- Purple exclamation marks: Xcode-detected leaks / retain cycles
- Center canvas: Visual graph of object references
- Click an object to see its retain graph — who holds a strong reference to it
Debugging Tips
- Filter by your module name to ignore system objects
- Look for cycles — two objects pointing at each other
- Check backtrace in the right sidebar to see where the object was allocated
- Export the graph: File > Export Memory Graph for sharing
Enable Malloc Stack Logging
For full allocation backtraces in Memory Graph Debugger: 1. Scheme > Run > Diagnostics 2. Enable Malloc Stack Logging (Live Allocations Only) 3. Now the right sidebar shows the exact call stack that allocated each object
Autorelease Pool Optimization
In tight loops creating Objective-C bridged objects:
// Bad — autoreleased objects accumulate until loop ends
for item in largeDataSet {
let string = item.name as NSString // Autoreleased
let data = string.data(using: .utf8) // Autoreleased
process(data)
}
// Memory spikes until loop completes and pool drains
// Good — drain pool each iteration
for item in largeDataSet {
autoreleasepool {
let string = item.name as NSString
let data = string.data(using: .utf8)
process(data)
}
}
// Memory stays flat — pool drains each iterationWhen autoreleasepool Matters
- Processing large collections (1000+ items)
- Creating temporary
NSString,NSData,NSNumberobjects in loops - Image processing pipelines
- Not needed for pure Swift value types (String, Data, Array)
Memory Footprint Tracking in Code
import os
func logMemoryFootprint() {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(
MemoryLayout<mach_task_basic_info>.size / MemoryLayout<natural_t>.size
)
let result = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
if result == KERN_SUCCESS {
let usedMB = Double(info.resident_size) / 1_048_576
os_log("Memory footprint: %.1f MB", usedMB)
}
}Quick Diagnosis Checklist
- [ ] Run Allocations with Generation Analysis — does memory grow across identical operations?
- [ ] Run Leaks instrument — any detected leaks?
- [ ] Check Memory Graph Debugger for retain cycles (purple warnings)
- [ ] Search for
selfin closures without[weak self]or[unowned self] - [ ] Verify delegates are declared
weak - [ ] Check
NSCacheusage instead of plain dictionaries for caches - [ ] Verify images are downsampled to display size
- [ ] Use
autoreleasepoolin tight loops with ObjC-bridged objects
Time Profiler & Hang Detection
Instruments Setup
Time Profiler Template
1. Xcode > Product > Profile (Cmd+I) — builds Release and opens Instruments 2. Choose Time Profiler template 3. Click record, reproduce the slow operation, stop recording
Key Settings
- Recording Mode: Deferred (lower overhead, recommended for production-like profiling)
- High Frequency: 10ms sampling for fine-grained analysis (default is 1ms)
- Record Waiting Threads: Enable to see threads blocked on locks/IO
Reading Time Profiler Results
Call Tree Navigation
- Weight: Total time spent in function + all children
- Self Weight: Time in just that function (no children)
- Symbol Name: Function/method being sampled
Essential Filters
| Filter | Purpose |
|---|---|
| Separate by Thread | Isolate main thread from background |
| Invert Call Tree | Show leaf functions first (where time is actually spent) |
| Hide System Libraries | Focus on your code |
| Flatten Recursion | Collapse recursive calls into one entry |
Typical Workflow
1. Separate by Thread — find Main Thread 2. Invert Call Tree — heaviest leaf functions appear at top 3. Hide System Libraries — focus on your functions 4. Click disclosure triangle to trace back through the call chain 5. Double-click a function to jump to source code
Hang Detection
What Counts as a Hang
| Duration | Classification | User Perception |
|---|---|---|
| < 100ms | Acceptable | Smooth |
| 100–250ms | Micro-hang | Slight stutter |
| 250ms–1s | Hang | Noticeable delay |
| > 1s | Severe hang | App feels broken |
| > 3s | Watchdog kill risk | System may terminate |
Using the Hangs Instrument
1. Open Instruments, choose Time Profiler (includes Hang detection lane) 2. Or use standalone Animation Hitches instrument for UI-specific stalls 3. Record and reproduce the interaction 4. Orange/red regions in the Hangs lane indicate hang duration 5. Click a hang to see the main thread call stack at that moment
Common Hang Causes and Fixes
Synchronous File I/O on Main Thread
// Bad — blocks main thread
func loadData() -> Data {
return try! Data(contentsOf: largeFileURL)
}
// Good — move to background
func loadData() async -> Data {
return try! await Task.detached {
try Data(contentsOf: largeFileURL)
}.value
}Synchronous Network Call
// Bad — URLSession.shared.dataTask is async but
// this pattern blocks main thread waiting for result
let data = try! Data(contentsOf: remoteURL)
// Good — use async/await
let (data, _) = try await URLSession.shared.data(from: remoteURL)Heavy Computation on Main Thread
// Bad — sorting/filtering large arrays on main thread
func updateUI() {
let sorted = hugeArray.sorted { $0.score > $1.score }
tableView.reloadData()
}
// Good — offload computation
func updateUI() async {
let sorted = await Task.detached {
hugeArray.sorted { $0.score > $1.score }
}.value
tableView.reloadData()
}Core Data / SwiftData Fetch on Main Thread
// Bad — fetching thousands of objects blocks UI
let items = try context.fetch(FetchDescriptor<Item>())
// Good — use background context (SwiftData)
let items = try await Task.detached {
let context = ModelContext(container)
return try context.fetch(FetchDescriptor<Item>())
}.valueImage Decoding on Main Thread
// Bad — decoding happens lazily on first display, blocking main thread
imageView.image = UIImage(contentsOfFile: path)
// Good — decode off main thread (iOS 15+)
let thumbnail = await UIImage(contentsOfFile: path)?
.byPreparingThumbnail(ofSize: targetSize)
imageView.image = thumbnail
// Good — using preparingForDisplay
let decoded = await UIImage(contentsOfFile: path)?
.byPreparingForDisplay()os_signpost API for Custom Profiling
Add signposts to measure specific operations in Instruments:
import os
extension OSSignpostID {
static let dataLoad = OSSignpostID(log: .performance)
}
extension OSLog {
static let performance = OSLog(
subsystem: Bundle.main.bundleIdentifier!,
category: "Performance"
)
}
// Mark intervals
func loadData() async throws -> [Item] {
let signpostID = OSSignpostID(log: .performance)
os_signpost(.begin, log: .performance, name: "DataLoad", signpostID: signpostID)
defer {
os_signpost(.end, log: .performance, name: "DataLoad", signpostID: signpostID)
}
// ... actual work
return items
}
// Mark events (single point)
os_signpost(.event, log: .performance, name: "CacheMiss")Viewing Signposts in Instruments
1. Add os_signpost instrument to your trace 2. Filter by subsystem/category to find your markers 3. Intervals show as bars with duration, events show as points
Thread Performance Checker
Enable in Scheme > Run > Diagnostics > Thread Performance Checker.
Detects at runtime:
- Priority inversions: High-priority thread waiting on low-priority thread
- Non-UI work on main thread: Disk I/O, network calls detected on main
- Excessive thread creation: Spawning too many threads
Issues appear as purple runtime warnings in Xcode's Issue Navigator.
Profiling Tips
Profile What Matters
- Always profile on physical device — Simulator has different CPU/memory characteristics
- Use Release configuration — Debug builds disable compiler optimizations
- Profile with realistic data — 10 items vs 10,000 items reveals different bottlenecks
- Warm the app first — first run includes one-time setup that skews results
Interpreting Results
- Focus on Self Weight to find actual bottlenecks (not just call tree weight)
- A function with high weight but low self-weight is just a caller — dig deeper
- Compare before/after traces to validate fixes
- Look for repeated patterns — a function called 1000x at 1ms each = 1s hang
Quick Wins Checklist
- [ ] Move all file I/O off main thread
- [ ] Decode images asynchronously
- [ ] Use
@MainActorsparingly — only for actual UI updates - [ ] Batch database writes instead of single-row inserts
- [ ] Cache expensive computations (JSON parsing, date formatting)
- [ ] Use
DateFormatter/NumberFormatteras shared instances (creation is expensive)