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:
- Info.plist key missing? (iPhone only) β Part 2
- Render loop configured for 60? (MTKView defaults, CADisplayLink) β Part 3
- System caps enabled? (Low Power Mode, Limit Frame Rate, Thermal) β Part 5
- Frame time > 8.33ms? (Can't sustain 120fps) β Part 6
- Frame pacing issues? (Micro-stuttering despite good FPS) β Part 7
- 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.
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
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.
let mtkView = MTKView(frame: frame, device: device)
mtkView.delegate = self
let mtkView = MTKView(frame: frame, device: device)
mtkView.preferredFramesPerSecond = 120
mtkView.isPaused = false
mtkView.enableSetNeedsDisplay = false
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.
Timer.scheduledTimer(withTimeInterval: 1.0/120.0, repeats: true) { _ in
self.render()
}
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.add(to: .main, forMode: .common)
let displayLink = CADisplayLink(target: self, selector: #selector(render))
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 80,
maximum: 120,
preferred: 120
)
displayLink.add(to: .main, forMode: .common)
Special priority for games: iOS 15+ gives 30Hz and 60Hz special priority. If targeting these rates:
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.
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
)
displayLink?.preferredFrameLatency = 2
displayLink?.add(to: .main, forMode: .common)
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
guard let drawable = update.drawable else { return }
let workingTime = update.targetTimestamp - CACurrentMediaTime()
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.
if ProcessInfo.processInfo.isLowPowerModeEnabled {
}
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.
switch ProcessInfo.processInfo.thermalState {
case .nominal, .fair:
preferredFramesPerSecond = 120
case .serious, .critical:
preferredFramesPerSecond = 60
@unknown default:
break
}
NotificationCenter.default.addObserver(
forName: ProcessInfo.thermalStateDidChangeNotification,
object: nil,
queue: .main
) { _