Adaptive performance availability for iOS.
Headroom helps you decide whether a feature has enough device + runtime headroom to run well.
import Headroom
// “This feature is fine on an iPhone 13 or better.”
if Headroom.isAvailable(.iPhone13) {
enableRealtimeBlur()
} else {
useStaticBackground()
}Headroom is like a runtime companion to #available:
| Question | Tool |
|---|---|
| “Is this OS API available?” | #available(iOS 17, *) |
| “Can this device run this feature well right now?” | Headroom.isAvailable(.iPhone13) |
- DeviceKit-style API:
.iPhone13,.iPhone15Pro,.iPadPro11M4 - Score-based decisions: device baselines are normalized to
0...100 - Adaptive by default: Low Power Mode, thermal state, and memory pressure subtract from the score
- Simple tiers:
.low,.medium,.high,.ultraare derived from scores - Feature gates: combine device baseline, memory, storage, thermal, and Low Power Mode
- Resource readings: memory, storage, thermal state
- No startup benchmark: deterministic, lightweight, and explainable
.package(url: "https://github.com/NuPlay/Headroom.git", from: "0.3.1")Requirements:
- iOS 13+
- Swift Package Manager
- DeviceKit 5.8.0+
DeviceKit 5.8.0 currently requires iOS 13+ through SwiftPM, so Headroom follows that minimum.
Headroom ships a PrivacyInfo.xcprivacy manifest. The library does not collect data, does not track users, and does not contact tracking domains. It declares disk-space required-reason API usage with reason E174.1 because Headroom.storage and feature gates can check whether there is enough local space before user-visible work such as downloads, caches, or media processing.
Run the package tests with SwiftPM:
swift testModern iOS apps often ship one codebase across many conditions:
- older iPhones with limited memory,
- Pro devices with stronger GPU/CPU headroom,
- iPads with large memory pools,
- Low Power Mode,
- thermal pressure,
- low remaining storage.
Without Headroom, feature decisions often spread across product code:
if device == .iPhoneSE || processInfo.isLowPowerModeEnabled || thermalState == .serious {
useFallback()
} else {
runExpensiveFeature()
}Headroom turns that into a readable product decision:
if Headroom.isAvailable(.iPhone13) {
runExpensiveFeature()
} else {
useFallback()
}graph TD
A[DeviceKit Device.current] --> B[Hardware Score]
C[Machine Identifier] --> B
D[Metal Apple GPU Family] --> B
E[Physical Memory] --> B
B --> F[Effective Score]
G[Low Power Mode Penalty] --> F
H[Thermal Penalty] --> F
I[Memory Pressure Penalty] --> F
F --> J[Derived Tier]
F --> K{Feature Available?}
L[Feature Required Score] --> K
K -->|Yes| M[Run Feature]
K -->|No| N[Fallback]
Headroom separates two ideas:
| API | Meaning |
|---|---|
hardwareScore |
Baseline capability of the hardware, normalized to 0...100 |
effectiveScore |
Hardware score after runtime pressure penalties |
hardwareTier |
Coarse tier derived from hardwareScore |
effectiveTier |
Coarse tier derived from effectiveScore |
let hardwareScore = Headroom.hardwareScore
let currentScore = Headroom.effectiveScore
let hardwareTier = Headroom.hardwareTier
let currentTier = Headroom.effectiveTierExample:
// A recent Pro device can have strong hardware but reduced runtime headroom.
// hardwareScore = 84 // iPhone 15 Pro-class
// effectiveScore = 76 // e.g. Low Power Mode
// iPhone13 = 71 // reference-device requirement
// result = passRuntime pressure is applied as a score penalty, not a fixed cap. That means a recent Pro device can still satisfy an iPhone 13-class requirement under a single pressure signal, while multiple pressure signals can still push it into fallback.
Headroom's public API stays simple, but internally it compares scores.
| Score | Tier | Suggested meaning |
|---|---|---|
0...39 |
.low |
Conservative UI, avoid expensive realtime work |
40...69 |
.medium |
Default experience, lightweight effects |
70...81 |
.high |
Rich animations, heavier UI/media work |
82...100 |
.ultra |
Premium paths for recent high-end hardware |
Approximate built-in reference scores:
| Reference device | Approx. score | Tier |
|---|---|---|
iPhone 11 |
60 |
.medium |
iPhone 12 |
66 |
.medium |
iPhone 13 |
71 |
.high |
iPhone 14 Pro / iPhone 15 |
79 |
.high |
iPhone 15 Pro |
84 |
.ultra |
iPhone 16 Pro |
92 |
.ultra |
These values are heuristic, seeded from public Geekbench 6 trends and kept intentionally rounded so product logic remains understandable.
Default adaptive penalties:
| Signal | Default behavior |
|---|---|
| Low Power Mode | -8 |
Thermal .fair |
0 |
Thermal .serious |
-10 |
Thermal .critical |
cap near 25 |
Memory .constrained |
-8 |
Memory .critical |
-18 |
Why is Low Power Mode only -8 by default? Because it is not always purely bad for a UI feature: on ProMotion devices Apple also limits refresh rate to 60 Hz, which can halve the frame-rate target for animation-heavy paths. CPU/GPU-heavy features should still use allowsLowPowerMode: false or increase lowPowerModePenalty.
Headroom's default table is not a raw Geekbench score table. It is a rounded product heuristic seeded from public benchmark trends.
| Device / mode | Geekbench 6 single | Geekbench 6 multi | Geekbench 6 Metal | Headroom score |
|---|---|---|---|---|
| iPhone 13 | 2215 |
5236 |
17767 |
71 |
| iPhone 15 Pro | 2883 |
7203 |
27052 |
84 |
| iPhone 15 Pro, Low Power Mode | 1174 |
3790 |
— | runtime penalty, not a separate device score |
The Low Power Mode benchmark above is roughly 41% of normal single-core and 53% of normal multi-core for that sample. Headroom still keeps the default penalty modest because Apple also limits ProMotion refresh rate to 60 Hz in Low Power Mode, changing the target for many UI workloads.
By default, Headroom uses adaptive availability.
Headroom.isAvailable(.iPhone13)
Headroom.isAvailable(.high)That means runtime pressure can cause fallback even on good hardware. The penalty is score-based, so an iPhone 15 Pro-class device can often still satisfy an iPhone 13-class feature under one pressure signal.
Use hardwareOnly when you only care about the device class:
Headroom.isAvailable(.iPhone13, mode: .hardwareOnly)
Headroom.isAvailable(.high, mode: .hardwareOnly)
let hardwareScore = Headroom.score(for: .hardwareOnly)
let hardwareTier = Headroom.tier(for: .hardwareOnly)flowchart LR
A[.adaptive] --> B[effectiveScore]
B --> C[Device + Low Power + Thermal + Memory]
D[.hardwareOnly] --> E[hardwareScore]
E --> F[Device only]
| Use case | API |
|---|---|
| “iPhone 13 or better, considering runtime pressure” | Headroom.isAvailable(.iPhone13) |
| “iPhone 13 or better, hardware only” | Headroom.isAvailable(.iPhone13, mode: .hardwareOnly) |
| “High tier or better, hardware only” | Headroom.isAvailable(.high, mode: .hardwareOnly) |
| Current score for a mode | Headroom.score(for: .adaptive) / Headroom.score(for: .hardwareOnly) |
| Current tier for a mode | Headroom.tier(for: .adaptive) / Headroom.tier(for: .hardwareOnly) |
| Current effective score | Headroom.effectiveScore |
| Current hardware score | Headroom.hardwareScore |
| Current effective tier | Headroom.effectiveTier |
| Current hardware tier | Headroom.hardwareTier |
| Memory pressure | Headroom.memoryPressure |
| Storage space | Headroom.storage |
| Thermal state | Headroom.thermalState |
| Detailed diagnostic snapshot | Headroom.snapshot |
| Detailed feature result | Headroom.availability(of:) |
| Evaluate a saved snapshot/resources pair | Headroom.availability(of:snapshot:resources:) |
| Reproducible feature diagnostic report | Headroom.diagnosticReport(of:) |
| Diagnostic report schema version | report.schemaVersion / HeadroomFeatureDiagnosticReport.currentSchemaVersion |
| Check decoded support artifact consistency | report.isReplayConsistent |
| Typed byte counts | HeadroomByteCount.mebibytes(300) / .gibibytes(2) |
Tiers are still useful when you do not care about exact scores:
if Headroom.isAvailable(.high) {
enableRichEffects()
}Internally, this is equivalent to checking the tier's minimum score.
Headroom.Tier.high.minimumScore // 70Tiers are intentionally coarse. Scores give Headroom smoother behavior; tiers keep product code readable.
For most code paths, this is enough:
if Headroom.isAvailable(.iPhone13) {
enableRealtimeBlur()
} else {
useStaticBackground()
}For expensive features, define a feature gate:
let realtimeBlur = HeadroomFeature(
.iPhone13,
resources: .init(
memory: .mebibytes(300),
storage: .gibibytes(2)
),
allowsLowPowerMode: false,
maximumThermalState: .fair
)
if Headroom.isAvailable(realtimeBlur) {
enableRealtimeBlur()
} else {
useStaticBackground()
}Need to debug why it failed?
let result = Headroom.availability(of: realtimeBlur)
if !result.isAvailable {
print(result.failureCodes) // ["score", "lowPowerMode", ...]
print(result.diagnosticSummary)
result.recoverySuggestions.forEach { print("• \($0)") }
}
let report = Headroom.diagnosticReport(of: realtimeBlur)
let data = try JSONEncoder().encode(report)Need deterministic tests or QA replay? Evaluate a feature against saved diagnostics without reading live device state:
let result = Headroom.availability(
of: realtimeBlur,
snapshot: savedSnapshot,
resources: savedResources
)Failure reasons can include:
- score is too low,
- Low Power Mode is enabled,
- thermal state is too high,
- available memory is too low,
- available storage is too low.
Use failureKinds, failureCodes, contains(_:), or failures(of:) when product logic or QA tooling needs stable machine-readable categories instead of display text.
HeadroomFeatureAvailability, HeadroomAvailabilityFailure, snapshots, resources, configuration, and feature definitions are Codable, so you can persist QA fixtures or send structured diagnostics to your own logging pipeline.
For support tickets or QA replay, HeadroomFeatureDiagnosticReport bundles the feature, snapshot, resources, schema version, and availability result into one Codable value. Failure JSON uses a stable tagged shape with a kind field, so support tooling can inspect failure records without parsing display strings.
When decoding a report from a log or support ticket, use report.isCurrentSchemaVersion to identify legacy artifacts and report.isReplayConsistent to confirm the embedded availability result still matches a fresh replay from the report's feature, snapshot, and resources.
Headroom also exposes lightweight resource snapshots.
let memory = Headroom.memory
memory.physicalBytes
memory.availableBytes
memory.usedBytes
memory.availableRatio
memory.usedRatioswitch Headroom.memoryPressure {
case .nominal:
break
case .constrained:
reduceCacheSize()
case .critical:
releaseNonEssentialResources()
case .unknown:
break
}let storage = Headroom.storage
storage.totalCapacityBytes
storage.availableCapacityBytes
storage.importantAvailableCapacityBytes
storage.opportunisticAvailableCapacityBytes
if storage.canFit(bytes: 500_000_000, usage: .important) {
startDownload()
}
if storage.canFit(.megabytes(500), usage: .important) {
startDownload()
}| Usage | Meaning |
|---|---|
.regular |
General available-capacity reading |
.important |
User-requested or important app work |
.opportunistic |
Cache, prefetch, optional downloads |
let state = Headroom.thermalState
state.isPerformanceConstrained
Headroom.isThermallyConstrainediOS public API exposes
ProcessInfo.ThermalState, not actual device temperature in Celsius. Headroom intentionally exposes thermal state only.
let resources = Headroom.resources
resources.memory.availableBytes
resources.memoryPressure
resources.storage.importantAvailableCapacityBytes
resources.thermalStateUse snapshot when you want the decision and the signals behind it.
let snapshot = Headroom.snapshot
snapshot.hardwareScore
snapshot.effectiveScore
snapshot.hardwareTier
snapshot.effectiveTier
snapshot.signals.deviceDescription
snapshot.signals.lowPowerModeEnabled
snapshot.signals.thermalState
snapshot.signals.memoryPressure
snapshot.signals.metalAppleGPUFamilyMost customization should stay simple: choose a DeviceKit reference device.
Headroom.isAvailable(.iPhone13)
Headroom.isAvailable(.iPhone15Pro)
Headroom.isAvailable(.iPadPro11M4)If Headroom's built-in score does not match your app's needs, override a DeviceKit case:
Headroom.configure {
$0.overrideDevice(.iPhone15Pro, as: 86)
$0.overrideDevice(.iPadPro11M4, as: 96)
}You can still override by tier if you want a coarse value:
Headroom.configure {
$0.overrideDevice(.iPhone15Pro, as: .ultra)
}Advanced policy tuning is available, but should be rare:
Headroom.configure {
$0.lowPowerModePenalty = 8
$0.fairThermalPenalty = 0
$0.seriousThermalPenalty = 12
$0.criticalThermalScore = 20
$0.memoryPressurePolicy = .init(
constrainedAvailableRatio: 0.15,
criticalAvailableRatio: 0.07,
constrainedAvailableBytes: 768 * 1_048_576,
criticalAvailableBytes: 256 * 1_048_576
)
}Debug override:
#if DEBUG
Headroom.configure {
$0.forcedEffectiveScore = 35
}
#endifReset:
Headroom.resetConfiguration()Headroom uses a layered strategy:
- DeviceKit device identity and CPU information.
- Machine identifier fallback for unknown devices.
- Metal Apple GPU family fallback for newer devices.
- Physical memory as a fallback signal.
- Runtime penalties: Low Power Mode, thermal state, memory pressure.
- Optional app overrides.
Headroom does not run synthetic benchmarks at startup. Benchmarks can be noisy, slow, battery-intensive, and affected by the thermal conditions they are trying to measure. The built-in table is deterministic and rounded; tune it for your app if you have better local data.
- Headroom does not replace
#available. You still need OS availability checks for OS APIs. - Headroom does not expose actual iPhone temperature in Celsius because public iOS API does not provide it.
- Scores and tiers are heuristics and should be calibrated to your app's feature set.
- Built-in mapping is intentionally conservative and rounded; use overrides when your app needs stricter or looser behavior.
Headroom is available under the MIT license. See LICENSE.