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
Real questions developers ask that this skill answers:
- "My app is always at the top of Battery Settings. How do I find what's draining power?"
→ The skill covers Power Profiler workflow to identify dominant subsystem and targeted fixes
- "Users report my app makes their phone hot. Where do I start debugging?"
→ The skill provides decision tree: CPU vs GPU vs Network diagnosis with specific patterns
- "I have timers and location updates. Are they causing battery drain?"
→ The skill covers timer tolerance, location accuracy trade-offs, and audit checklists
- "My app drains battery in the background even when users aren't using it."
→ The skill covers background execution patterns, BGTasks, and EMRCA principles
- "How do I measure if my optimization actually improved battery life?"
→ The skill demonstrates before/after Power Profiler comparison workflow
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)
- Connect iPhone wirelessly to Xcode (wireless debugging)
- Xcode → Product → Profile (Cmd+I)
- Select Blank template
- Click "+" → Add "Power Profiler" instrument
- Optional: Add "CPU Profiler" for correlation
- Click Record
- Use your app normally for 2-3 minutes
- Click Stop
Why 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: 5 for non-urgent updates (energy efficient)
-
Use apns-priority: 10 only 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 , LazyVGrid for 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?
-
waitsForConnectivity set to avoid failed connection attempts?
-
allowsExpensiveNetworkAccess set to false for deferrable work?
-
Push notifications instead of polling?
Location Audit
-
Using appropriate accuracy (not kCLLocationAccuracyBest unless navigation)?
-
distanceFilter set 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
-
endBackgroundTask called promptly when work completes?
-
Long operations use BGProcessingTask with requiresExternalPower ?
-
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 drain
-
kCLLocationAccuracyHundredMeters : 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:
-
Recorded trace while opening Library pane
-
CPU Power Impact lane showed massive spike
-
Time Profiler showed VideoCardView body called hundreds of times
-
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:
-
User collected trace during commute (Settings → Developer → Performance Trace)
-
Trace showed periodic CPU spikes correlating with movement
-
Time Profiler showed videoSuggestionsForLocation consuming CPU
-
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:
-
Power Profiler showed sustained background CPU activity
-
Audio session remained active after playback stopped
-
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 idle
Result: 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
- Connect device wirelessly
- Product → Profile → Blank → Add Power Profiler
- Record 2-3 minutes of usage
- Identify dominant subsystem (CPU/GPU/Network/Display)
- Apply targeted fix from patterns above
- Record again to verify improvement
Key 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
Related Skills
-
axiom-energy-ref — Complete API reference with all code examples
-
axiom-energy-diag — Symptom-based troubleshooting decision trees
-
axiom-background-processing — Background task mechanics (why tasks don't run)
-
axiom-performance-profiling — General Instruments workflows
-
axiom-memory-debugging — Memory leak diagnosis (often related to energy)
-
axiom-networking — Network optimization patterns
WWDC Sessions
-
WWDC25-226 "Profile and optimize power usage in your app" — Power Profiler workflow
-
WWDC25-227 "Finish tasks in the background" — BGContinuedProcessingTask, EMRCA
-
WWDC22-10083 "Power down: Improve battery consumption" — Dark Mode, frame rates, deferral
-
WWDC20-10095 "The Push Notifications primer" — Push vs poll
-
WWDC19-417 "Improving Battery Life and Performance" — MetricKit
Last Updated: 2025-12-26 Platforms: iOS 26+, iPadOS 26+ Status: Production-ready energy optimization patterns