
Axiom Performance
- 719 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-performance is a Claude Code skill that routes Apple-platform performance work—slow UI, memory leaks, battery drain, and profiling—through Axiom playbooks covering Instruments, retain cycles, and memory debugging.
About
axiom-performance is the mandatory performance router in the Axiom skill suite for Apple-platform apps. When an app feels laggy, memory climbs, the device overheats, or Battery Settings show high energy use, the skill directs developers to the right playbook: Instruments profiling, retain-cycle detection, memory-leak workflows, and optimization audits. axiom-performance also covers memory-warning crashes and explicit guidance that any performance issue must flow through this router. Developers on iOS, macOS, or other Apple stacks reach for axiom-performance instead of generic web perf advice.
- Mandatory router for any performance issue: slowness, memory growth, battery, or heat
- Swift memory leak patterns and deinit-not-called diagnosis via dedicated skill refs
- Objective-C block retain-cycle guidance for network callback leaks
- Instruments decision trees: Time Profiler, Allocations, Core Data N+1
- memory-auditor agent path: 5-phase semantic audit citing 6 leak patterns and lifecycle scoring
Axiom Performance by the numbers
- 719 all-time installs (skills.sh)
- Ranked #257 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-performanceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 719 |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you profile memory leaks on Apple platforms?
Route Apple-platform performance work—slow UI, leaks, battery drain—through the right Axiom profiling and memory-debugging playbooks and audits.
Who is it for?
Apple-platform developers diagnosing lag, memory growth, battery drain, or crashes with memory warnings using Instruments.
Skip if: Web-only frontend performance tuning, Android profiling, or backend server latency optimization without an Apple client.
When should I use this skill?
An Apple app is slow, memory usage grows, battery drains quickly, the device heats up, or Instruments profiling is needed.
What you get
Instruments traces, retain-cycle fixes, memory-leak resolutions, and battery or thermal optimization actions.
- Instruments profile session
- Memory-leak fix plan
- Performance optimization audit
Files
Performance
You MUST use this skill for ANY performance issue including memory leaks, slow execution, battery drain, or profiling.
When to Use
Use this router when:
- App feels slow or laggy
- Memory usage grows over time
- Battery drains quickly
- Device gets hot during use
- High energy usage in Battery Settings
- Diagnosing performance with Instruments
- Memory leaks or retain cycles
- App crashes with memory warnings
Routing Logic
Memory Issues
Memory leaks (Swift) → See skills/memory-debugging.md
- Systematic leak diagnosis
- 5 common leak patterns
- Instruments workflows
- deinit not called
Memory leak scan → Launch memory-auditor agent or /axiom:audit memory (5-phase semantic audit: maps resource ownership, detects 6 leak patterns, reasons about missing cleanup, correlates compound risks, scores lifecycle health)
Memory leaks (Objective-C blocks) → See skills/objc-block-retain-cycles.md
- Block retain cycles
- Weak-strong pattern
- Network callback leaks
Performance Profiling
Performance profiling (GUI) → See skills/performance-profiling.md
- Time Profiler (CPU), incl. Top Functions mode for scattered overhead (
OS27) - Allocations (memory growth)
- Core Data profiling (N+1 queries)
- Decision trees for tool selection
- Instruments 27 Run Comparisons, Swift executors instrument, Foundation Models instrument; Xcode 27 Organizer (Storage/hitches metrics, Metric Goals, Generate Recommendations)
Automated profiling (CLI) → See skills/xctrace-ref.md
- Headless xctrace profiling
- CI/CD integration patterns
- Command-line trace recording
- Programmatic trace analysis
Run automated profile → Use performance-profiler agent or /axiom:profile
- Records trace via xctrace
- Exports and analyzes data
- Reports findings with severity
Compare two traces / detect regressions → See skills/trace-comparison.md or /axiom:compare-traces
- Did this change slow down a hot path? Function-level CPU-share deltas
- CI gating with
xcprof compare --fail-on-regression(non-zero exit) - Regressions vs improvements, severity ranking, exit-code semantics
Hang/Freeze Issues
App hangs or freezes → See skills/hang-diagnostics.md
- UI unresponsive for >1 second
- Main thread blocked (busy or waiting)
- Decision tree: busy vs blocked diagnosis
- Time Profiler vs System Trace selection
- 8 common hang patterns with fixes
- Watchdog terminations
Corpus/aggregate hang triage (Sentry, ASC) → axiom-shipping (skills/production-triage.md) + triage-analyzer agent
- Multiple grouped hang reports from an aggregator, not a single .ips file
- Classify
anr_idle_runloopvsanr_main_thread_blockacross the corpus - Flag suspension/idle-runloop false-positives (the #1 hang by user count is often noise)
- Cluster into root-cause families and rank by impact
App Launch
Slow app launch → See skills/app-launch.md
- Slow first frame, frozen first screen, launch regression in Organizer
- Launch-phase model (pre-main / main→first frame / extended launch)
- Cold vs warm vs hot/resume vs notification launch — how to reproduce each
- App Launch instrument workflow,
dyld Activity, measurement hygiene - Pre-main fixes (frameworks,
+load, mergeable libraries), main-thread deferral, priority inversion XCTApplicationLaunchMetricregression test,MXAppLaunchMetricfield histograms, custom "app is interactive" signpost- Push-notification launch path (tap→first pixel / tap→interactive targets)
Energy Issues
Battery drain, high energy → See skills/energy.md
- Power Profiler workflow
- Subsystem diagnosis (CPU/GPU/Network/Location/Display)
- Anti-pattern fixes
- Background execution optimization
Symptom-based diagnosis → See skills/energy-diag.md
- "App at top of Battery Settings"
- "Device gets hot"
- "Background battery drain"
- Time-cost analysis for each path
API reference with code → See skills/energy-ref.md
- Complete WWDC code examples
- Timer, network, location efficiency
- BGContinuedProcessingTask (iOS 26)
- MetricKit setup
Energy scan → Launch energy-auditor agent or /axiom:audit energy (8 anti-patterns: timer abuse, polling, continuous location, animation leaks, background mode misuse, network inefficiency, GPU waste, disk I/O)
Timer Safety
Timer crash patterns (DispatchSourceTimer) → See axiom-integration (skills/timer-patterns.md)
- 4 crash scenarios causing EXC_BAD_INSTRUCTION
- RunLoop mode gotcha (Timer stops during scroll)
- SafeDispatchTimer wrapper
- Timer vs DispatchSourceTimer decision
Timer API reference → See axiom-integration (skills/timer-patterns-ref.md)
- Timer, DispatchSourceTimer, Combine, AsyncTimerSequence APIs
- Lifecycle diagrams
- Platform availability
Swift Performance
Swift performance optimization → See skills/swift-performance.md
- Value vs reference types, copy-on-write
- ARC overhead, generic specialization
- Collection performance
Swift performance scan → Launch swift-performance-analyzer agent or /axiom:audit swift-performance (unnecessary copies, ARC overhead, unspecialized generics, collection inefficiencies, actor isolation costs, memory layout)
Modern Swift idioms → See axiom-swift (skills/swift-modern.md)
- Outdated API patterns (Date(), CGFloat, DateFormatter)
- Foundation modernization (URL.documentsDirectory, FormatStyle)
- Claude-specific hallucination corrections
MetricKit Integration
MetricKit API reference → See skills/metrickit-ref.md
- New Swift-first API (
OS27): MetricManager AsyncSequence streams, typed MetricResult metrics (incl. Metal frame rate, storage), typed diagnostics with termination categories, launch-task tracking - Per-state metrics (StateReporting framework,
OS27): split hitch/hang/memory metrics by tab, mode, or experiment - Crash reporter extensions (CrashReportExtension framework,
OS27): process crashes at crash time with in-extension symbolication (Part 10) - MXMetricPayload / MXDiagnosticPayload parsing (legacy, iOS 13–26)
- Field performance data collection
- Integration with crash reporting
Runtime Console Capture
Capture simulator console output → /axiom:console
- Capture print(), os_log(), Logger output from simulator
- Structured JSON with level, subsystem, category
- Bounded collection with
--timeoutand--max-lines - Filter by subsystem or regex
Runtime State Inspection
LLDB interactive debugging → See axiom-build (skills/lldb.md)
- Set breakpoints, inspect variables at runtime
- Crash reproduction from crash logs
- Thread state analysis for hangs
- Swift value inspection (po vs v)
LLDB command reference → See axiom-build (skills/lldb-ref.md)
- Complete command syntax
- Breakpoint recipes
- Expression evaluation patterns
Decision Tree
1. Memory climbing + UI stutter/jank? → memory-debugging FIRST (memory pressure causes GC pauses that drop frames), then performance-profiling if memory is fixed but stutter remains 2. Memory leak (Swift)? → memory-debugging 3. Memory leak (Objective-C blocks)? → objc-block-retain-cycles 4. App hang/freeze — is UI completely unresponsive (can't tap, no feedback)?
- YES → hang-diagnostics (busy vs blocked diagnosis)
- NO, just slow → performance-profiling (Time Profiler)
- First launch only? → Also check for synchronous I/O or lazy initialization in hang-diagnostics
- Multiple grouped hang reports from Sentry/ASC (corpus, not single file)? →
axiom-shipping (skills/production-triage.md)+triage-analyzer
5. Slowdown when multiple async operations complete at once? → Cross-route to axiom-concurrency (callback contention, not profiling) 6. Slow app launch / slow first frame / launch regression / slow after push tap? → app-launch 7. Battery drain (know the symptom)? → energy-diag 8. Battery drain (need API reference)? → energy-ref 9. Battery drain (general)? → energy 10. MetricKit setup/parsing? → metrickit-ref 10a. Metrics split by app state (per-tab hitch rate, experiment arms) or StateReporting? → metrickit-ref (Part 1) 10b. Profiling agentic/LLM features (Foundation Models instrument, token metrics)? → performance-profiling, then axiom-ai 10c. Building a crash reporter extension (process crashes at crash time)? → metrickit-ref (Part 10) 11. Profile with GUI (Instruments)? → performance-profiling 12. Profile with CLI (xctrace)? → xctrace-ref 13. Run automated profile now? → performance-profiler agent 14. General slow/lag? → performance-profiling 14a. Slow GRDB/SQLite queries (EXPLAIN QUERY PLAN, index design, cursors)? → See axiom-data (skills/grdb-performance.md) 15. Want proactive memory leak scan? → memory-auditor (Agent) 16. Want energy anti-pattern scan? → energy-auditor (Agent) 17. Want Swift performance audit (ARC, generics, collections)? → swift-performance-analyzer (Agent) 18. Need to inspect variable/thread state at runtime? → See axiom-build (skills/lldb.md) 19. Need exact LLDB command syntax? → See axiom-build (skills/lldb-ref.md) 20. Timer stops during scrolling? → timer-patterns (RunLoop mode) 21. EXC_BAD_INSTRUCTION crash with DispatchSourceTimer? → timer-patterns (4 crash patterns) 22. Choosing between Timer, DispatchSourceTimer, Combine timer, async timer? → timer-patterns 23. Need timer API syntax/lifecycle? → timer-patterns-ref 24. Code review for outdated Swift patterns? → swift-modern 25. Claude generating legacy APIs (DateFormatter, CGFloat, DispatchQueue)? → swift-modern 26. Need to see runtime console output before profiling? → axiom-tools (skills/xclog-ref.md) or /axiom:console 27. Have an .ips, MetricKit, or legacy .crash text file to symbolicate/triage? → axiom-tools (skills/xcsym-ref.md) or /axiom:analyze-crash
Anti-Rationalization
| Thought | Reality |
|---|---|
| "I know it's a memory leak, let me find it" | Memory leaks have 6 patterns. memory-debugging diagnoses the right one in 15 min vs 2 hours. |
| "I'll just run Time Profiler" | Wrong Instruments template wastes time. performance-profiling selects the right tool first. |
| "Battery drain is probably the network layer" | Energy issues span 8 subsystems. energy skill diagnoses the actual cause. |
| "App feels slow, I'll optimize later" | Performance issues compound. Profiling now saves exponentially more time later. |
| "It's just a UI freeze, probably a slow API call" | Freezes have busy vs blocked causes. hang-diagnostics has a decision tree for both. |
| "Memory is climbing AND scrolling stutters — two separate bugs" | Memory pressure causes GC pauses that drop frames. Fix the leak first, then re-check scroll performance. |
| "It only freezes on first launch, must be loading something" | First-launch hangs have 3 patterns: synchronous I/O, lazy initialization, main thread contention. hang-diagnostics diagnoses which. |
| "Launch feels slow — I'll trim some startup code" | Launch has 3 phases (pre-main / main→first frame / extended) and a watchdog. app-launch tells you which phase to profile, with measurement hygiene so the number means something. |
| "Launch is fine, it's fast on my phone" | Measure on your oldest supported device with a Release build. app-launch has the full hygiene checklist — newest-device numbers hide the regression. |
| "Resume from the app switcher is slow too" | Resume isn't a launch — never measure it as one. app-launch distinguishes cold/warm/hot/notification and how to reproduce each. |
| "UI locks up when network requests finish — that's slow" | Multiple callbacks completing at once = main thread contention = concurrency issue. Cross-route to axiom-concurrency. |
| "I'll just add print statements to debug this" | Print-debug cycles cost 3-5 min each (build + run + reproduce). An LLDB breakpoint costs 30 seconds. axiom-build (skills/lldb.md) has the commands. |
| "I can't see what the app is logging" | xclog captures print() + os_log from the simulator with structured JSON. /axiom:console. |
| "I'll hand-parse this .ips JSON to see the top frame" | xcsym parses, discovers dSYMs, symbolicates, and categorizes in one call — structured JSON with pattern_tag. /axiom:analyze-crash. |
| "I'll just use Timer.scheduledTimer, it's simpler" | Timer stops during scrolling (.default mode), retains its target (leak). timer-patterns has the decision tree. |
| "DispatchSourceTimer crashed but it's intermittent, let's ship" | DispatchSourceTimer has 4 crash patterns that are ALL deterministic. timer-patterns diagnoses which one. |
| "Claude already knows modern Swift" | Claude defaults to pre-5.5 patterns (Date(), CGFloat, filter().count). swift-modern has the correction table. |
| "MetricKit is that old MX delegate API" | The 27 cycle rebuilt it: MetricManager AsyncSequence streams, typed metrics, per-state aggregation. metrickit-ref Part 1 has the new surface. |
| "My field metrics are one blended average, can't tell which screen is slow" | StateReporting splits every metric by app state you define (per-tab, per-experiment). metrickit-ref Part 1. |
| "No single function is hot, so the profile is useless" | Scattered overhead (dynamic dispatch, retain/release, existentials) hides in flame graphs. Top Functions mode merges it. performance-profiling. |
Critical Patterns
Memory Debugging (memory-debugging):
- 6 leak patterns: timers, observers, closures, delegates, view callbacks, PhotoKit
- Instruments workflows
- Leak vs caching distinction
Performance Profiling (performance-profiling):
- Time Profiler for CPU bottlenecks
- Allocations for memory growth
- Core Data SQL logging for N+1 queries
- Self Time vs Total Time
Energy Optimization (energy):
- Power Profiler subsystem diagnosis
- 8 anti-patterns: timers, polling, location, animations, background, network, GPU, disk
- Audit checklists by subsystem
- Pressure scenarios for deadline resistance
Example Invocations
User: "My app's memory usage keeps growing" → See skills/memory-debugging.md
User: "I have a memory leak but deinit isn't being called" → See skills/memory-debugging.md
User: "My app feels slow, where do I start?" → See skills/performance-profiling.md
User: "My Objective-C block callback is leaking" → See skills/objc-block-retain-cycles.md
User: "My app drains battery quickly" → See skills/energy.md
User: "Users say the device gets hot when using my app" → See skills/energy-diag.md
User: "What's the best way to implement location tracking efficiently?" → See skills/energy-ref.md
User: "Profile my app's CPU usage" → Use: performance-profiler agent (or /axiom:profile)
User: "How do I run xctrace from the command line?" → See skills/xctrace-ref.md
User: "I need headless profiling for CI/CD" → See skills/xctrace-ref.md
User: "My app hangs sometimes" → See skills/hang-diagnostics.md
User: "The UI freezes and becomes unresponsive" → See skills/hang-diagnostics.md
User: "Main thread is blocked, how do I diagnose?" → See skills/hang-diagnostics.md
User: "Triage my Sentry hangs" / "Which ANR reports are real blocks?" → See axiom-shipping (skills/production-triage.md) + triage-analyzer agent (or /axiom:triage sentry)
User: "My app takes 3 seconds to launch" → See skills/app-launch.md
User: "Xcode Organizer says my launch time regressed" → See skills/app-launch.md
User: "How do I reduce pre-main / dyld time?" → See skills/app-launch.md
User: "App is slow to come up after tapping a push notification" → See skills/app-launch.md
User: "How do I write a launch performance test?" → See skills/app-launch.md
User: "How do I set up MetricKit?" → See skills/metrickit-ref.md
User: "How do I parse MXMetricPayload?" → See skills/metrickit-ref.md
User: "How do I use the new MetricManager / migrate off MXMetricManager?" → See skills/metrickit-ref.md (Part 1)
User: "Can I get hitch metrics per tab or per experiment?" → See skills/metrickit-ref.md (Part 1, StateReporting)
User: "How do I profile my Foundation Models / agentic feature?" → See skills/performance-profiling.md (Foundation Models instrument), then axiom-ai
User: "How do I compare two Instruments runs to verify a fix?" → See skills/performance-profiling.md (Run Comparisons) or skills/trace-comparison.md (CLI/CI)
User: "Scan my code for memory leaks" → Invoke: memory-auditor agent
User: "Check my app for battery drain issues" → Invoke: energy-auditor agent
User: "Audit my Swift code for performance anti-patterns" → Invoke: swift-performance-analyzer agent
User: "How do I inspect this variable in the debugger?" → Invoke: See axiom-build (skills/lldb.md)
User: "What's the LLDB command for conditional breakpoints?" → Invoke: See axiom-build (skills/lldb-ref.md)
User: "I need to reproduce this crash in the debugger" → Invoke: See axiom-build (skills/lldb.md)
User: "My list scrolls slowly and memory keeps growing" → See skills/memory-debugging.md first, then skills/performance-profiling.md if stutter remains
User: "App freezes for a few seconds on first launch then works fine" → See skills/hang-diagnostics.md
User: "UI locks up when multiple API calls return at the same time" → Cross-route: /skill axiom-concurrency (callback contention)
User: "My timer stops when the user scrolls" → Read: axiom-integration (skills/timer-patterns.md)
User: "EXC_BAD_INSTRUCTION crash in my timer code" → Read: axiom-integration (skills/timer-patterns.md)
User: "Should I use Timer or DispatchSourceTimer?" → Read: axiom-integration (skills/timer-patterns.md)
User: "How do I create an AsyncTimerSequence?" → Read: axiom-integration (skills/timer-patterns-ref.md)
User: "Review my Swift code for outdated patterns" → Invoke: See axiom-swift (skills/swift-modern.md)
User: "Is there a more modern way to do this?" → Invoke: See axiom-swift (skills/swift-modern.md)
User: "What is the app logging? I need to see console output" → Invoke: /axiom:console
User: "Capture the simulator logs while I reproduce this bug" → Invoke: /axiom:console
App Launch Performance
Diagnose and fix slow app launch — from the moment the user taps the icon to the moment the first frame is interactive. The target Apple sets is first frame within ~400 ms, app interactive by the time the launch animation finishes. iOS runs a watchdog that terminates apps that overrun the launch budget.
This skill owns the launch-specific workflow. It cross-links — does not duplicate — Instruments/signpost mechanics (performance-profiling, xctrace-ref), MXAppLaunchMetric field data (metrickit-ref), and main-thread analysis (hang-diagnostics).
Red Flags — Check This Skill When
| Symptom | This skill applies |
|---|---|
| App takes >1 s (new device) or >2 s (old device) to show its first screen | Yes |
| Launch is fine on your phone, slow on users' older phones | Yes — measure on the oldest supported device |
| First screen appears but is frozen for a moment before it responds | Yes — Phase 3 / extended launch |
| Xcode Organizer "Launches" pane flags a regression | Yes |
| App is slow to come up after tapping a push notification | Yes — notification-launch path |
| App is slow only when returning from the background (app switcher) | No — that's a resume, not a launch. Don't measure it as one. |
| App is responsive but generally sluggish during use | No → performance-profiling |
| UI is completely frozen mid-session | No → hang-diagnostics |
Launch vs Resume — Get the Vocabulary Right
| Type | When | Cost | Reproduce |
|---|---|---|---|
| Cold launch | After reboot, or after the system evicted the app from memory | Highest, most variable | Restart device, wait ~30 s for boot work to settle, then launch |
| Warm launch | App relaunched soon after being force-quit; frameworks still cached in memory | Lower, more consistent — Apple recommends measuring this | Force-quit (swipe up in app switcher), wait ~5 s, launch |
| Hot launch / resume | User re-enters from app switcher or Home Screen; process still alive | Near-instant | Background the app, immediately return — this is not a launch; never measure it as one |
| Notification launch | User taps a push notification; a launch carrying a deep-link/action payload | Cold or warm + payload resolution | Background, send a push (xcrun simctl push or server), tap it |
The Launch Phase Model
digraph launch {
rankdir=LR;
"icon tap" [shape=ellipse];
"Phase 1\npre-main" [shape=box];
"Phase 2\nmain → first frame" [shape=box];
"Phase 3\nfirst frame → interactive" [shape=box];
"icon tap" -> "Phase 1\npre-main" -> "Phase 2\nmain → first frame" -> "Phase 3\nfirst frame → interactive";
}In Instruments these map to the App Life Cycle timeline: process initialization → UIKit initialization → UIKit initial scene rendering → initial frame rendering, plus the app-owned "extended" tail.
Phase 1 — Pre-main (before your main() runs)
The dynamic loader (dyld) maps the executable, loads every linked framework/dylib, and resolves symbols. Then the runtime runs static initializers. Roughly 100 ms of fixed system work plus whatever your dependencies add.
What costs time here:
- Number of dynamically-linked frameworks. Each one adds dyld work. Built-in system frameworks (CoreFoundation, etc.) are nearly free (shared memory across processes); third-party embedded frameworks are not.
- Static initializers that run before `main()`: C++ static constructors, Objective-C
+loadmethods,__attribute__((constructor))functions, and entries in__DATA,__mod_init_func. - Forced eager evaluation in Swift. Swift global
let/varand stored type properties are computed lazily on first access — they do not run pre-main. Pre-main Swift cost shows up only via Obj-C-interop+load, C/C++ constructors, or code you force to run eagerly. dlopen/NSBundle.loadat launch — forfeits the dyld launch-closure win.
Measure it with the `dyld Activity` instrument (static-initializer timings) or the App Launch template's pre-main lanes.
Phase 2 — main → first frame
System creates UIApplication and your delegate, then your code runs:
- UIKit (no scenes):
application(_:willFinishLaunchingWithOptions:)→application(_:didFinishLaunchingWithOptions:)→ create root view controllers here. - UIKit (UIScene):
willFinish/didFinishLaunchingstill fire, but create root view controllers in `scene(_:willConnectTo:options:)`, not indidFinishLaunching. Doing both is a common bug. - SwiftUI:
App.init()thenApp.body(Scene/rootView). Heavy work in either blocks the first frame. - Then layout + draw of the first frame's view hierarchy.
The launch cycle does not complete until your delegate methods return. Anything synchronous and slow here — disk I/O, network, decoding, big data loads — is straight-up launch time.
Phase 3 — first frame → interactive (extended launch)
The launch metric stops at the first frame, but the user's experience doesn't. If your first frame has placeholders for async data, the app must already be interactive — and you should measure the extended tail yourself with signposts (and optionally MXMetricManager.extendLaunchMeasurement(forTaskID:) for field data).
Decision Path
digraph decide {
"Slow launch?" [shape=diamond];
"Reproducible & clean?" [shape=diamond];
"Profile: App Launch template" [shape=box];
"Which phase dominates?" [shape=diamond];
"Fix Phase 1\n(dyld / static init)" [shape=box];
"Fix Phase 2\n(main → first frame)" [shape=box];
"Fix Phase 3\n(extended launch)" [shape=box];
"Lock down environment\n(reboot, release build,\nairplane mode, stable data,\noldest device)" [shape=box];
"Slow launch?" -> "Reproducible & clean?";
"Reproducible & clean?" -> "Lock down environment\n(reboot, release build,\nairplane mode, stable data,\noldest device)" [label="no"];
"Lock down environment\n(reboot, release build,\nairplane mode, stable data,\noldest device)" -> "Profile: App Launch template";
"Reproducible & clean?" -> "Profile: App Launch template" [label="yes"];
"Profile: App Launch template" -> "Which phase dominates?";
"Which phase dominates?" -> "Fix Phase 1\n(dyld / static init)" [label="pre-main"];
"Which phase dominates?" -> "Fix Phase 2\n(main → first frame)" [label="didFinishLaunching /\nApp.init / root view"];
"Which phase dominates?" -> "Fix Phase 3\n(extended launch)" [label="first frame → interactive"];
}Triage Without Instruments (deadline mode)
You do not need an Instruments session to start — a code-review pass of the launch path is a legitimate first move, and there's a zero-setup pre-main breakdown:
- `DYLD_PRINT_STATISTICS=1` (Xcode → Edit Scheme → Run → Arguments → Environment Variables) prints Phase-1 timings to the console at launch — total pre-main time, dylib loading, rebase/bind, ObjC setup, initializers — with no Instruments needed. If pre-main is small (~100–200 ms), the problem is your code (Phase 2/3) — go to the next bullet. If it's large, the cause is framework count and/or
+loadwork — that's usually not a safe same-night fix (file it for the next release) so pivot to Phase 2 anyway for the quick win. - Read the launch path directly:
application(_:didFinishLaunchingWithOptions:)/scene(_:willConnectTo:options:)/App.init()/App.body/ rootviewDidLoad/ firstView.body. Walk the "Fixes by Phase → Phase 2" list below as a checklist — analytics/SDK init, synchronous network,SELECT *at launch,ModelContainer/@Querysetup, heavy view hierarchy, priority inversions. - Bisect on a real device: comment out SDK initializers one at a time, watch
DYLD_PRINT_STATISTICS+ the launch console log. - Verify the win with the
XCTApplicationLaunchMetrictest (below), ideally on a real device — minutes, not an Instruments session.
Do the full Instruments + hygiene pass when you have time; this is the under-deadline path, not a replacement.
Measurement Hygiene (do this before profiling)
Field devices are noisy; an unstable baseline tells you nothing. Before you measure:
- Reboot the device and wait a few minutes for boot-time work to settle.
- Use a Release build (Profile scheme) — debug overhead and missing optimizations distort everything.
- Cut network variance — airplane mode, or mock network dependencies in code.
- Stabilize iCloud — use an unchanging account/data, or sign out.
- Use fixed mock data sets — ideally one small and one large; load only what the first screen needs.
- Pick a device set and stick to it — include your oldest supported device; performance characteristics differ wildly from the newest.
- Measure warm launches for consistency; measure cold launches separately when that's the case you care about.
- Profiling ≠ measuring. Instruments adds overhead (a 6 ms phase can show 149 ms under the profiler). Profile to find the work; use
XCTApplicationLaunchMetricto measure the real number.
Tools
| Tool | Use it for | Cross-link |
|---|---|---|
| Instruments App Launch template | The triage workhorse — time profile + thread-state trace, broken into launch phases. Configure target, hit record, read which phase dominates and which thread is blocked. "Extended Launch" captures the full flow. | performance-profiling |
| Instruments dyld Activity | Static-initializer timings, dyld closure cost | performance-profiling |
xctrace record --template 'App Launch' --launch -- <app> | Headless / CI launch profiling | xctrace-ref |
| Xcode Organizer — Launch Time pane | Field ms (50th/90th pct) by device & OS, version-over-version | — |
| Xcode Organizer — Launches pane | Longest functions during startup, with stack traces and a 14-day trend | — |
XCTApplicationLaunchMetric (XCTest) | Regression gate in CI — see snippet below | axiom-testing |
MXAppLaunchMetric (MetricKit) | Field histograms: histogrammedTimeToFirstDraw, histogrammedOptimizedTimeToFirstDraw (prewarmed), histogrammedApplicationResumeTime, histogrammedExtendedLaunch; MXDiagnosticPayload.appLaunchDiagnostics for slow-launch stacks | metrickit-ref |
MetricKit 27 launch family OS27 | Typed field metrics .timeToFirstDraw / .optimizedTimeToFirstDraw / .applicationResumeTime / .extendedLaunch, the .appLaunch diagnostic with launch stacks, and MetricManager.trackLaunchTask(id:) to instrument named extended-launch work (@MainActor, sync/async overloads) | metrickit-ref Part 1 |
| App Store Connect — "App Extended Launch Usage" report | Field extended-launch data (iOS 17.4+, daily) | — |
| Custom Points of Interest signpost | Marking your own "app is interactive" boundary | performance-profiling |
Custom "app is interactive" signpost
When your real interactive point is after the first frame (async data, document open), bracket it with a signpost so it shows up in the Points of Interest instrument.
Swift:
import OSLog
let launchLog = OSSignposter(subsystem: "com.example.app", category: .pointsOfInterest)
// at the start of launch-critical setup
let state = launchLog.beginInterval("Launch → interactive")
// ... later, once the screen is genuinely usable
launchLog.endInterval("Launch → interactive", state)Objective-C uses os_signpost(OS_SIGNPOST_INTERVAL_BEGIN/END, log, "Launch → interactive") with an OSLog created with the OS_LOG_CATEGORY_POINTS_OF_INTEREST category. In SwiftUI, begin in App.init() (or a root-view task) and end from the first view's .onAppear once data has loaded.
Regression test (XCTest)
func testLaunchPerformance() {
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}One throwaway launch, then (by default) five measured iterations with statistics. XCTApplicationLaunchMetric(waitUntilResponsive:) extends the window to first-responsive. (XCTApplicationLaunchMetric supersedes the old XCTOSSignpostMetric.applicationLaunch.) For field monitoring, wrap your extended-launch tasks in MXMetricManager.shared.extendLaunchMeasurement(forTaskID:) / finishExtendedLaunchMeasurement(forTaskID:).
Fixes by Phase
Phase 1 — Pre-main
- Reduce dynamic framework count. Consolidate small frameworks; statically link what you can. Use mergeable libraries (Xcode 15+) to keep many-small-modules ergonomics in debug while shipping a merged binary with static-like launch cost in release.
- Move `+load` work to `+initialize` (lazy, first message) or to an explicit init API you call after launch.
- Don't force Swift globals/type properties eager. Let them stay lazy. If a framework you own does heavy module-load work, expose an init-early API instead.
- No `dlopen`/`NSBundle.load` on the launch path.
Phase 2 — main → first frame
- Defer everything not needed for the first frame out of
didFinishLaunchingWithOptions/scene(_:willConnectTo:)/App.init/App.body/ rootviewDidLoad/ firstView.body: analytics SDK init, network sync (→ background queue orBGTask), non-view services (persistence, location) → init on first use. - Load only first-screen data. A table view shows ~10–20 cells; load those synchronously, fetch the rest in the background and update when done. Don't
SELECT *at launch. - Watch SwiftData/Core Data stack cost. Building a
ModelContainer(or a Core Data stack) inApp.init()and a@Query/FetchRequeston the root view both run on the launch path — keep store setup off the critical path where you can (migrations especially), scope@Querypredicates/fetchLimitto what the first screen shows, and load the rest after first frame. - Get GCD priorities right. A user-interactive main thread waiting on a background-QoS queue is a priority inversion — it stalls launch. Use the correct concurrency primitive so priority propagates; offload heavy main-actor work (cross-link
axiom-concurrency). - Simplify the first view hierarchy — flatten views, fewer Auto Layout constraints, lazily load views not visible at launch, avoid unnecessary custom
draw(_:).
Phase 3 — first frame → interactive
- Placeholders + async load — render a usable frame immediately, fill in data asynchronously, keep the UI responsive throughout.
- Signpost the extended phase so you can see where the tail goes.
- No speculative pre-warming. Pre-building screens the user hasn't navigated to (e.g. a detail VC inside
cellForRowAt) is a classic launch regression — measure before assuming a "pre-warm" helps.
Push-Notification Launch
A notification tap is a launch entry path that arrives with a deep-link/action payload. Targets: tap → first pixel ≈ 200 ms, tap → interactive content ≈ 1 s.
- Don't do heavy work in `UNUserNotificationCenterDelegate` handlers (
didReceive) — they run on the launch path. Resolve the deep link to a route, then render; defer network/database fetches until after the first pixel. - Cache deep-link routing data so resolving a payload to a destination is cheap.
- Background-app-refresh pre-warming is opportunistic, not guaranteed — design the tap path to be fast from a cold state; treat any pre-warmed state as a bonus.
- Profile it on a real device with the App Launch template ("Extended Launch") plus a custom signpost around the notification-handling flow; simulator timing isn't representative. Send a test push with
xcrun simctl push. - Handler-side detail (categories, actions, content extensions) → cross-link
axiom-integration(push notifications).
Common Launch Mistakes
| Mistake | Why it bites | Fix |
|---|---|---|
| Measuring in the Simulator / a Debug build | Numbers are meaningless — different perf characteristics, debug overhead | Release build, real device, oldest supported model |
| Measuring a resume and calling it a launch | Resumes are ~free; you'll think launch is fine when it isn't | Force-quit (or reboot) before each measurement |
Synchronous I/O or network in didFinishLaunching / App.init | The launch cycle blocks until those return | Background queue; load on first use |
| Loading all data at launch | Scales with the user's data, not the screen | Load the first screen's data only; lazy-load the rest |
Heavy +load / many embedded dynamic frameworks | Runs before main(), before you can do anything | +initialize/runtime init; consolidate/merge frameworks |
| Speculative pre-warming of unseen screens | Adds guaranteed cost for a maybe-benefit | Measure first; usually just delete it |
| Big allocations during launch | Raises memory pressure → watchdog-termination risk | Allocate lazily; stream large data |
| "Profiled it, it's 400 ms" | Profiler overhead inflates numbers | Profile to find work; XCTApplicationLaunchMetric to measure |
Quick Reference — Commands
# Headless launch profile
xctrace record --template 'App Launch' --launch -- /path/to/Your.app
# Clean-boot a simulator for consistent dev-time measurement (real device for real numbers)
xcrun simctl shutdown all && xcrun simctl erase <device-udid> && xcrun simctl boot <device-udid>
# Send a test push to a booted simulator (notification-launch testing)
xcrun simctl push <device-udid> com.example.app payload.apns
# payload.apns: {"aps":{"alert":"Test","sound":"default"}, "deep_link":"app://detail/42"}
# Run the launch regression test
xcodebuild test -scheme MyApp -destination 'platform=iOS,name=...' -only-testing:MyAppPerfTests/LaunchPerfTestsIn Instruments: File → New → choose App Launch template → select your app as the target → Record. Triple-click a phase band to see its stack traces; gray thread = blocked, red = runnable-but-starved, orange = preempted, blue = running.
Resources
WWDC: 2019-423, 2019-411, 2021-10181, 2022-110362, 2023-10268, 2024-10181
Docs: /xcode/reducing-your-app-s-launch-time, /metrickit/mxapplaunchmetric, /xctest/xctapplicationlaunchmetric, /uikit/about-the-app-launch-sequence
Skills: performance-profiling, xctrace-ref, metrickit-ref, hang-diagnostics, axiom-concurrency, axiom-integration
Energy Diagnostics
Symptom-based troubleshooting for energy issues. Start with your symptom, follow the decision tree, get the fix.
Related skills: axiom-performance (skills/energy.md) (patterns, checklists), axiom-performance (skills/energy-ref.md) (API reference)
---
Measurement Red Flags — Read Before Profiling
These two mistakes invalidate an entire profiling session. Catch them first.
| Red flag | Why it ruins the trace | Fix |
|---|---|---|
| Profiling over a USB cable | System power metrics read ~0 when the device is charging — the trace looks clean while the bug is still there | Profile over wireless debugging (Window → Devices → Connect via network), unplugged |
| Can't reproduce drain at your desk | Real drain happens during the commute/in-pocket, not at a stationary desk on WiFi | Use on-device Performance Trace (below) — you cannot find this with a cabled Mac trace |
On-device Performance Trace for unreproducible drain
When the drain only shows up in real-world use (commute, pocket, cellular), capture it on the device itself, then bring it back to the Mac:
1. Settings → Developer → Performance Trace → Enable, set mode to Power Profiler 2. Add the Performance Trace control to Control Center (Add a Control → Performance Trace) 3. Start the trace from Control Center, use the app normally for the real-world scenario (captures up to ~10 hours) 4. Stop, then Settings → Developer → Performance Trace → share the trace to your Mac and open in Instruments
---
Symptom 1: App at Top of Battery Settings
Users or you notice your app consuming significant battery.
Diagnosis Decision Tree
App at top of Battery Settings?
│
├─ Step 1: Run Power Profiler (15 min)
│ ├─ CPU Power Impact high?
│ │ ├─ Continuous? → Timer leak or polling loop
│ │ │ └─ Fix: Check timers, add tolerance, convert to push
│ │ └─ Spikes during actions? → Eager loading or repeated parsing
│ │ └─ Fix: Use LazyVStack, cache parsed data
│ │
│ ├─ Network Power Impact high?
│ │ ├─ Many small requests? → Batching issue
│ │ │ └─ Fix: Batch requests, use discretionary URLSession
│ │ └─ Regular intervals? → Polling pattern
│ │ └─ Fix: Convert to push notifications
│ │
│ ├─ GPU Power Impact high?
│ │ ├─ Animations? → Running when not visible
│ │ │ └─ Fix: Stop in viewWillDisappear
│ │ └─ Blur effects? → Over dynamic content
│ │ └─ Fix: Remove or use static backgrounds
│ │
│ └─ Display Power Impact high?
│ └─ Light backgrounds on OLED?
│ └─ Fix: Implement Dark Mode (up to 70% savings)
│
└─ Step 2: Check background section in Battery Settings
├─ High background time?
│ ├─ Location icon visible? → Continuous location
│ │ └─ Fix: Switch to significant-change monitoring
│ ├─ Audio active? → Session not deactivated
│ │ └─ Fix: Deactivate audio session when not playing
│ └─ BGTasks running long? → Not completing promptly
│ └─ Fix: Call setTaskCompleted sooner
│
└─ Background time appropriate?
└─ Issue is in foreground usage → Focus on CPU/GPU fixes aboveTime-Cost Analysis
| Approach | Time | Accuracy |
|---|---|---|
| Run Power Profiler, identify subsystem | 15-20 min | High |
| Guess and optimize random areas | 4+ hours | Low |
| Read all code looking for issues | 2+ hours | Medium |
Recommendation: Always use Power Profiler first. It costs 15 minutes but guarantees you optimize the right subsystem.
---
Symptom 2: Device Gets Hot
Device temperature increases noticeably during app use.
Diagnosis Decision Tree
Device gets hot during app use?
│
├─ Hot during specific action?
│ │
│ ├─ During video/camera use?
│ │ ├─ Video encoding? → Expected, but check efficiency
│ │ │ └─ Fix: Use hardware encoding, reduce resolution if possible
│ │ └─ Camera active unnecessarily? → Not releasing session
│ │ └─ Fix: Call stopRunning() when done
│ │
│ ├─ During scroll/animation?
│ │ ├─ GPU-intensive effects? → Blur, shadows, many layers
│ │ │ └─ Fix: Reduce effects, cache rendered content
│ │ └─ High frame rate? → Unnecessary 120fps
│ │ └─ Fix: Use CADisplayLink preferredFrameRateRange
│ │
│ └─ During data processing?
│ ├─ JSON parsing? → Repeated or large payloads
│ │ └─ Fix: Cache parsed results, paginate
│ └─ Image processing? → Synchronous on main thread
│ └─ Fix: Move to background, cache results
│
├─ Hot during normal use (no specific action)?
│ │
│ ├─ Run Power Profiler to identify:
│ │ ├─ CPU high continuously → Timer, polling, tight loop
│ │ ├─ GPU high continuously → Animation leak
│ │ └─ Network high continuously → Polling pattern
│ │
│ └─ Check for infinite loops or runaway recursion
│ └─ Use Time Profiler in Instruments
│
└─ Hot only in background?
├─ Location updates continuous? → High accuracy or no stop
│ └─ Fix: Reduce accuracy, stop when done
├─ Audio session active? → Hardware kept powered
│ └─ Fix: Deactivate when not playing
└─ BGTask running too long? → System may throttle
└─ Fix: Complete tasks faster, use requiresExternalPowerTime-Cost Analysis
| Approach | Time | Outcome |
|---|---|---|
| Power Profiler + Time Profiler | 20-30 min | Identifies exact cause |
| Check code for obvious issues | 1-2 hours | May miss non-obvious causes |
| Wait for user complaints | N/A | Reputation damage |
---
Symptom 3: Background Battery Drain
App drains battery even when user isn't actively using it.
Diagnosis Decision Tree
High background battery usage?
│
├─ Step 1: Check Info.plist background modes
│ │
│ ├─ "location" enabled?
│ │ ├─ Actually need background location?
│ │ │ ├─ YES → Use significant-change, lowest accuracy
│ │ │ └─ NO → Remove background mode, use when-in-use only
│ │ └─ Check: Is stopUpdatingLocation called?
│ │
│ ├─ "audio" enabled?
│ │ ├─ Audio playing? → Expected
│ │ ├─ Audio NOT playing? → Session still active
│ │ │ └─ Fix: Deactivate session, use autoShutdownEnabled
│ │ └─ Playing silent audio? → Anti-pattern for keeping app alive
│ │ └─ Fix: Use proper background API (BGTask)
│ │
│ ├─ "fetch" enabled?
│ │ └─ Check: Is earliestBeginDate reasonable? (not too frequent)
│ │
│ └─ "remote-notification" enabled?
│ └─ Expected for push updates, check didReceiveRemoteNotification efficiency
│
├─ Step 2: Check BGTaskScheduler usage
│ │
│ ├─ BGAppRefreshTask scheduled too frequently?
│ │ └─ Fix: Increase earliestBeginDate interval
│ │
│ ├─ BGProcessingTask not using requiresExternalPower?
│ │ └─ Fix: Add requiresExternalPower = true for non-urgent work
│ │
│ └─ Tasks not completing? (setTaskCompleted not called)
│ └─ Fix: Always call setTaskCompleted, implement expirationHandler
│
└─ Step 3: Check beginBackgroundTask usage
│
├─ endBackgroundTask called promptly?
│ └─ Fix: Call immediately after work completes, not at expiration
│
└─ Multiple overlapping background tasks?
└─ Fix: Track task IDs, ensure each is endedCommon Background Drain Patterns
| Pattern | Power Profiler Signature | Fix |
|---|---|---|
| Continuous location | CPU lane + location icon | significant-change |
| Audio session leak | CPU lane steady | setActive(false) |
| Timer not invalidated | CPU spikes at intervals | invalidate in background |
| Polling from background | Network lane at intervals | Push notifications |
| BGTask too long | CPU sustained | Faster completion |
Time-Cost Analysis
| Approach | Time | Outcome |
|---|---|---|
| Check Info.plist + BGTask code | 30 min | Finds common issues |
| On-device Power Profiler trace | 1-2 hours (real usage) | Captures real behavior |
| User-collected trace | Variable | Best for unreproducible issues |
---
Symptom 4: High Energy Only on Cellular
Battery drains faster on cellular than WiFi.
Diagnosis Decision Tree
High battery drain on cellular only?
│
├─ Expected: Cellular radio uses more power than WiFi
│ └─ But: Excessive drain indicates optimization opportunity
│
├─ Check URLSession configuration
│ │
│ ├─ allowsExpensiveNetworkAccess = true (default)?
│ │ └─ Fix: Set to false for non-urgent requests
│ │
│ ├─ isDiscretionary = false (default)?
│ │ └─ Fix: Set to true for background downloads
│ │
│ └─ waitsForConnectivity = false (default)?
│ └─ Fix: Set to true to avoid failed connection retries
│
├─ Check request patterns
│ │
│ ├─ Many small requests? → High connection overhead
│ │ └─ Fix: Batch into fewer larger requests
│ │
│ ├─ Polling? → Radio stays active
│ │ └─ Fix: Push notifications
│ │
│ └─ Large downloads in foreground? → Could wait for WiFi
│ └─ Fix: Use background URLSession with discretionary
│
└─ Check Low Data Mode handling
├─ Respecting allowsConstrainedNetworkAccess?
│ └─ Fix: Set to false for non-essential requests
│
└─ Checking ProcessInfo.processInfo.isLowDataModeEnabled?
└─ Fix: Reduce payload sizes, defer non-essential transfersTime-Cost Analysis
| Approach | Time | Outcome |
|---|---|---|
| Review URLSession configs | 15 min | Quick wins |
| Add discretionary flags | 30 min | Significant savings |
| Convert poll to push | 2-4 hours | Largest impact |
---
Symptom 5: Energy Spike During Specific Action
Noticeable battery drain or heat when performing particular operation.
Diagnosis Decision Tree
Energy spike during specific action?
│
├─ Step 1: Record Power Profiler during action
│ └─ Note which subsystem spikes (CPU/GPU/Network/Display)
│
├─ CPU spike?
│ │
│ ├─ Is it parsing data?
│ │ ├─ Same data parsed repeatedly?
│ │ │ └─ Fix: Cache parsed results (lazy var)
│ │ └─ Large JSON/XML payload?
│ │ └─ Fix: Paginate, stream parse, or use binary format
│ │
│ ├─ Is it creating views?
│ │ ├─ Many views at once?
│ │ │ └─ Fix: Use LazyVStack/LazyHStack
│ │ └─ Complex view hierarchies?
│ │ └─ Fix: Simplify, use drawingGroup()
│ │
│ └─ Is it image processing?
│ ├─ On main thread?
│ │ └─ Fix: Move to background queue
│ └─ No caching?
│ └─ Fix: Cache processed images
│
├─ GPU spike?
│ │
│ ├─ Starting animation?
│ │ └─ Fix: Ensure frame rate appropriate
│ │
│ ├─ Showing blur effect?
│ │ └─ Fix: Use solid color or pre-rendered blur
│ │
│ └─ Complex render? (shadows, masks, many layers)
│ └─ Fix: Simplify, use shouldRasterize, cache
│
├─ Network spike?
│ │
│ ├─ Large download started?
│ │ └─ Fix: Use background URLSession, show progress
│ │
│ ├─ Many parallel requests?
│ │ └─ Fix: Limit concurrency, batch
│ │
│ └─ Retrying failed requests?
│ └─ Fix: Exponential backoff, waitsForConnectivity
│
└─ Display spike?
└─ Unusual unless changing brightness programmatically
└─ Fix: Don't modify brightness, let system controlTime-Cost Analysis
| Approach | Time | Outcome |
|---|---|---|
| Power Profiler during action | 5-10 min | Identifies subsystem |
| Time Profiler for CPU details | 10-15 min | Identifies function |
| Code review without profiling | 1+ hours | May miss actual cause |
---
Quick Diagnostic Checklist
Use this when you need fast answers:
30-Second Check
- [ ] Profiling over USB cable? Power metrics read ~0 — switch to wireless debugging, unplugged
- [ ] Debug build? (Less optimized than release)
- [ ] Low Power Mode on? (May affect measurements)
5-Minute Check (Power Profiler)
- [ ] Which subsystem is dominant? (CPU/GPU/Network/Display)
- [ ] Sustained or spiky?
- [ ] Foreground or background?
15-Minute Investigation
- [ ] If CPU: Run Time Profiler to identify function
- [ ] If Network: Check request frequency and size
- [ ] If GPU: Check animation frame rates
- [ ] If Background: Check Info.plist modes
Common Quick Fixes
| Finding | Quick Fix | Time |
|---|---|---|
| Timer without tolerance | Add .tolerance = 0.1 | 1 min |
| VStack with large ForEach | Change to LazyVStack | 1 min |
| allowsExpensiveNetworkAccess = true | Set to false | 1 min |
| Missing stopUpdatingLocation | Add stop call | 2 min |
| No Dark Mode | Add asset variants | 30 min |
| Audio session always active | Add setActive(false) | 5 min |
| Polling on a timer (e.g. every 5s) | Convert to push — polling uses ~100x more energy than push | 2-4 hours |
| Background push waking the radio | Send apns-priority: 5 so the system batches/coalesces delivery | server-side |
| Off-screen CADisplayLink at high fps | Cap with preferredFrameRateRange (~20% GPU savings) or pause when off-screen | 5 min |
---
Prove the Fix in the Field (MetricKit)
A clean local Power Profiler trace proves nothing about real users. After shipping a fix, confirm it landed with the MetricKit field that measures exactly what you changed. Implement MXMetricManager and read these from MXMetricPayload:
| Fix shipped | Field that proves it | What "fixed" looks like |
|---|---|---|
| Right-sized background location | locationActivityMetrics.cumulativeBackgroundLocationTime | Drops sharply vs the leaking build |
| Polling converted to push | networkTransferMetrics (cellular + WiFi up/down bytes) | Fewer bytes, no steady-interval pattern |
| Best-accuracy GPS reduced | locationActivityMetrics.cumulativeBestAccuracyTime | Time shifts out of the best-accuracy bucket |
Compare the post-fix payload against the pre-fix baseline — without the baseline you can't tell improvement from noise.
---
When to Escalate
Use axiom-performance (skills/energy.md) skill when
- Need full audit checklist
- Want comprehensive patterns with code
- Planning proactive optimization
Use axiom-performance (skills/energy-ref.md) skill when
- Need specific API details
- Want complete code examples
- Implementing from scratch
Use energy-auditor agent when
- Want automated codebase scan
- Looking for anti-patterns at scale
- Pre-release energy audit
Run: /axiom:audit energy
---
Last Updated: 2025-12-26 Platforms: iOS 26+, iPadOS 26+
Energy Optimization Reference
Complete API reference for iOS energy optimization, with code examples from WWDC sessions and Apple documentation.
Related skills: axiom-performance (skills/energy.md) (decision trees, patterns), axiom-performance (skills/energy-diag.md) (troubleshooting)
---
Part 1: Power Profiler Workflow
Recording a Trace with Instruments
Tethered Recording (Connected to Mac)
1. Connect iPhone wirelessly to Xcode
- Xcode → Window → Devices and Simulators
- Enable "Connect via network" for your device
2. Profile your app
- Xcode → Product → Profile (Cmd+I)
- Select Blank template
- Click "+" → Add "Power Profiler"
- Optionally add "CPU Profiler" for correlation
3. Record
- Select your app from target dropdown
- Click Record (red button)
- Use app normally for 2-3 minutes
- Click Stop
4. Analyze
- Expand Power Profiler track
- Examine per-app lanes: CPU, GPU, Display, NetworkImportant: Use wireless debugging. When device is charging via cable, system power usage shows 0.
On-Device Recording (Without Mac)
From WWDC25-226: Capture traces in real-world conditions.
1. Enable Developer Mode
Settings → Privacy & Security → Developer Mode → Enable
2. Enable Performance Trace
Settings → Developer → Performance Trace → Enable
Set tracing mode to "Power Profiler"
Toggle ON your app in the app list
3. Add Control Center shortcut
Control Center → Tap "+" → Add a Control → Performance Trace
4. Record
Swipe down → Tap Performance Trace icon → Start
Use app (can record up to 10 hours)
Tap Performance Trace icon → Stop
5. Share trace
Settings → Developer → Performance Trace
Tap Share button next to trace file
AirDrop to Mac or email to developerInterpreting Power Profiler Metrics
| Lane | Meaning | What High Values Indicate |
|---|---|---|
| System Power | Overall battery drain rate | General energy consumption |
| CPU Power Impact | Processor activity score | Computation, timers, parsing |
| GPU Power Impact | Graphics rendering score | Animations, blur, Metal |
| Display Power Impact | Screen power usage | Brightness, content type |
| Network Power Impact | Radio activity score | Requests, downloads, polling |
Key insight: Values are scores for comparison, not absolute measurements. Compare before/after traces on the same device.
Comparing Before/After (Example from WWDC25-226)
// Before optimization: CPU Power Impact = 21
VStack {
ForEach(videos) { video in
VideoCardView(video: video)
}
}
// After optimization: CPU Power Impact = 4.3
LazyVStack {
ForEach(videos) { video in
VideoCardView(video: video)
}
}---
Part 2: Timer Efficiency APIs
NSTimer with Tolerance
// Basic timer with tolerance
let timer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true
) { [weak self] _ in
self?.updateUI()
}
timer.tolerance = 0.1 // 10% minimum recommended
// Add to run loop (if not using scheduledTimer)
RunLoop.current.add(timer, forMode: .common)
// Always invalidate when done
deinit {
timer.invalidate()
}Combine Timer Publisher
import Combine
class ViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
func startPolling() {
Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
.autoconnect()
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
func stopPolling() {
cancellables.removeAll()
}
}Dispatch Timer Source (Low-Level)
From Energy Efficiency Guide:
let queue = DispatchQueue(label: "com.app.timer")
let timer = DispatchSource.makeTimerSource(queue: queue)
// Set interval with leeway (tolerance)
timer.schedule(
deadline: .now(),
repeating: .seconds(1),
leeway: .milliseconds(100) // 10% tolerance
)
timer.setEventHandler { [weak self] in
self?.performWork()
}
timer.resume()
// Cancel when done
timer.cancel()For DispatchSourceTimer lifecycle safety and crash prevention, see axiom-integration (skills/timer-patterns.md).Event-Driven Alternative to Timers
From Energy Efficiency Guide: Prefer dispatch sources over polling.
// Monitor file changes instead of polling
let fileDescriptor = open(filePath.path, O_EVTONLY)
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileDescriptor,
eventMask: [.write, .delete],
queue: .main
)
source.setEventHandler { [weak self] in
self?.handleFileChange()
}
source.setCancelHandler {
close(fileDescriptor)
}
source.resume()---
Part 3: Network Efficiency APIs
URLSession Configuration
// Standard configuration with energy-conscious settings
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true // Don't fail immediately
config.allowsExpensiveNetworkAccess = false // Prefer WiFi
config.allowsConstrainedNetworkAccess = false // Respect Low Data Mode
let session = URLSession(configuration: config)Discretionary Background Downloads
From WWDC22-10083:
// Background session for non-urgent downloads
let config = URLSessionConfiguration.background(
withIdentifier: "com.app.downloads"
)
config.isDiscretionary = true // System chooses optimal time
config.sessionSendsLaunchEvents = true
// Set timeouts
config.timeoutIntervalForResource = 24 * 60 * 60 // 24 hours
config.timeoutIntervalForRequest = 60
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
// Create download task with scheduling hints
let task = session.downloadTask(with: url)
task.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 60 * 60) // 2 hours from now
task.countOfBytesClientExpectsToSend = 200 // Small request
task.countOfBytesClientExpectsToReceive = 500_000 // 500KB response
task.resume()Background Session Delegate
class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// Move file from temp location
let destination = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)[0].appendingPathComponent("downloaded.data")
try? FileManager.default.moveItem(at: location, to: destination)
}
func urlSessionDidFinishEvents(
forBackgroundURLSession session: URLSession
) {
// Notify app delegate to call completion handler
DispatchQueue.main.async {
if let handler = AppDelegate.shared.backgroundCompletionHandler {
handler()
AppDelegate.shared.backgroundCompletionHandler = nil
}
}
}
}---
Part 4: Location Efficiency APIs
CLLocationManager Configuration
import CoreLocation
class LocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
func configure() {
manager.delegate = self
// Use appropriate accuracy
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
// Reduce update frequency
manager.distanceFilter = 100 // Update every 100 meters
// Allow indicator pause when stationary
manager.pausesLocationUpdatesAutomatically = true
// For background updates (if needed)
manager.allowsBackgroundLocationUpdates = true
manager.showsBackgroundLocationIndicator = true
}
func startTracking() {
manager.requestWhenInUseAuthorization()
manager.startUpdatingLocation()
}
func startSignificantChangeTracking() {
// Much more energy efficient for background
manager.startMonitoringSignificantLocationChanges()
}
func stopTracking() {
manager.stopUpdatingLocation()
manager.stopMonitoringSignificantLocationChanges()
}
}iOS 26+ CLLocationUpdate (Modern Async API)
import CoreLocation
func trackLocation() async throws {
for try await update in CLLocationUpdate.liveUpdates() {
// Check if device became stationary
if update.stationary {
// System pauses updates automatically
// Consider switching to region monitoring
break
}
if let location = update.location {
handleLocation(location)
}
}
}CLMonitor for Significant Changes
import CoreLocation
func setupRegionMonitoring() async {
let monitor = CLMonitor("significant-changes")
// Add condition to monitor
let condition = CLMonitor.CircularGeographicCondition(
center: currentLocation.coordinate,
radius: 500 // 500 meter radius
)
await monitor.add(condition, identifier: "home-region")
// React to events
for try await event in monitor.events {
switch event.state {
case .satisfied:
// Entered region
handleRegionEntry()
case .unsatisfied:
// Exited region
handleRegionExit()
default:
break
}
}
}Location Accuracy Options
| Constant | Accuracy | Battery Impact | Use Case |
|---|---|---|---|
kCLLocationAccuracyBestForNavigation | ~1m | Extreme | Turn-by-turn only |
kCLLocationAccuracyBest | ~10m | Very High | Fitness tracking |
kCLLocationAccuracyNearestTenMeters | ~10m | High | Precise positioning |
kCLLocationAccuracyHundredMeters | ~100m | Medium | Store locators |
kCLLocationAccuracyKilometer | ~1km | Low | Weather, general |
kCLLocationAccuracyThreeKilometers | ~3km | Very Low | Regional content |
---
Part 5: Background Execution APIs
beginBackgroundTask (Short Tasks)
class AppDelegate: UIResponder, UIApplicationDelegate {
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
backgroundTask = application.beginBackgroundTask(withName: "Save State") {
// Expiration handler - clean up
self.endBackgroundTask()
}
// Perform quick work
saveState()
// End immediately when done
endBackgroundTask()
}
private func endBackgroundTask() {
guard backgroundTask != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}
}BGAppRefreshTask
import BackgroundTasks
// Register at app launch
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.refresh",
using: nil
) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true
}
// Schedule refresh
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
try? BGTaskScheduler.shared.submit(request)
}
// Handle refresh
func handleAppRefresh(task: BGAppRefreshTask) {
scheduleAppRefresh() // Schedule next refresh
let fetchTask = Task {
do {
let hasNewData = try await fetchLatestData()
task.setTaskCompleted(success: hasNewData)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
fetchTask.cancel()
}
}BGProcessingTask
import BackgroundTasks
// Register
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.maintenance",
using: nil
) { task in
self.handleMaintenance(task: task as! BGProcessingTask)
}
// Schedule with requirements
func scheduleMaintenance() {
let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = true // Only when charging
try? BGTaskScheduler.shared.submit(request)
}
// Handle
func handleMaintenance(task: BGProcessingTask) {
let operation = MaintenanceOperation()
task.expirationHandler = {
operation.cancel()
}
operation.completionBlock = {
task.setTaskCompleted(success: !operation.isCancelled)
}
OperationQueue.main.addOperation(operation)
}iOS 26+ BGContinuedProcessingTask
From WWDC25-227: Continue user-initiated tasks with system UI.
import BackgroundTasks
// Info.plist: Add identifier to BGTaskSchedulerPermittedIdentifiers
// "com.app.export" or "com.app.exports.*" for wildcards
// Register handler (can be dynamic, not just at launch)
func setupExportHandler() {
// `using:` is the dispatch queue — pass nil for a default background queue
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.export", using: nil) { task in
let continuedTask = task as! BGContinuedProcessingTask
var shouldContinue = true
continuedTask.expirationHandler = {
shouldContinue = false
}
// Report progress
continuedTask.progress.totalUnitCount = 100
continuedTask.progress.completedUnitCount = 0
// Perform work
for i in 0..<100 {
guard shouldContinue else { break }
performExportStep(i)
continuedTask.progress.completedUnitCount = Int64(i + 1)
}
continuedTask.setTaskCompleted(success: shouldContinue)
}
}
// Submit request
func startExport() {
let request = BGContinuedProcessingTaskRequest(
identifier: "com.app.export",
title: "Exporting Photos",
subtitle: "0 of 100 photos"
)
// Submission strategy
request.strategy = .fail // Fail if can't start immediately
// or default: queue if can't start
do {
try BGTaskScheduler.shared.submit(request)
} catch {
// Handle submission failure
showExportNotAvailable()
}
}EMRCA Principles (from WWDC25-227)
Background tasks must be:
| Principle | Meaning | Implementation |
|---|---|---|
| Efficient | Lightweight, purpose-driven | Do one thing well |
| Minimal | Keep work to minimum | Don't expand scope |
| Resilient | Save progress, handle expiration | Checkpoint frequently |
| Courteous | Honor preferences | Check Low Power Mode |
| Adaptive | Work with system | Don't fight constraints |
---
Part 6: Display & GPU Efficiency APIs
Dark Mode Support
// Check current appearance
let isDarkMode = traitCollection.userInterfaceStyle == .dark
// React to appearance changes
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
updateColorsForAppearance()
}
}
// Use dynamic colors
let dynamicColor = UIColor { traitCollection in
switch traitCollection.userInterfaceStyle {
case .dark:
return UIColor.black // OLED: True black = pixels off = 0 power
default:
return UIColor.white
}
}Frame Rate Control with CADisplayLink
From WWDC22-10083:
class AnimationController {
private var displayLink: CADisplayLink?
func startAnimation() {
displayLink = CADisplayLink(target: self, selector: #selector(update))
// Control frame rate
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 10, // Minimum acceptable
maximum: 30, // Maximum needed
preferred: 30 // Ideal rate
)
displayLink?.add(to: .current, forMode: .default)
}
@objc private func update(_ displayLink: CADisplayLink) {
// Update animation
updateAnimationFrame()
}
func stopAnimation() {
displayLink?.invalidate()
displayLink = nil
}
}Stop Animations When Not Visible
class AnimatedViewController: UIViewController {
private var animator: UIViewPropertyAnimator?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
startAnimations()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopAnimations() // Critical for energy
}
private func stopAnimations() {
animator?.stopAnimation(true)
animator = nil
}
}---
Part 7: Disk I/O Efficiency APIs
Batch Writes
// BAD: Multiple small writes
for item in items {
let data = try JSONEncoder().encode(item)
try data.write(to: fileURL) // Writes each item separately
}
// GOOD: Single batched write
let allData = try JSONEncoder().encode(items)
try allData.write(to: fileURL) // One write operationSQLite WAL Mode
import SQLite3
// Enable Write-Ahead Logging
var db: OpaquePointer?
sqlite3_open(dbPath, &db)
var statement: OpaquePointer?
sqlite3_prepare_v2(db, "PRAGMA journal_mode=WAL", -1, &statement, nil)
sqlite3_step(statement)
sqlite3_finalize(statement)XCTStorageMetric for Testing
import XCTest
class DiskWriteTests: XCTestCase {
func testDiskWritePerformance() {
measure(metrics: [XCTStorageMetric()]) {
// Code that writes to disk
saveUserData()
}
}
}---
Part 8: Low Power Mode & Thermal Response APIs
Low Power Mode Detection
import Foundation
class PowerStateManager {
private var cancellables = Set<AnyCancellable>()
init() {
// Check initial state
updateForPowerState()
// Observe changes
NotificationCenter.default.publisher(
for: .NSProcessInfoPowerStateDidChange
)
.sink { [weak self] _ in
self?.updateForPowerState()
}
.store(in: &cancellables)
}
private func updateForPowerState() {
if ProcessInfo.processInfo.isLowPowerModeEnabled {
reduceEnergyUsage()
} else {
restoreNormalOperation()
}
}
private func reduceEnergyUsage() {
// Increase timer intervals
// Reduce animation frame rates
// Defer network requests
// Stop location updates if not critical
// Reduce refresh frequency
}
}Thermal State Response
import Foundation
class ThermalManager {
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(thermalStateChanged),
name: ProcessInfo.thermalStateDidChangeNotification,
object: nil
)
}
@objc private func thermalStateChanged() {
switch ProcessInfo.processInfo.thermalState {
case .nominal:
// Normal operation
restoreFullFunctionality()
case .fair:
// Slightly elevated, minor reduction
reduceNonEssentialWork()
case .serious:
// Significant reduction needed
suspendBackgroundTasks()
reduceAnimationQuality()
case .critical:
// Maximum reduction
minimizeAllActivity()
showThermalWarningIfAppropriate()
@unknown default:
break
}
}
}---
Part 9: MetricKit Monitoring APIs
The 27 cycle replaces this subscriber model with a Swift-first API (MetricManager + AsyncSequence, typed metrics incl. iOS-only background-time and pixel-luminance metrics) — see axiom-performance (skills/metrickit-ref.md) Part 1. The legacy setup below works on iOS 13–26.
Basic Setup
import MetricKit
class MetricsManager: NSObject, MXMetricManagerSubscriber {
static let shared = MetricsManager()
func startMonitoring() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
processPayload(payload)
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
processDiagnostic(payload)
}
}
}Processing Energy Metrics
func processPayload(_ payload: MXMetricPayload) {
// CPU metrics — MXCPUMetric has no foreground/background split.
if let cpu = payload.cpuMetrics {
let totalCPUTime = cpu.cumulativeCPUTime // Measurement<UnitDuration> (iOS 13+)
let instructionsRetired = cpu.cumulativeCPUInstructions // Measurement<Unit>, dimensionless count (iOS 14+)
logMetric("cpu_time", value: totalCPUTime)
logMetric("cpu_instructions", value: instructionsRetired)
}
// Location metrics
if let location = payload.locationActivityMetrics {
let backgroundLocationTime = location.cumulativeBackgroundLocationTime
logMetric("background_location_seconds", value: backgroundLocationTime)
}
// Network metrics
if let network = payload.networkTransferMetrics {
let cellularUpload = network.cumulativeCellularUpload
let cellularDownload = network.cumulativeCellularDownload
let wifiUpload = network.cumulativeWifiUpload
let wifiDownload = network.cumulativeWifiDownload
logMetric("cellular_upload", value: cellularUpload)
logMetric("cellular_download", value: cellularDownload)
}
// Disk metrics
if let disk = payload.diskIOMetrics {
let writes = disk.cumulativeLogicalWrites
logMetric("disk_writes", value: writes)
}
// GPU metrics
if let gpu = payload.gpuMetrics {
let gpuTime = gpu.cumulativeGPUTime
logMetric("gpu_time", value: gpuTime)
}
}Xcode Organizer Integration
View field metrics in Xcode: 1. Window → Organizer 2. Select your app 3. Click "Battery Usage" in sidebar 4. Compare versions, filter by device/OS
Categories shown:
- Audio
- Networking
- Processing (CPU + GPU)
- Display
- Bluetooth
- Location
- Camera
- Torch
- NFC
- Other
---
Part 10: Push Notifications APIs
Alert Notifications Setup
From WWDC20-10095:
import UserNotifications
class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
func setup() {
UNUserNotificationCenter.current().delegate = self
UIApplication.shared.registerForRemoteNotifications()
}
func requestPermission() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
) { granted, error in
print("Permission granted: \(granted)")
}
}
}
// AppDelegate
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
sendTokenToServer(token)
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register: \(error)")
}Background Push Notifications
// Handle background notification
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Check for content-available flag
guard let aps = userInfo["aps"] as? [String: Any],
aps["content-available"] as? Int == 1 else {
completionHandler(.noData)
return
}
Task {
do {
let hasNewData = try await fetchLatestContent()
completionHandler(hasNewData ? .newData : .noData)
} catch {
completionHandler(.failed)
}
}
}Server Payload Examples
// Alert notification (user-visible)
{
"aps": {
"alert": {
"title": "New Message",
"body": "You have a new message from John"
},
"sound": "default",
"badge": 1
},
"message_id": "12345"
}
// Background notification (silent)
{
"aps": {
"content-available": 1
},
"update_type": "new_content"
}Push Priority Headers
| Priority | Header | Use Case |
|---|---|---|
| High (10) | apns-priority: 10 | Time-sensitive alerts |
| Low (5) | apns-priority: 5 | Deferrable updates |
Energy tip: Use priority 5 for all non-urgent notifications. System batches low-priority pushes for energy efficiency.
---
Troubleshooting Checklist
Issue: App at Top of Battery Settings
- [ ] Run Power Profiler to identify dominant subsystem
- [ ] Check for timers without tolerance
- [ ] Check for polling patterns
- [ ] Check for continuous location
- [ ] Check for background audio session
- [ ] Verify BGTasks complete promptly
Issue: Device Gets Hot
- [ ] Check GPU Power Impact for sustained high values
- [ ] Look for continuous animations
- [ ] Check for blur effects over dynamic content
- [ ] Verify Metal frame limiting
- [ ] Check CPU for tight loops
Issue: Background Battery Drain
- [ ] Audit background modes in Info.plist
- [ ] Verify audio session deactivated when not playing
- [ ] Check location accuracy and stop calls
- [ ] Verify beginBackgroundTask calls end promptly
- [ ] Review BGTask scheduling
Issue: High Cellular Usage
- [ ] Check allowsExpensiveNetworkAccess setting
- [ ] Verify discretionary flag on background downloads
- [ ] Look for polling patterns
- [ ] Check for large automatic downloads
---
Expert Review Checklist
Timers (10 items)
- [ ] Tolerance ≥10% on all timers
- [ ] Timers invalidated in deinit
- [ ] No timers running when app backgrounded
- [ ] Using Combine Timer where possible
- [ ] No sub-second intervals without justification
- [ ] Event-driven alternatives considered
- [ ] No synchronization via timer polling
- [ ] Timer invalidated before creating new one
- [ ] Repeating timers have clear stop condition
- [ ] Background timer usage justified
Network (10 items)
- [ ] waitsForConnectivity = true
- [ ] allowsExpensiveNetworkAccess appropriate
- [ ] allowsConstrainedNetworkAccess appropriate
- [ ] Non-urgent downloads use discretionary
- [ ] Push notifications instead of polling
- [ ] Requests batched where possible
- [ ] Payloads compressed
- [ ] Background URLSession for large transfers
- [ ] Retry logic has exponential backoff
- [ ] Connection reuse via single URLSession
Location (10 items)
- [ ] Accuracy appropriate for use case
- [ ] distanceFilter set
- [ ] Updates stopped when not needed
- [ ] pausesLocationUpdatesAutomatically = true
- [ ] Background location only if essential
- [ ] Significant-change for background
- [ ] CLMonitor for region monitoring
- [ ] Location permission matches actual need
- [ ] Stationary detection utilized
- [ ] Location icon explained to users
Background Execution (10 items)
- [ ] endBackgroundTask called promptly
- [ ] Expiration handlers implemented
- [ ] BGTasks use requiresExternalPower when possible
- [ ] EMRCA principles followed
- [ ] Background modes limited to needed
- [ ] Audio session deactivated when idle
- [ ] Progress saved incrementally
- [ ] Tasks complete within time limits
- [ ] Low Power Mode checked before heavy work
- [ ] Thermal state monitored
Display/GPU (10 items)
- [ ] Dark Mode supported
- [ ] Animations stop when view hidden
- [ ] Frame rates appropriate for content
- [ ] Secondary animations lower priority
- [ ] Blur effects minimized
- [ ] Metal has frame limiting
- [ ] Brightness-independent design
- [ ] No hidden animations consuming power
- [ ] GPU-intensive work has visibility checks
- [ ] ProMotion considered in frame rate decisions
---
WWDC Session Reference
| Session | Year | Topic |
|---|---|---|
| 226 | 2025 | Power Profiler workflow, on-device tracing |
| 227 | 2025 | BGContinuedProcessingTask, EMRCA principles |
| 10083 | 2022 | Dark Mode, frame rates, deferral |
| 10095 | 2020 | Push notifications primer |
| 707 | 2019 | Background execution advances |
| 417 | 2019 | Battery life, MetricKit |
---
Last Updated: 2025-12-26 Platforms: iOS 26+, iPadOS 26+
Energy Optimization
Overview
Energy issues manifest as battery drain, hot devices, and poor App Store reviews. Core principle: Measure before optimizing. Use Power Profiler to identify the dominant subsystem (CPU/GPU/Network/Location/Display), then apply targeted fixes.
Key insight: Developers often don't know where to START auditing. This skill provides systematic diagnosis, not guesswork.
Requirements: iOS 26+, Xcode 26+, Power Profiler in Instruments
Example Prompts
- "My app is always at the top of Battery Settings. How do I find what's draining power?"
- "Users report my app makes their phone hot. Where do I start debugging?"
- "I have timers and location updates. Are they causing battery drain?"
- "My app drains battery in the background even when users aren't using it."
- "How do I measure if my optimization actually improved battery life?"
---
Red Flags — High Energy Likely
If you see ANY of these, suspect energy inefficiency:
- Battery Settings: Your app consistently at top of battery consumers
- Device temperature: Phone gets warm during normal app use
- User reviews: Mentions of "battery drain", "hot phone", "kills my battery"
- Xcode Energy Gauge: Shows sustained high or very high impact
- Background runtime: App runs longer than expected when not visible
- Network activity: Frequent small requests instead of batched operations
- Location icon: Appears in status bar when app shouldn't need location
Difference from normal energy use
- Normal: App uses energy during active use, minimal when backgrounded
- Problem: App uses significant energy even when user isn't interacting
Mandatory First Steps
ALWAYS run Power Profiler FIRST before optimizing code:
Step 1: Record a Power Trace (5 minutes)
1. Connect iPhone wirelessly to Xcode (wireless debugging)
2. Xcode → Product → Profile (Cmd+I)
3. Select Blank template
4. Click "+" → Add "Power Profiler" instrument
5. Optional: Add "CPU Profiler" for correlation
6. Click Record
7. Use your app normally for 2-3 minutes
8. Click StopWhy wireless: When device is charging via cable, power metrics show 0. Use wireless debugging for accurate readings.
Step 2: Identify Dominant Subsystem
Expand the Power Profiler track and examine per-app metrics:
| Lane | Meaning | High Value Indicates |
|---|---|---|
| CPU Power Impact | Processor activity | Computation, timers, parsing |
| GPU Power Impact | Graphics rendering | Animations, blur, Metal |
| Display Power Impact | Screen usage | Brightness, always-on content |
| Network Power Impact | Radio activity | Requests, downloads, polling |
Look for: Which subsystem shows highest sustained values during your app's usage.
Step 3: Branch to Subsystem-Specific Fixes
Once you identify the dominant subsystem, use the decision trees below.
What this tells you
- CPU dominant → Check timers, polling, JSON parsing, eager loading
- GPU dominant → Check animations, blur effects, frame rates
- Network dominant → Check request frequency, polling vs push
- Display dominant → Check Dark Mode, brightness, screen-on time
- Location (shown in CPU) → Check accuracy, update frequency
Why diagnostics first
- Finding root cause with Power Profiler: 15-20 minutes
- Guessing and testing random optimizations: 4+ hours, often wrong subsystem
---
Energy Decision Tree
User reports energy issue?
│
├─ CPU Power Impact dominant?
│ ├─ Continuous high impact?
│ │ ├─ Timers running? → Pattern 1: Timer Efficiency
│ │ ├─ Polling data? → Pattern 2: Push vs Poll
│ │ └─ Processing in loop? → Pattern 3: Lazy Loading
│ ├─ Spikes during specific actions?
│ │ ├─ JSON parsing? → Cache parsed results
│ │ ├─ Image processing? → Move to background, cache
│ │ └─ Database queries? → Index, batch, prefetch
│ └─ High background CPU?
│ ├─ Location updates? → Pattern 4: Location Efficiency
│ ├─ BGTasks running too long? → Pattern 5: Background Execution
│ └─ Audio session active? → Stop when not playing
│
├─ Network Power Impact dominant?
│ ├─ Many small requests?
│ │ └─ Batch into fewer large requests
│ ├─ Polling pattern detected?
│ │ └─ Convert to push notifications → Pattern 2
│ ├─ Downloads in foreground?
│ │ └─ Use discretionary background URLSession
│ └─ High cellular usage?
│ └─ Defer to WiFi when possible
│
├─ GPU Power Impact dominant?
│ ├─ Continuous animations?
│ │ └─ Stop when view not visible
│ ├─ Blur effects (UIVisualEffectView)?
│ │ └─ Reduce or remove, use solid colors
│ ├─ High frame rate animations?
│ │ └─ Audit secondary frame rates → Pattern 6
│ └─ Metal rendering?
│ └─ Implement frame limiting
│
├─ Display Power Impact dominant?
│ ├─ Light backgrounds on OLED?
│ │ └─ Implement Dark Mode (up to 70% savings)
│ ├─ High brightness content?
│ │ └─ Use darker UI elements
│ └─ Screen always on?
│ └─ Allow screen to sleep when appropriate
│
└─ Location causing drain? (check CPU lane + location icon)
├─ Continuous updates?
│ └─ Switch to significant-change monitoring
├─ High accuracy (kCLLocationAccuracyBest)?
│ └─ Reduce to kCLLocationAccuracyHundredMeters
└─ Background location?
└─ Evaluate if truly needed → Pattern 4---
Common Energy Patterns (With Fixes)
Pattern 1: Timer Efficiency
Problem: Timers wake the CPU from idle states, consuming significant energy.
❌ Anti-Pattern — Timer without tolerance
// BAD: Timer fires exactly every 1.0 seconds
// Prevents system from batching with other timers
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.updateUI()
}✅ Fix — Set tolerance for timer batching
// GOOD: 10% tolerance allows system to batch timers
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.updateUI()
}
timer.tolerance = 0.1 // 10% tolerance minimum
// BETTER: Use Combine Timer with tolerance
Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
.autoconnect()
.sink { [weak self] _ in
self?.updateUI()
}
.store(in: &cancellables)✅ Best — Use event-driven instead of polling
// BEST: Don't use timer at all — react to events
NotificationCenter.default.publisher(for: .dataDidUpdate)
.sink { [weak self] _ in
self?.updateUI()
}
.store(in: &cancellables)Key points:
- Set tolerance to at least 10% of interval
- Timer tolerance allows system to batch multiple timers into single wake
- Prefer event-driven patterns over polling timers
- Always invalidate timers when no longer needed
---
Pattern 2: Push vs Poll
Problem: Polling (checking server every N seconds) keeps radios active and drains battery.
❌ Anti-Pattern — Polling every 5 seconds
// BAD: Polls server every 5 seconds
// Radio stays active, massive battery drain
Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.fetchLatestData() // Network request every 5 seconds
}✅ Fix — Use background push notifications
// GOOD: Server pushes when data changes
// Radio only active when there's actual new data
// 1. Register for remote notifications
UIApplication.shared.registerForRemoteNotifications()
// 2. Handle background notification
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
guard let _ = userInfo["content-available"] else {
completionHandler(.noData)
return
}
Task {
do {
let hasNewData = try await fetchLatestData()
completionHandler(hasNewData ? .newData : .noData)
} catch {
completionHandler(.failed)
}
}
}Server payload for background push:
{
"aps": {
"content-available": 1
},
"custom-data": "your-payload"
}Key points:
- Background pushes are discretionary — system delivers at optimal time
- Use
apns-priority: 5for non-urgent updates (energy efficient) - Use
apns-priority: 10only for time-sensitive alerts - Polling every 5 seconds uses 100x more energy than push
---
Pattern 3: Lazy Loading & Caching
Problem: Loading all data upfront causes CPU spikes and memory pressure.
❌ Anti-Pattern — Eager loading (from WWDC25-226)
// BAD: Creates and renders ALL views upfront
// From WWDC25-226: This caused CPU spike and hang
VStack {
ForEach(videos) { video in
VideoCardView(video: video) // Creates ALL thumbnails immediately
}
}✅ Fix — Lazy loading
// GOOD: Only creates visible views
// From WWDC25-226: Reduced CPU power impact from 21 to 4.3
LazyVStack {
ForEach(videos) { video in
VideoCardView(video: video) // Creates on-demand
}
}❌ Anti-Pattern — Repeated parsing (from WWDC25-226)
// BAD: Parses JSON file on every location update
// From WWDC25-226: Caused continuous CPU drain during commute
func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
// Called every location change!
let data = try? Data(contentsOf: rulesFileURL)
let rules = try? JSONDecoder().decode([RecommendationRule].self, from: data)
return filteredVideos(using: rules)
}✅ Fix — Cache parsed data
// GOOD: Parse once, reuse cached result
// From WWDC25-226: Eliminated CPU drain
private lazy var cachedRules: [RecommendationRule] = {
let data = try? Data(contentsOf: rulesFileURL)
return (try? JSONDecoder().decode([RecommendationRule].self, from: data)) ?? []
}()
func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
return filteredVideos(using: cachedRules) // No parsing!
}Key points:
- Use
LazyVStack,LazyHStack,LazyVGridfor large collections - Cache parsed JSON, decoded data, computed results
- Move expensive operations out of frequently-called methods
---
Pattern 4: Location Efficiency
Problem: Continuous location updates keep GPS active, draining battery rapidly.
❌ Anti-Pattern — Continuous high-accuracy updates
// BAD: Continuous updates with best accuracy
// GPS stays active constantly, massive battery drain
let locationManager = CLLocationManager()
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.startUpdatingLocation() // Never stops!✅ Fix — Appropriate accuracy and significant-change
// GOOD: Reduced accuracy, significant-change monitoring
let locationManager = CLLocationManager()
// Use appropriate accuracy (100m is fine for most apps)
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
// Use distance filter to reduce updates
locationManager.distanceFilter = 100 // Only update every 100 meters
// For background: Use significant-change monitoring
locationManager.startMonitoringSignificantLocationChanges()
// Stop when done
func stopTracking() {
locationManager.stopUpdatingLocation()
locationManager.stopMonitoringSignificantLocationChanges()
}✅ Better — iOS 26+ CLLocationUpdate with stationary detection
// BEST: Modern async API with automatic stationary detection
for try await update in CLLocationUpdate.liveUpdates() {
if update.stationary {
// Device stopped moving — system pauses updates automatically
// Switch to CLMonitor for region monitoring
break
}
handleLocation(update.location)
}Accuracy comparison (battery impact):
| Accuracy | Battery Impact | Use Case |
|---|---|---|
kCLLocationAccuracyBest | Very High | Navigation apps only |
kCLLocationAccuracyNearestTenMeters | High | Fitness tracking |
kCLLocationAccuracyHundredMeters | Medium | Store locators |
kCLLocationAccuracyKilometer | Low | Weather apps |
| Significant-change | Very Low | Background updates |
---
Pattern 5: Background Execution (EMRCA)
Problem: Background tasks that run too long or too often drain battery.
EMRCA Principles (from WWDC25-227)
Your background work must be:
- Efficient — Design lightweight, purpose-driven tasks
- Minimal — Keep background work to a minimum
- Resilient — Save incremental progress; respond to expiration signals
- Courteous — Honor user preferences and system conditions
- Adaptive — Understand and adapt to system priorities
❌ Anti-Pattern — Long-running background task
// BAD: Requests unlimited background time
// System will terminate after ~30 seconds anyway
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
backgroundTask = application.beginBackgroundTask {
// Expiration handler — but task runs too long
}
// Long operation that may not complete
performLongOperation()
}✅ Fix — Proper background task handling
// GOOD: Finish quickly, save progress, notify system
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
backgroundTask = application.beginBackgroundTask(withName: "Save State") { [weak self] in
// Expiration handler — clean up immediately
self?.saveProgress()
if let task = self?.backgroundTask {
application.endBackgroundTask(task)
}
self?.backgroundTask = .invalid
}
// Quick operation
saveEssentialState()
// End task as soon as done — don't wait for expiration
application.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}✅ For Long Operations — Use BGProcessingTask
// BEST: Let system schedule at optimal time (charging, WiFi)
func scheduleBackgroundProcessing() {
let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = true // Only when charging
try? BGTaskScheduler.shared.submit(request)
}
// Register handler at app launch
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.maintenance",
using: nil
) { task in
self.handleMaintenance(task: task as! BGProcessingTask)
}✅ iOS 26+ — BGContinuedProcessingTask for user-initiated work
// NEW iOS 26: Continue user-initiated tasks with progress UI
let request = BGContinuedProcessingTaskRequest(
identifier: "com.app.export",
title: "Exporting Photos",
subtitle: "23 of 100 photos"
)
try? BGTaskScheduler.shared.submit(request)---
Pattern 6: Frame Rate Auditing
Problem: Secondary animations running at higher frame rates than needed increase GPU power.
❌ Anti-Pattern — Uncontrolled frame rates
// BAD: Secondary animation runs at 60fps
// When primary content only needs 30fps, this wastes power
UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat]) {
self.subtitleLabel.alpha = 0.5
} completion: { _ in
self.subtitleLabel.alpha = 1.0
}✅ Fix — Control frame rate with CADisplayLink
// GOOD: Explicitly set preferred frame rate
let displayLink = CADisplayLink(target: self, selector: #selector(updateAnimation))
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 10,
maximum: 30, // Match primary content
preferred: 30
)
displayLink.add(to: .current, forMode: .default)From WWDC22-10083: Up to 20% battery savings by aligning secondary animation frame rates with primary content.
---
Audit Checklists
Timer Audit
- [ ] All timers have tolerance set (≥10% of interval)?
- [ ] Timers invalidated when no longer needed?
- [ ] Using Combine Timer instead of NSTimer where possible?
- [ ] No polling patterns that could use push notifications?
- [ ] Timers stopped when app enters background?
Network Audit
- [ ] Requests batched instead of many small requests?
- [ ] Using discretionary URLSession for non-urgent downloads?
- [ ]
waitsForConnectivityset to avoid failed connection attempts? - [ ]
allowsExpensiveNetworkAccessset to false for deferrable work? - [ ] Push notifications instead of polling?
Location Audit
- [ ] Using appropriate accuracy (not
kCLLocationAccuracyBestunless navigation)? - [ ]
distanceFilterset to reduce update frequency? - [ ] Stopping updates when no longer needed?
- [ ] Using significant-change for background updates?
- [ ] Background location justified and explained to users?
Background Execution Audit
- [ ]
endBackgroundTaskcalled promptly when work completes? - [ ] Long operations use
BGProcessingTaskwithrequiresExternalPower? - [ ] Background modes in Info.plist limited to what's actually needed?
- [ ] Audio session deactivated when not playing?
- [ ] EMRCA principles followed?
Display/GPU Audit
- [ ] Dark Mode supported (70% OLED power savings)?
- [ ] Animations stopped when view not visible?
- [ ] Secondary animations use appropriate frame rates?
- [ ] Blur effects minimized or removed?
- [ ] Metal rendering has frame limiting?
Disk I/O Audit
- [ ] Writes batched instead of frequent small writes?
- [ ] SQLite using WAL journaling mode?
- [ ] Avoiding rapid file creation/deletion?
- [ ] Using SwiftData/Core Data instead of serialized files for frequent updates?
---
Pressure Scenarios
Scenario 1: "Just poll every 5 seconds for real-time updates"
The temptation: "Push notifications are complex. Polling is simpler."
The reality:
- Polling every 5 seconds: Radio active 100% of time
- Push notifications: Radio active only when data changes
- Users WILL see your app at top of Battery Settings
- App Store reviews WILL mention "battery hog"
Time cost comparison:
- Implement polling: 30 minutes
- Implement push: 2-4 hours
- Fix bad reviews + reputation damage: Weeks
Pushback template: "Push notification setup takes a few hours, but polling will guarantee we're at the top of Battery Settings. Users actively uninstall apps that drain battery. The 2-hour investment prevents ongoing reputation damage."
---
Scenario 2: "Use continuous location for best accuracy"
The temptation: "Users expect accurate location. Let's use kCLLocationAccuracyBest."
The reality:
kCLLocationAccuracyBest: GPS + WiFi + Cellular triangulation = massive drainkCLLocationAccuracyHundredMeters: Good enough for 95% of use cases- Location icon in status bar = users checking Battery Settings
Time cost comparison:
- Implement high accuracy: 10 minutes
- Debug "why does my app drain battery" complaints: Hours
- Refactor to appropriate accuracy: 30 minutes
Pushback template: "100-meter accuracy is sufficient for [use case]. Navigation apps like Google Maps need best accuracy, but we're showing [store locations / weather / general area]. The accuracy difference is imperceptible to users, but battery difference is massive."
---
Scenario 3: "Keep animations running, users expect smooth UI"
The temptation: "Animations make the app feel alive and polished."
The reality:
- Animations running when view not visible = pure waste
- High frame rate secondary animations = GPU drain
- GPU power is significant portion of total device power
Time cost comparison:
- Add animation: 15 minutes
- Add visibility checks: 5 minutes extra
- Debug "phone gets hot" reports: Hours
Pushback template: "We can keep the animation, but should pause it when the view isn't visible. This is a 5-minute change that prevents GPU drain when users aren't looking at the screen."
---
Scenario 4: "Ship now, optimize later"
The temptation: "Energy optimization is polish. We can do it in v1.1."
The reality:
- Battery drain is immediately visible to users
- First impressions drive reviews
- "Battery hog" reputation is hard to shake
- Power Profiler baseline takes 15 minutes
Time cost comparison:
- Power Profiler check before launch: 15 minutes
- Fix energy issues post-launch: Days (plus reputation damage)
- Regain user trust: Months
Pushback template: "A 15-minute Power Profiler session before launch catches major energy issues. If we ship with battery problems, users will see us at top of Battery Settings on day one and leave 1-star reviews. Let me do a quick check — it's faster than damage control."
---
Real-World Examples
Example 1: Video Streaming App with Eager Loading (WWDC25-226)
Symptom: CPU power impact jumped from 1 to 21 when opening Library pane. UI hung.
Diagnosis using Power Profiler: 1. Recorded trace while opening Library pane 2. CPU Power Impact lane showed massive spike 3. Time Profiler showed VideoCardView body called hundreds of times 4. Root cause: VStack creating ALL video thumbnails upfront
Fix:
// Before: VStack (eager)
VStack {
ForEach(videos) { video in
VideoCardView(video: video)
}
}
// After: LazyVStack (on-demand)
LazyVStack {
ForEach(videos) { video in
VideoCardView(video: video)
}
}Result: CPU power impact dropped from 21 to 4.3. UI no longer hung.
---
Example 2: Location-Based Suggestions with Repeated Parsing (WWDC25-226)
Symptom: User commuting reported massive battery drain. Developer couldn't reproduce at desk.
Diagnosis using on-device Power Profiler: 1. User collected trace during commute (Settings → Developer → Performance Trace) 2. Trace showed periodic CPU spikes correlating with movement 3. Time Profiler showed videoSuggestionsForLocation consuming CPU 4. Root cause: JSON file parsed on EVERY location update
Fix:
// Before: Parse on every call
func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
let data = try? Data(contentsOf: rulesFileURL)
let rules = try? JSONDecoder().decode([RecommendationRule].self, from: data)
return filteredVideos(using: rules)
}
// After: Parse once, cache
private lazy var cachedRules: [RecommendationRule] = {
let data = try? Data(contentsOf: rulesFileURL)
return (try? JSONDecoder().decode([RecommendationRule].self, from: data)) ?? []
}()
func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
return filteredVideos(using: cachedRules)
}Result: Eliminated CPU spikes during movement. Battery drain resolved.
---
Example 3: Music App with Always-Active Audio Session
Symptom: App drains battery even when not playing music.
Diagnosis: 1. Power Profiler showed sustained background CPU activity 2. Audio session remained active after playback stopped 3. System kept audio hardware powered on
Fix:
// Before: Never deactivate
func playTrack(_ track: Track) {
try? AVAudioSession.sharedInstance().setActive(true)
player.play()
}
func stopPlayback() {
player.stop()
// Audio session still active!
}
// After: Deactivate when done
func stopPlayback() {
player.stop()
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
// Even better: Use AVAudioEngine auto-shutdown
let engine = AVAudioEngine()
engine.isAutoShutdownEnabled = true // Automatically powers down when idleResult: Background audio hardware powered down. Battery drain eliminated.
---
Responding to Low Power Mode
Detect and adapt when user enables Low Power Mode:
// Check current state
if ProcessInfo.processInfo.isLowPowerModeEnabled {
reduceEnergyUsage()
}
// React to changes
NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)
.sink { [weak self] _ in
if ProcessInfo.processInfo.isLowPowerModeEnabled {
self?.reduceEnergyUsage()
} else {
self?.restoreNormalOperation()
}
}
.store(in: &cancellables)
func reduceEnergyUsage() {
// Pause optional activities
// Reduce animation frame rates
// Increase timer intervals
// Defer network requests
// Stop location updates if not critical
}---
Monitoring Energy in Production
MetricKit Setup
import MetricKit
class EnergyMetricsManager: NSObject, MXMetricManagerSubscriber {
static let shared = EnergyMetricsManager()
func startMonitoring() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
if let cpuMetrics = payload.cpuMetrics {
// Monitor CPU time
let foregroundCPU = cpuMetrics.cumulativeCPUTime
logMetric("foreground_cpu", value: foregroundCPU)
}
if let locationMetrics = payload.locationActivityMetrics {
// Monitor location usage
let backgroundLocation = locationMetrics.cumulativeBackgroundLocationTime
logMetric("background_location", value: backgroundLocation)
}
}
}
}Xcode Organizer
Check Battery Usage pane in Xcode Organizer for field data:
- Foreground vs background energy breakdown
- Category breakdown (Audio, Networking, Processing, Display, etc.)
- Version comparison to detect regressions
---
Quick Reference
Power Profiler Workflow
1. Connect device wirelessly
2. Product → Profile → Blank → Add Power Profiler
3. Record 2-3 minutes of usage
4. Identify dominant subsystem (CPU/GPU/Network/Display)
5. Apply targeted fix from patterns above
6. Record again to verify improvementKey Energy Savings
| Optimization | Potential Savings |
|---|---|
| Dark Mode on OLED | Up to 70% display power |
| Frame rate alignment | Up to 20% GPU power |
| Push vs poll | 100x network efficiency |
| Location accuracy reduction | 50-90% GPS power |
| Timer tolerance | Significant CPU savings |
| Lazy loading | Eliminates startup CPU spikes |
Resources
WWDC: 2025-226, 2025-227, 2022-10083, 2020-10095, 2019-417
Skills: skills/energy-ref.md, skills/energy-diag.md, skills/performance-profiling.md, skills/memory-debugging.md
Hang Diagnostics
Systematic diagnosis and resolution of app hangs. A hang occurs when the main thread is blocked for more than 1 second, making the app unresponsive to user input.
Why xcsym rejected my hang .ips
xcsym's crash subcommand explicitly rejects .ips files of type hang because hang analysis has a different workflow from crash analysis. If xcsym returned HangError: bug_type=298, you're in the right place — this skill is the authoritative path for hang diagnosis. See axiom-tools (skills/xcsym-ref.md) for the crash-focused workflow.
Red Flags — Check This Skill When
| Symptom | This Skill Applies |
|---|---|
| App freezes briefly during use | Yes — likely hang |
| UI doesn't respond to touches | Yes — main thread blocked |
| "App not responding" system dialog | Yes — severe hang |
| Xcode Organizer shows hang diagnostics | Yes — field hang reports |
| MetricKit MXHangDiagnostic received | Yes — aggregated hang data |
| Animations stutter or skip | Maybe — could be hitch, not hang |
| App feels slow but responsive | No — performance issue, not hang |
What Is a Hang
A hang is when the main runloop cannot process events for more than 1 second. The user taps, but nothing happens.
User taps → Main thread busy/blocked → Event queued → 1+ second delay → HANGKey distinction: The main thread handles ALL user input. If it's busy or blocked, the entire UI freezes.
Hang vs Hitch vs Lag
| Issue | Duration | User Experience | Tool |
|---|---|---|---|
| Hang | >1 second | App frozen, unresponsive | Time Profiler, System Trace |
| Hitch | 1-3 frames (16-50ms) | Animation stutters | Animation Hitches instrument |
| Lag | 100-500ms | Feels slow but responsive | Time Profiler |
This skill covers hangs. For hitches, see axiom-swiftui (performance reference). For general lag, see axiom-performance (skills/performance-profiling.md).
The Two Causes of Hangs
Every hang has one of two root causes:
1. Main Thread Busy
The main thread is doing work instead of processing events.
Subcategories:
| Type | Example | Fix |
|---|---|---|
| Proactive work | Pre-computing data user hasn't requested | Lazy initialization, compute on demand |
| Irrelevant work | Processing all notifications, not just relevant ones | Filter notifications, targeted observers |
| Suboptimal API | Using blocking API when async exists | Switch to async API |
2. Main Thread Blocked
The main thread is waiting for something else.
Subcategories:
| Type | Example | Fix |
|---|---|---|
| Synchronous IPC | Calling system service synchronously | Use async API variant |
| File I/O | Data(contentsOf:) on main thread | Move to background queue |
| Network | Synchronous URL request | Use URLSession async |
| Lock contention | Waiting for lock held by background thread | Reduce critical section, use actors |
| Semaphore/dispatch_sync | Blocking on background work | Restructure to async completion |
Decision Tree — Diagnosing Hangs
START: App hangs reported
│
├─→ Do you have hang diagnostics from Organizer or MetricKit?
│ │
│ ├─→ YES: Examine stack trace
│ │ │
│ │ ├─→ Stack shows your code running
│ │ │ → BUSY: Main thread doing work
│ │ │ → Profile with Time Profiler
│ │ │
│ │ └─→ Stack shows waiting (semaphore, lock, dispatch_sync)
│ │ → BLOCKED: Main thread waiting
│ │ → Profile with System Trace
│ │
│ └─→ NO: Can you reproduce?
│ │
│ ├─→ YES: Profile with Time Profiler first
│ │ │
│ │ ├─→ High CPU on main thread
│ │ │ → BUSY: Optimize the work
│ │ │
│ │ └─→ Low CPU, thread blocked
│ │ → Use System Trace to find what's blocking
│ │
│ └─→ NO: Enable MetricKit in app
│ → Wait for field reports
│ → Check Organizer > HangsTool Selection
| Scenario | Primary Tool | Why |
|---|---|---|
| Reproduces locally | Time Profiler | See exactly what main thread is doing |
| Blocked thread suspected | System Trace | Shows thread state, lock contention |
| Field reports only | Xcode Organizer | Aggregated hang diagnostics |
| Want in-app data | MetricKit | MXHangDiagnostic with call stacks |
| Need precise timing | System Trace | Nanosecond-level thread analysis |
| Re-scope a known hang to app code | xcprof | Auto-flags candidate stalls; --start-ms/--end-ms window + --user-binary attribution (see Hang Window Workflow) |
Time Profiler Workflow for Hangs
1. Launch Instruments → Select Time Profiler template 2. Record during hang → Reproduce the freeze 3. Stop recording → Find the hang period in timeline 4. Select hang region → Drag to select frozen timespan 5. Examine call tree → Look for main thread work
What to look for:
- Functions with high "Self Time" on main thread
- Unexpectedly deep call stacks
- System calls that shouldn't be on main thread
Hang Window Workflow
xcprof analyze runs main-thread hang detection automatically on every invocation (not opt-in): the ## Main thread (approximate) section reports the largest gap between consecutive main-thread samples (max gap) and a candidate stalls count — a strong signal that a hang occurred and how long the worst one was. It's approximate because cpu-profile only samples running threads, so a large gap is a candidate stall, not a confirmed one. The actionable follow-up is to re-scope to the hang window and attribute the samples to your own code.
Step 1 — Bound the window. xcprof reports the stall's duration (max gap), not its start time, so estimate the window from when you observed the freeze: a MetricKit hang report's timestamp, a user-visible stall, or the Instruments timeline (--open). Example: a ~5s freeze around the 2s mark → window ≈ 2000–7000ms.
Step 2 — Re-scope and attribute to app code.
xcprof analyze MyApp.trace \
--start-ms 2000 --end-ms 7000 \
--user-binary MyApp--start-ms/--end-ms restrict the sample set to that window (echoed back as a scope: line). The ## Top user-code frames table is emitted on every run; --user-binary (comma-separated) just sharpens it — narrowing user-code attribution to the named binaries plus the recording target, so the table lists your app's functions instead of every non-system frame.
Expected output (shape — real output is markdown sections + tables):
## Summary
- duration: 12.400s · mode: immediate · end: time-limit
- scope: 2000–7000ms (812 samples in window)
## Main thread (approximate)
- samples: 812 · cpu share: 71.2% · max gap: 4980ms (threshold 250ms) · candidate stalls: 1
## Top user-code frames
| function | binary | self | inclusive |
|---|---|---|---|
| ImageStore.thumbnail(for:) | MyApp | 58.0% (~2900ms) | 62.0% (~3100ms) |
| FeedView.body.getter | MyApp | 12.0% (~600ms) | 24.0% (~1200ms) |The answer is now "ImageStore.thumbnail(for:) runs ~2.9s on the main thread" (→ move the decode off-main), not an opaque deepest system frame.
Release builds without symbols attribute nothing ("none attributed"). Pass --dsym <path> (or rely on Spotlight UUID discovery) so frames resolve to names.System Trace Workflow for Blocked Hangs
1. Launch Instruments → Select System Trace template 2. Record during hang → Capture thread states 3. Find main thread → Filter to main thread 4. Look for red/orange → Blocked states 5. Examine blocking reason → Lock, semaphore, IPC
Thread states:
- Running (blue): Executing code
- Preempted (orange): Runnable but not scheduled
- Blocked (red): Waiting for resource
Common Hang Patterns and Fixes
Pattern 1: Synchronous File I/O
Before (hangs):
// Main thread blocks on file read
func loadUserData() {
let data = try! Data(contentsOf: largeFileURL) // BLOCKS
processData(data)
}After (async):
func loadUserData() {
// `Task.detached` is intentional — `Task {}` would inherit the caller's
// @MainActor isolation and run the file I/O on main. In Swift 6.2+,
// prefer marking a helper `@concurrent` instead of detached.
Task.detached {
let data = try Data(contentsOf: largeFileURL)
await MainActor.run {
self.processData(data)
}
}
}Pattern 2: Unfiltered Notification Observer
Before (processes all):
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSManagedObjectContextObjectsDidChange,
object: nil // Receives ALL contexts
)After (filtered):
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSManagedObjectContextObjectsDidChange,
object: relevantContext // Only this context
)Pattern 3: Expensive Formatter Creation
Before (creates each time):
func formatDate(_ date: Date) -> String {
let formatter = DateFormatter() // EXPENSIVE
formatter.dateStyle = .medium
return formatter.string(from: date)
}After (cached):
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
func formatDate(_ date: Date) -> String {
Self.dateFormatter.string(from: date)
}Pattern 4: dispatch_sync to Main Thread
Before (deadlock risk):
// From background thread
DispatchQueue.main.sync { // BLOCKS if main is blocked
updateUI()
}After (async):
DispatchQueue.main.async {
self.updateUI()
}Pattern 5: Semaphore for Async Result
Before (blocks main thread):
func fetchDataSync() -> Data {
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
URLSession.shared.dataTask(with: url) { data, _, _ in
result = data
semaphore.signal()
}.resume()
semaphore.wait() // BLOCKS MAIN THREAD
return result!
}After (async/await):
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}Pattern 6: Lock Contention
Before (shared lock):
class DataManager {
private let lock = NSLock()
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
lock.lock() // Main thread waits for background
defer { lock.unlock() }
return cache[key]
}
}After (actor):
actor DataManager {
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
cache[key] // Actor serializes access safely
}
}Pattern 7: App Launch Hang (Watchdog)
Before (too much work):
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
loadAllUserData() // Expensive
setupAnalytics() // Network calls
precomputeLayouts() // CPU intensive
return true
}After (deferred):
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Only essential setup
setupMinimalUI()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Defer non-essential work
Task {
await loadUserDataInBackground()
}
}Pattern 8: Image Processing on Main Thread
Before (blocks UI):
func processImage(_ image: UIImage) {
let filtered = applyExpensiveFilter(image) // BLOCKS
imageView.image = filtered
}After (background processing):
func processImage(_ image: UIImage) {
imageView.image = placeholder
// `Task.detached` here because the enclosing function is @MainActor-isolated;
// `Task {}` would inherit isolation and run the filter on main. In Swift 6.2+,
// prefer making the filter `@concurrent` and using a regular `Task {}`.
Task.detached(priority: .userInitiated) {
let filtered = applyExpensiveFilter(image)
await MainActor.run {
self.imageView.image = filtered
}
}
}Xcode Organizer Hang Diagnostics
Window > Organizer > Select App > Hangs
The Organizer shows aggregated hang data from users who opted into sharing diagnostics.
Reading the report: 1. Hang Rate: Hangs per day per device 2. Call Stack: Where the hang occurred 3. Device/OS breakdown: Which configurations affected
Interpreting call stacks:
- Your code at top: Main thread busy with your work
- System API at top: You called blocking API on main thread
- pthread_mutex/semaphore: Lock contention or explicit waiting
The Xcode 27 Organizer goes further: the redesigned Overview pairs the hang-rate chart with the underlying diagnostics on one screen, Metric Goals calibrate an achievable hang-rate target against similar apps and your own baselines, and Generate Recommendations runs an agentic analysis over the diagnostic data to localize the hang and propose fixes. A new hitches metric also surfaces choppy animations beyond scrolling. See axiom-performance (skills/performance-profiling.md) for the Instruments-27 side (Swift executors instrument for main-actor congestion, Inspector for blocked-thread syscalls).
MetricKit Hang Diagnostics
On the 27 cycle, hang diagnostics arrive as typed DiagnosticReport values (OS27 — not watchOS/tvOS):
import MetricKit
let manager = MetricManager() // keep alive
for await report in manager.diagnosticReports {
if case .hang(let hang) = report.result {
uploadHangDiagnostic(duration: hang.hangDuration,
callStack: hang.callStackTree)
}
}report.environment includes the signpost intervals and reported app states active around the hang — see axiom-performance (skills/metrickit-ref.md) Part 1.
On earlier releases, adopt the legacy subscriber:
import MetricKit
class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
if let hangDiagnostics = payload.hangDiagnostics {
for diagnostic in hangDiagnostics {
analyzeHang(diagnostic)
}
}
}
}
private func analyzeHang(_ diagnostic: MXHangDiagnostic) {
// Duration of the hang
let duration = diagnostic.hangDuration
// Call stack tree (needs symbolication)
let callStack = diagnostic.callStackTree
// Send to your analytics
uploadHangDiagnostic(duration: duration, callStack: callStack)
}
}Key MXHangDiagnostic properties:
hangDuration: How long the hang lastedcallStackTree: MXCallStackTree with frames
There is no built-in grouping identifier — derive your own signature from the symbolicated call stack to group similar hangs.
Watchdog Terminations
The watchdog kills apps that hang during key transitions:
| Transition | Time Limit | Consequence |
|---|---|---|
| App launch | ~20 seconds | App killed, crash logged |
| Background transition | ~5 seconds | App killed |
| Foreground transition | ~10 seconds | App killed |
Watchdog disabled in:
- Simulator
- Debugger attached
- Development builds (sometimes)
Watchdog kills are logged as crashes with exception type EXC_CRASH (SIGKILL) and termination reason Namespace RUNNINGBOARD, Code 3735883980 (hex 0xDEAD10CC — indicates app held a file lock or SQLite database lock while being suspended).
Pressure Scenarios
Scenario 1: Manager Says "Just Add a Loading Spinner"
Situation: App hangs during data load. Manager suggests adding spinner to "fix" it.
Why this fails: Adding a spinner doesn't prevent the hang—the UI still freezes, the spinner won't animate, and the app remains unresponsive.
Correct response: "A spinner won't animate during a hang because the main thread is blocked. We need to move this work off the main thread so the spinner can actually spin and the app stays responsive."
Scenario 2: "It Works Fine in Testing"
Situation: QA can't reproduce the hang. Logs show it happens in production.
Analysis: 1. Field devices have different data sizes 2. Network conditions vary (slow connection = longer sync) 3. Background apps consume memory/CPU 4. Watchdog is disabled in debug builds
Action:
- Add MetricKit to capture field diagnostics
- Test with production-sized datasets
- Test without debugger attached
- Check Organizer for hang reports
Scenario 3: "We've Always Done It This Way"
Situation: Legacy code calls synchronous API on main thread. Refactoring is "too risky."
Why it matters: Even if it worked before:
- Data may have grown larger
- OS updates may have changed timing
- New devices have different characteristics
- Users notice more as apps get faster
Approach: 1. Add metrics to measure current hang rate 2. Refactor incrementally with feature flags 3. A/B test to show improvement 4. Document risk of not fixing
Anti-Patterns to Avoid
| Anti-Pattern | Why It's Wrong | Instead |
|---|---|---|
DispatchQueue.main.sync from background | Can deadlock, always blocks | Use .async |
| Semaphore to convert async to sync | Blocks calling thread | Stay async with completion/await |
| File I/O on main thread | Unpredictable latency | Background queue |
| Unfiltered notification observer | Processes irrelevant events | Filter by object/name |
| Creating formatters in loops | Expensive initialization | Cache and reuse |
| Synchronous network request | Blocks on network latency | URLSession async |
Hang Prevention Checklist
Before shipping, verify:
- [ ] No
Data(contentsOf:)or file reads on main thread - [ ] No
DispatchQueue.main.syncfrom background threads - [ ] No semaphore.wait() on main thread
- [ ] Formatters (DateFormatter, NumberFormatter) are cached
- [ ] Notification observers filter appropriately
- [ ] Launch work is minimized (defer non-essential)
- [ ] Image processing happens off main thread
- [ ] Database queries don't run on main thread
- [ ] MetricKit adopted for field diagnostics
Resources
WWDC: 2021-10258, 2022-10082, 2026-268
Docs: /xcode/analyzing-responsiveness-issues-in-your-shipping-app, /metrickit/mxhangdiagnostic
Skills: axiom-performance (skills/metrickit-ref.md), axiom-performance (skills/performance-profiling.md), axiom-concurrency, axiom-build (skills/lldb.md) (interactive thread inspection at freeze point)
Related skills
FAQ
Must axiom-performance be used for Apple perf issues?
axiom-performance states developers MUST use this skill for ANY performance issue including memory leaks, slow execution, battery drain, or profiling on Apple platforms. It acts as the router to the correct Axiom playbook.
What symptoms trigger axiom-performance?
axiom-performance triggers on laggy apps, growing memory usage, quick battery drain, device heat, high energy in Battery Settings, Instruments diagnosis, retain cycles, and memory-warning crashes.
Is Axiom 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.