← Back to Blog

Building for 120Hz Displays: Mastering Refresh Rates in Flutter

January 15, 202610 Minutes Read
flutterperformance120hzrefresh ratehigh refresh rateanimationmobile developmentapp developmentoptimizationiosandroid

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).devicePixelRatio and window.devicePixelRatio, enable high refresh rate with WidgetsFlutterBinding.ensureInitialized() configuration, optimize animations with AnimationController tuned for 120fps, and test on real 120Hz devices. Use flutter run --profile to 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