Building for 120Hz Displays: Mastering Refresh Rates in Flutter
Building for 120Hz Displays: Mastering Refresh Rates in Flutter
Your Flutter app runs smoothly at 60fps, but on the latest iPhone Pro and high-end Android devices with 120Hz displays, users expect buttery-smooth 120fps animations. Your app might be running at 60fps even on these premium devices, missing the opportunity to deliver a premium experience.
120Hz displays are becoming standard on flagship devices. Supporting high refresh rates is no longer optional for premium apps - it's expected. But Flutter apps need special configuration to take advantage of these displays.
Quick Solution: Detect display refresh rate with
MediaQuery.of(context).devicePixelRatioandwindow.devicePixelRatio, enable high refresh rate withWidgetsFlutterBinding.ensureInitialized()configuration, optimize animations withAnimationControllertuned for 120fps, and test on real 120Hz devices. Useflutter run --profileto monitor frame times.
This guide shows you how to detect, enable, and optimize for 120Hz displays in Flutter, ensuring your app delivers the premium experience users expect on high-end devices.
Understanding Refresh Rates
What Are Refresh Rates?
Refresh rate (measured in Hz) is how many times per second a display can update its image:
- 60Hz: 60 frames per second (16.67ms per frame)
- 120Hz: 120 frames per second (8.33ms per frame)
- ProMotion (iOS): Variable refresh rate (24Hz-120Hz)
Why 120Hz Matters
Benefits:
- Smoother animations - Less motion blur
- Better responsiveness - Lower input latency
- Premium feel - Perceived quality improvement
- Competitive advantage - Users notice the difference
Challenges:
- Higher CPU/GPU usage - More frames to render
- Battery drain - More work = more power
- Performance optimization - Need to hit 8.33ms frame time
- Device support - Not all devices support 120Hz
Detecting High Refresh Rate Displays
Method 1: Using MediaQuery
class RefreshRateDetector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final refreshRate = mediaQuery.refreshRate;
debugPrint('Detected refresh rate: ${refreshRate}Hz');
return Scaffold(
body: Center(
child: Text('Refresh Rate: ${refreshRate.toStringAsFixed(0)}Hz'),
),
);
}
}
Method 2: Using Platform Channel (Android)
import 'package:flutter/services.dart';
class RefreshRateService {
static const MethodChannel _channel = MethodChannel('refresh_rate');
static Future<double?> getRefreshRate() async {
if (!Platform.isAndroid) return null;
try {
final rate = await _channel.invokeMethod<double>('getRefreshRate');
return rate;
} catch (e) {
debugPrint('Error getting refresh rate: $e');
return null;
}
}
}
Android Native Code (android/app/src/main/kotlin/.../MainActivity.kt):
class MainActivity: FlutterActivity() {
private val CHANNEL = "refresh_rate"
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "getRefreshRate") {
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val display = windowManager.defaultDisplay
val refreshRate = display.refreshRate
result.success(refreshRate.toDouble())
} else {
result.notImplemented()
}
}
}
}
Method 3: Using UIApplication (iOS)
import 'package:flutter/services.dart';
class RefreshRateService {
static const MethodChannel _channel = MethodChannel('refresh_rate');
static Future<double?> getRefreshRate() async {
if (!Platform.isIOS) return null;
try {
final rate = await _channel.invokeMethod<double>('getRefreshRate');
return rate;
} catch (e) {
debugPrint('Error getting refresh rate: $e');
return null;
}
}
}
iOS Native Code (ios/Runner/AppDelegate.swift):
import Flutter
import UIKit
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller = window?.rootViewController as! FlutterViewController
let refreshRateChannel = FlutterMethodChannel(
name: "refresh_rate",
binaryMessenger: controller.binaryMessenger
)
refreshRateChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
if call.method == "getRefreshRate" {
let refreshRate = UIScreen.main.maximumFramesPerSecond
result(refreshRate)
} else {
result(FlutterMethodNotImplemented)
}
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
Complete Detection Helper
class RefreshRateHelper {
static Future<double> getRefreshRate(BuildContext? context) async {
// Try MediaQuery first (Flutter 3.10+)
if (context != null) {
try {
final mediaQuery = MediaQuery.of(context);
final refreshRate = mediaQuery.refreshRate;
if (refreshRate > 60) {
return refreshRate;
}
} catch (e) {
debugPrint('MediaQuery refresh rate not available: $e');
}
}
// Fall back to platform channel
final platformRate = await RefreshRateService.getRefreshRate();
if (platformRate != null && platformRate > 60) {
return platformRate;
}
// Default to 60Hz
return 60.0;
}
static bool isHighRefreshRate(double refreshRate) {
return refreshRate > 60.0;
}
static bool isProMotion(double refreshRate) {
// ProMotion supports variable refresh rate (24-120Hz)
return refreshRate >= 120.0;
}
}
Enabling High Refresh Rate in Flutter
Default Behavior
By default, Flutter runs at 60fps. To enable 120fps support:
Method 1: Configure in main.dart
import 'package:flutter/scheduler.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Enable high refresh rate if available
// This is handled automatically in Flutter 3.10+
// But you can configure frame callbacks
runApp(MyApp());
}
Method 2: Request High Refresh Rate (iOS)
Flutter automatically requests high refresh rate on iOS 13+ when using Metal (Impeller). Ensure you're using the Metal renderer:
# Build with Metal renderer (default with Impeller)
flutter build ios --release
Method 3: Request High Refresh Rate (Android)
For Android, you need to configure the activity:
AndroidManifest.xml:
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.EnableHighRefreshRate"
android:value="true" />
</activity>
MainActivity.kt:
import android.view.WindowManager
class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Enable high refresh rate
window?.let {
val params = it.attributes
params.preferredRefreshRate = 120f // Request 120Hz
it.attributes = params
}
}
}
Optimizing Animations for 120fps
AnimationController Configuration
class HighRefreshRateAnimation extends StatefulWidget {
@override
_HighRefreshRateAnimationState createState() => _HighRefreshRateAnimationState();
}
class _HighRefreshRateAnimationState extends State<HighRefreshRateAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double _refreshRate = 60.0;
@override
void initState() {
super.initState();
_detectRefreshRate();
}
Future<void> _detectRefreshRate() async {
final rate = await RefreshRateHelper.getRefreshRate(context);
setState(() {
_refreshRate = rate;
});
// Configure animation based on refresh rate
_setupAnimation();
}
void _setupAnimation() {
// For 120Hz, animations should feel smoother
// But duration can remain the same (user perception)
_controller = AnimationController(
duration: Duration(milliseconds: 300),
vsync: this,
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
_controller.repeat(); // Or forward() for one-time
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: _animation.value * 2 * pi,
child: Container(
width: 100,
height: 100,
color: Colors.blue,
),
);
},
);
}
}
Optimized Animation Duration
class AdaptiveAnimationController {
static Duration getOptimalDuration(double refreshRate) {
// At 120Hz, animations can be slightly faster for same perception
// But generally, keep durations consistent for UX consistency
if (refreshRate >= 120) {
// Slight optimization for high refresh rate
return Duration(milliseconds: 250); // Slightly faster
}
return Duration(milliseconds: 300); // Standard
}
static Curve getOptimalCurve(double refreshRate) {
// Use smoother curves for high refresh rate
if (refreshRate >= 120) {
return Curves.easeOutCubic; // Smoother curve
}
return Curves.easeInOut;
}
}
Using Tween Animation
class SmoothAnimation extends StatefulWidget {
@override
_SmoothAnimationState createState() => _SmoothAnimationState();
}
class _SmoothAnimationState extends State<SmoothAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 300),
vsync: this,
);
// Multiple animations for smooth transitions
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_slideAnimation = Tween<Offset>(
begin: Offset(0, 0.1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: YourContent(),
),
);
}
}
Performance Optimization for 120fps
Frame Time Target
At 120Hz, you need to render frames in 8.33ms or less:
- 60Hz: 16.67ms per frame
- 120Hz: 8.33ms per frame (half the time!)
Optimization Strategies
1. Minimize Widget Rebuilds
// ❌ Bad - Rebuilds entire tree
setState(() {
_counter++;
});
// ✅ Good - Only rebuilds what changed
ValueNotifier<int> counter = ValueNotifier(0);
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) {
return Text('$value');
},
)
2. Use const Constructors
// ✅ Good - No rebuild
const Text('Hello')
// ❌ Bad - Rebuilds every frame
Text('Hello')
3. Optimize Paint Operations
// Use RepaintBoundary for expensive paints
RepaintBoundary(
child: ComplexCustomPaint(),
)
4. Lazy Load Heavy Widgets
// Defer heavy widgets until after first frame
bool _ready = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_ready = true;
});
});
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return SimplePlaceholder();
}
return HeavyWidget();
}
5. Optimize Images
// Use cached images
CachedNetworkImage(
imageUrl: url,
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
)
// Precache images
precacheImage(NetworkImage(url), context);
Testing High Refresh Rate
Enable Performance Overlay
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
showPerformanceOverlay: true, // Enable to see frame times
home: HomePage(),
);
}
}
Monitor Frame Times
import 'package:flutter/scheduler.dart';
void monitorFrameTimes() {
SchedulerBinding.instance.addTimingsCallback((timings) {
for (final timing in timings) {
final total = timing.totalSpan.inMilliseconds;
// At 120Hz, frame time should be < 8.33ms
if (total > 8.33) {
debugPrint('⚠️ Slow frame at 120Hz: ${total}ms');
debugPrint(' Build: ${timing.buildDuration.inMilliseconds}ms');
debugPrint(' Raster: ${timing.rasterDuration.inMilliseconds}ms');
}
}
});
}
Profile Mode
# Run in profile mode to see real performance
flutter run --profile
# Check frame times in DevTools
flutter pub global activate devtools
flutter pub global run devtools
Real-World Example: Adaptive Animation
class AdaptiveAnimationWidget extends StatefulWidget {
@override
_AdaptiveAnimationWidgetState createState() => _AdaptiveAnimationWidgetState();
}
class _AdaptiveAnimationWidgetState extends State<AdaptiveAnimationWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double _refreshRate = 60.0;
@override
void initState() {
super.initState();
_initializeAnimation();
}
Future<void> _initializeAnimation() async {
// Detect refresh rate
final context = this.context;
if (context != null) {
final rate = await RefreshRateHelper.getRefreshRate(context);
setState(() {
_refreshRate = rate;
});
}
// Configure animation based on refresh rate
final duration = _refreshRate >= 120
? Duration(milliseconds: 250)
: Duration(milliseconds: 300);
final curve = _refreshRate >= 120
? Curves.easeOutCubic
: Curves.easeInOut;
_controller = AnimationController(
duration: duration,
vsync: this,
);
_animation = CurvedAnimation(
parent: _controller,
curve: curve,
);
_controller.repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Refresh Rate: ${_refreshRate.toStringAsFixed(0)}Hz'),
),
body: Center(
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: 1.0 + (_animation.value * 0.2),
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
);
},
),
),
);
}
}
Best Practices
1. Always Test on Real Devices
Simulators/emulators may not accurately represent 120Hz performance.
2. Monitor Frame Times
Keep frame times under 8.33ms for smooth 120fps.
3. Graceful Degradation
Your app should work well at 60Hz too - don't require 120Hz.
4. Optimize Critical Paths
Focus optimization on animations and scroll performance.
5. Use Performance Overlay
Enable performance overlay during development to catch issues early.
6. Profile Regularly
Use DevTools to profile and identify bottlenecks.
7. Consider Battery Impact
High refresh rate uses more battery - allow users to control it if possible.
8. Test Edge Cases
Test with low battery, background apps, and different device states.
Common Issues and Solutions
Issue: App Still Running at 60fps on 120Hz Device
Solution:
- Check if device actually supports 120Hz
- Verify Impeller/Metal is enabled (iOS)
- Check Android manifest configuration
- Ensure you're testing on a real device
Issue: Janky Animations at 120Hz
Solution:
- Optimize widget rebuilds
- Use RepaintBoundary for expensive paints
- Profile with DevTools to find bottlenecks
- Reduce animation complexity if needed
Issue: Battery Drain
Solution:
- Only enable high refresh rate for critical animations
- Allow users to disable if preferred
- Optimize rendering to reduce GPU usage
- Consider adaptive refresh rate (ProMotion)
Conclusion
Supporting 120Hz displays is essential for premium Flutter apps:
- Detect refresh rate using MediaQuery or platform channels
- Enable high refresh rate with proper configuration
- Optimize animations for 120fps (8.33ms frame time)
- Test on real devices to verify performance
- Monitor frame times to catch issues early
- Graceful degradation - work well at 60Hz too
With these techniques, your Flutter app will deliver the smooth, premium experience users expect on high-end devices.
Next Steps
- Test your app on a 120Hz device
- Profile animations with DevTools
- Optimize frame times to < 8.33ms
- Consider adaptive refresh rate for battery savings
Updated for Flutter 3.24+, iOS ProMotion, Android 120Hz, and 2026 best practices