---
name: axiom-display-performance
description: Use when app runs at unexpected frame rate, stuck at 60fps on ProMotion, frame pacing issues, or configuring render loops. Covers MTKView, CADisplayLink, CAMetalDisplayLink, frame pacing, hitches, system caps.
license: MIT
metadata:
version: "1.0.0"
---
# Display Performance
Systematic diagnosis for frame rate issues on variable refresh rate displays (ProMotion, iPad Pro, future devices). Covers render loop configuration, frame pacing, hitch mechanics, and production telemetry.
**Key insight**: "ProMotion available" does NOT mean your app automatically runs at 120Hz. You must configure it correctly, account for system caps, and ensure proper frame pacing.
---
## Part 1: Why You're Stuck at 60fps
### Diagnostic Order
Check these in order when stuck at 60fps on ProMotion:
1. **Info.plist key missing?** (iPhone only) → Part 2
2. **Render loop configured for 60?** (MTKView defaults, CADisplayLink) → Part 3
3. **System caps enabled?** (Low Power Mode, Limit Frame Rate, Thermal) → Part 5
4. **Frame time > 8.33ms?** (Can't sustain 120fps) → Part 6
5. **Frame pacing issues?** (Micro-stuttering despite good FPS) → Part 7
6. **Measuring wrong thing?** (UIScreen vs actual presentation) → Part 9
---
## Part 2: Enabling ProMotion on iPhone
**Critical**: Core Animation won't access frame rates above 60Hz on iPhone unless you add this key.
```xml
CADisableMinimumFrameDurationOnPhone
```
Without this key:
- Your `preferredFrameRateRange` hints are ignored above 60Hz
- Other animations may affect your CADisplayLink callback rate
- iPad Pro does NOT require this key
**When to add**: Any iPhone app that needs >60Hz for games, animations, or smooth scrolling.
---
## Part 3: Render Loop Configuration
### MTKView Defaults to 60fps
**This is the most common cause.** MTKView's `preferredFramesPerSecond` defaults to 60.
```swift
// ❌ WRONG: Implicit 60fps (default)
let mtkView = MTKView(frame: frame, device: device)
mtkView.delegate = self
// Running at 60fps even on ProMotion!
// ✅ CORRECT: Explicit 120fps request
let mtkView = MTKView(frame: frame, device: device)
mtkView.preferredFramesPerSecond = 120
mtkView.isPaused = false
mtkView.enableSetNeedsDisplay = false // Continuous, not on-demand
mtkView.delegate = self
```
**Critical settings for continuous high-rate rendering:**
| Property | Value | Why |
|----------|-------|-----|
| `preferredFramesPerSecond` | `120` | Request max rate |
| `isPaused` | `false` | Don't pause the render loop |
| `enableSetNeedsDisplay` | `false` | Continuous mode, not on-demand |
### CADisplayLink Configuration (iOS 15+)
Apple explicitly recommends CADisplayLink (not timers) for custom render loops.
```swift
// ❌ WRONG: Timer-based render loop (drifts, wastes frame time)
Timer.scheduledTimer(withTimeInterval: 1.0/120.0, repeats: true) { _ in
self.render()
}
// ❌ WRONG: Default CADisplayLink (may hint 60)
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.add(to: .main, forMode: .common)
// ✅ CORRECT: Explicit frame rate range
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 80, // Minimum acceptable
maximum: 120, // Preferred maximum
preferred: 120 // What you want
)
displayLink.add(to: .main, forMode: .common)
```
**Special priority for games**: iOS 15+ gives 30Hz and 60Hz special priority. If targeting these rates:
```swift
// 30Hz and 60Hz get priority scheduling
let prioritizedRange = CAFrameRateRange(
minimum: 30,
maximum: 60,
preferred: 60
)
displayLink.preferredFrameRateRange = prioritizedRange
```
### Suggested Frame Rates by Content Type
| Content Type | Suggested Rate | Notes |
|--------------|----------------|-------|
| Video playback | 24-30 Hz | Match content frame rate |
| Scrolling UI | 60-120 Hz | Higher = smoother |
| Fast games | 60-120 Hz | Match rendering capability |
| Slow animations | 30-60 Hz | Save power |
| Static content | 10-24 Hz | Minimal updates needed |
---
## Part 4: CAMetalDisplayLink (iOS 17+)
For Metal apps needing precise timing control, `CAMetalDisplayLink` provides more control than CADisplayLink.
```swift
class MetalRenderer: NSObject, CAMetalDisplayLinkDelegate {
var displayLink: CAMetalDisplayLink?
var metalLayer: CAMetalLayer!
func setupDisplayLink() {
displayLink = CAMetalDisplayLink(metalLayer: metalLayer)
displayLink?.delegate = self
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 60,
maximum: 120,
preferred: 120
)
// Control render latency (in frames)
displayLink?.preferredFrameLatency = 2
displayLink?.add(to: .main, forMode: .common)
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
// update.drawable - The drawable to render to
// update.targetTimestamp - Deadline to finish rendering
// update.targetPresentationTimestamp - When frame will display
guard let drawable = update.drawable else { return }
let workingTime = update.targetTimestamp - CACurrentMediaTime()
// workingTime = seconds available before deadline
// Render to drawable...
renderFrame(to: drawable)
}
}
```
**Key differences from CADisplayLink:**
| Feature | CADisplayLink | CAMetalDisplayLink |
|---------|---------------|-------------------|
| Drawable access | Manual via layer | Provided in callback |
| Latency control | None | `preferredFrameLatency` |
| Target timing | timestamp/targetTimestamp | + targetPresentationTimestamp |
| Use case | General animation | Metal-specific rendering |
**When to use CAMetalDisplayLink:**
- Need precise control over render timing window
- Want to minimize input latency
- Building games or intensive Metal apps
- iOS 17+ only deployment
---
## Part 5: System Caps
System states can force 60fps even when your code requests 120:
### Low Power Mode
**Caps ProMotion devices to 60fps.**
```swift
// Check programmatically
if ProcessInfo.processInfo.isLowPowerModeEnabled {
// System caps display to 60Hz
}
// Observe changes
NotificationCenter.default.addObserver(
forName: .NSProcessInfoPowerStateDidChange,
object: nil,
queue: .main
) { _ in
let isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
self.adjustRenderingForPowerState(isLowPower)
}
```
### Limit Frame Rate (Accessibility)
**Settings → Accessibility → Motion → Limit Frame Rate** caps to 60fps.
No API to detect. If user reports 60fps despite configuration, have them check this setting.
### Thermal Throttling
System restricts 120Hz when device overheats.
```swift
// Check thermal state
switch ProcessInfo.processInfo.thermalState {
case .nominal, .fair:
preferredFramesPerSecond = 120
case .serious, .critical:
preferredFramesPerSecond = 60 // Reduce proactively
@unknown default:
break
}
// Observe thermal changes
NotificationCenter.default.addObserver(
forName: ProcessInfo.thermalStateDidChangeNotification,
object: nil,
queue: .main
) { _ in
self.adjustForThermalState()
}
```
### Adaptive Power (iOS 26+, iPhone 17)
**New in iOS 26**: Adaptive Power is ON by default on iPhone 17/17 Pro. Can throttle even at 60% battery.
**User action for testing**: Settings → Battery → Power Mode → disable **Adaptive Power**.
No public API to detect Adaptive Power state.
---
## Part 6: Performance Budget
### Frame Time Budgets
| Target FPS | Frame Budget | Vsync Interval |
|------------|--------------|----------------|
| 120 | 8.33ms | Every vsync |
| 90 | 11.11ms | — |
| 60 | 16.67ms | Every 2nd vsync |
| 30 | 33.33ms | Every 4th vsync |
**If you consistently exceed budget, system drops to next sustainable rate.**
### Measuring GPU Frame Time
```swift
func draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer() else { return }
// Your rendering code...
commandBuffer.addCompletedHandler { buffer in
let gpuTime = buffer.gpuEndTime - buffer.gpuStartTime
let gpuMs = gpuTime * 1000
if gpuMs > 8.33 {
print("⚠️ GPU: \(String(format: "%.2f", gpuMs))ms exceeds 120Hz budget")
}
}
commandBuffer.commit()
}
```
### Can't Sustain 120? Target Lower Rate Evenly
**Critical**: Uneven frame pacing looks worse than consistent lower rate.
```swift
// If you can't sustain 8.33ms, explicitly target 60 for smooth cadence
if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
mtkView.preferredFramesPerSecond = 60
}
```
---
## Part 7: Frame Pacing
### The Micro-Stuttering Problem
Even with good average FPS, inconsistent frame timing causes visible jitter.
```
// BAD: Inconsistent intervals despite ~40 FPS average
Frame 1: 25ms
Frame 2: 40ms ← stutter
Frame 3: 25ms
Frame 4: 40ms ← stutter
// GOOD: Consistent intervals at 30 FPS
Frame 1: 33ms
Frame 2: 33ms
Frame 3: 33ms
Frame 4: 33ms
```
**Presenting immediately after rendering causes this.** Use explicit timing control.
### Frame Pacing APIs
#### present(afterMinimumDuration:) — Recommended
Ensures consistent spacing between frames:
```swift
func draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let drawable = view.currentDrawable else { return }
// Render to drawable...
// Present with minimum 33ms between frames (30 FPS target)
commandBuffer.present(drawable, afterMinimumDuration: 0.033)
commandBuffer.commit()
}
```
#### present(at:) — Precise Timing
Schedule presentation at specific time:
```swift
// Present at specific Mach absolute time
let presentTime = CACurrentMediaTime() + 0.033
commandBuffer.present(drawable, atTime: presentTime)
```
#### presentedTime — Verify Actual Presentation
Check when frames actually appeared:
```swift
drawable.addPresentedHandler { drawable in
let actualTime = drawable.presentedTime
if actualTime == 0.0 {
// Frame was dropped!
print("⚠️ Frame dropped")
} else {
print("Frame presented at: \(actualTime)")
}
}
```
### Frame Pacing Pattern
```swift
class SmoothRenderer: NSObject, MTKViewDelegate {
private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0 // 60 FPS target
func draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let drawable = view.currentDrawable else { return }
renderScene(to: drawable)
// Use frame pacing to ensure consistent intervals
commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
commandBuffer.commit()
}
func adjustTargetFrameRate(canSustain fps: Int) {
switch fps {
case 90...:
targetFrameDuration = 1.0 / 120.0
case 50...:
targetFrameDuration = 1.0 / 60.0
default:
targetFrameDuration = 1.0 / 30.0
}
}
}
```
---
## Part 8: Understanding Hitches
### Render Loop Phases
Frame lifecycle: **Begin Time → Commit Deadline → Presentation Time**
1. **App Process (CPU)**: Handle events, compute UI updates, Core Animation commit
2. **Render Server (CPU+GPU)**: Transform UI to bitmap, render to buffer
3. **Display Driver**: Swap buffer to screen at vsync
At 120Hz, each phase has ~8.33ms. Miss any deadline = hitch.
### Commit Hitch vs Render Hitch
**Commit Hitch**: App process misses commit deadline
- Cause: Main thread work takes too long
- Fix: Move work off main thread, reduce view complexity
**Render Hitch**: Render server misses presentation deadline
- Cause: GPU work too complex (blur, shadows, layers)
- Fix: Simplify visual effects, reduce overdraw
### Double vs Triple Buffering
**Double Buffer (default)**:
- Frame lifetime: 2 vsync intervals
- Tighter deadlines
- Lower latency
**Triple Buffer (system may enable)**:
- Frame lifetime: 3 vsync intervals
- Render server gets 2 vsync intervals
- Higher latency but more headroom
The system automatically switches to triple buffering to recover from render hitches.
### Hitch Duration
```
Expected Frame Lifetime = Begin Time → Presentation Time
Actual Frame Lifetime = Begin Time → Actual Vsync
Hitch Duration = Actual - Expected
```
If hitch duration > 0, the frame was late and previous frame stayed onscreen longer.
---
## Part 9: Measurement
### UIScreen Lies, Actual Presentation Tells Truth
```swift
// ❌ This says 120 even when system caps you to 60
let maxFPS = UIScreen.main.maximumFramesPerSecond
// Reports capability, not actual rate!
// ✅ Measure from CADisplayLink timing
@objc func displayLinkCallback(_ link: CADisplayLink) {
// Time available to prepare next frame
let workingTime = link.targetTimestamp - CACurrentMediaTime()
// Actual interval since last callback
if lastTimestamp > 0 {
let interval = link.timestamp - lastTimestamp
let actualFPS = 1.0 / interval
}
lastTimestamp = link.timestamp
}
```
### Metal Performance HUD
Enable on-device real-time performance overlay:
**Via Xcode scheme:**
1. Edit Scheme → Run → Diagnostics
2. Enable "Show Graphics Overview"
3. Optionally enable "Log Graphics Overview"
**Via environment variable:**
```bash
MTL_HUD_ENABLED=1
```
**Via device settings:**
Settings → Developer → Graphics HUD → Show Graphics HUD
**HUD shows:**
- FPS (average)
- GPU time per frame
- Frame interval chart (last 120 frames)
- Memory usage
### Production Telemetry with MetricKit
Monitor hitches in production:
```swift
import MetricKit
class MetricsManager: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
if let animationMetrics = payload.animationMetrics {
// Ratio of time spent hitching during scroll
let scrollHitchRatio = animationMetrics.scrollHitchTimeRatio
// Ratio of time spent hitching in all animations
if #available(iOS 17.0, *) {
let hitchRatio = animationMetrics.hitchTimeRatio
}
analyzeHitchMetrics(scrollHitchRatio: scrollHitchRatio)
}
}
}
}
// Register for metrics
MXMetricManager.shared.add(metricsManager)
```
**What to track:**
- `scrollHitchTimeRatio`: Time spent hitching while scrolling (UIScrollView only)
- `hitchTimeRatio` (iOS 17+): Time spent hitching in all tracked animations
---
## Part 10: Quick Diagnostic Checklist
When debugging frame rate issues:
| Step | Check | Fix |
|------|-------|-----|
| 1 | Info.plist key present? (iPhone) | Add `CADisableMinimumFrameDurationOnPhone` |
| 2 | Limit Frame Rate off? | Settings → Accessibility → Motion |
| 3 | Low Power Mode off? | Settings → Battery |
| 4 | Adaptive Power off? (iPhone 17+) | Settings → Battery → Power Mode |
| 5 | preferredFramesPerSecond = 120? | Set explicitly on MTKView |
| 6 | preferredFrameRateRange set? | Configure on CADisplayLink |
| 7 | GPU frame time < 8.33ms? | Profile with Metal HUD or Instruments |
| 8 | Frame pacing consistent? | Use present(afterMinimumDuration:) |
| 9 | Hitches in production? | Monitor with MetricKit |
---
## Part 11: Common Patterns
### Pattern: Adaptive Frame Rate with Thermal Awareness
```swift
class AdaptiveRenderer: NSObject, MTKViewDelegate {
private var recentFrameTimes: [Double] = []
private let sampleCount = 30
private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0
func draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let drawable = view.currentDrawable else { return }
let startTime = CACurrentMediaTime()
renderScene(to: drawable)
let frameTime = (CACurrentMediaTime() - startTime) * 1000
updateTargetRate(frameTime: frameTime, view: view)
commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
commandBuffer.commit()
}
private func updateTargetRate(frameTime: Double, view: MTKView) {
recentFrameTimes.append(frameTime)
if recentFrameTimes.count > sampleCount {
recentFrameTimes.removeFirst()
}
let avgFrameTime = recentFrameTimes.reduce(0, +) / Double(recentFrameTimes.count)
let thermal = ProcessInfo.processInfo.thermalState
let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
// Constrain based on what we can sustain AND system state
if lowPower || thermal >= .serious {
view.preferredFramesPerSecond = 30
targetFrameDuration = 1.0 / 30.0
} else if avgFrameTime < 7.0 && thermal == .nominal {
view.preferredFramesPerSecond = 120
targetFrameDuration = 1.0 / 120.0
} else if avgFrameTime < 14.0 {
view.preferredFramesPerSecond = 60
targetFrameDuration = 1.0 / 60.0
} else {
view.preferredFramesPerSecond = 30
targetFrameDuration = 1.0 / 30.0
}
}
}
```
### Pattern: Frame Drop Detection
```swift
class FrameDropMonitor {
private var expectedPresentTime: CFTimeInterval = 0
private var dropCount = 0
func trackFrame(drawable: MTLDrawable, expectedInterval: CFTimeInterval) {
drawable.addPresentedHandler { [weak self] drawable in
guard let self = self else { return }
if drawable.presentedTime == 0.0 {
self.dropCount += 1
print("⚠️ Frame dropped (total: \(self.dropCount))")
} else if self.expectedPresentTime > 0 {
let actualInterval = drawable.presentedTime - self.expectedPresentTime
let variance = abs(actualInterval - expectedInterval)
if variance > expectedInterval * 0.5 {
print("⚠️ Frame timing variance: \(variance * 1000)ms")
}
}
self.expectedPresentTime = drawable.presentedTime
}
}
}
```
---
## Resources
**WWDC**: 2021-10147, 2018-612, 2022-10083, 2023-10123
**Tech Talks**: 10855, 10856, 10857 (Hitch deep dives)
**Docs**: /quartzcore/cadisplaylink, /quartzcore/cametaldisplaylink, /quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays, /xcode/understanding-hitches-in-your-app, /metal/mtldrawable/present(afterminimumduration:), /metrickit/mxanimationmetric
**Skills**: axiom-energy, axiom-ios-graphics, axiom-metal-migration-ref, axiom-performance-profiling