refresh_rate
Request high refresh rates in one line of Flutter — with live diagnostics, benchmark sessions, and an overlay HUD.
Updated , by A Few Good Coders
Published 13 sectionsFlutter Package
- pub points
- 160/160
- likes
- 21
- downloads, 30 days
- 3,071
- latest version
- 2.0.1
pub.dev, as of Verify on pub.dev (opens in a new tab)BSD-3-Clause licence (opens in a new tab)
Start here: request a rate, measure the result
Your first working integration
- Install and rebuild
- Run the minimal app
- Check the reported display signals
- Measure a repeatable workload
- Diagnose unexpected results
Already using this in production? Start with the troubleshooting and migration or release-checklist sections below. Work in a development project first, and keep the reviewed package version visible when comparing these examples with your lockfile.
This guide targets refresh_rate 2.0.1, checked against the published package and repository on 13 October 2026. Start with installation and the minimal app, then add scoped preferences and measurement only where you need them.
The package has four jobs: submit Android refresh preferences, inspect display signals, show a diagnostic overlay, and collect Flutter frame timing sessions. It does not make slow rendering faster by itself. A request accepted by Android is not proof that the panel—or Flutter—is producing 120 frames per second.
Choose your path: use enable() for an app-wide high-rate preference; RefreshRateScope for a visible route; a lease for explicitly owned work; or diagnostics alone on platforms without control.
1. Install and rebuild
Use Flutter 3.24+ and Dart 3.5+. Android support starts at API 23. Run these commands from the Flutter app directory. Commit your application's lockfile so the team measures the same resolved version.
After adding a native plugin, stop the app and rebuild it. Hot reload does not register new native platform code. No Firebase account, API key, or server is required.
If dependency resolution fails, inspect flutter --version and the solver's SDK constraints before adding overrides. Dependencies can impose additional requirements beyond a package's own minimum.
flutter --version
flutter pub add refresh_rate:^2.0.1
flutter pub get
flutter run2. Run a complete minimal app
Replace lib/main.dart in a scratch app with this example. The request happens after the first frame so Flutter has attached a view. Keep your normal application bootstrap when integrating into an existing product.
Inspect the returned status. submitted means the selected backend accepted the preference, not that the requested refresh rate was reached. unsupported is expected for control on Apple, desktop, and web targets; it does not mean their displays lack high-refresh hardware.
import 'package:flutter/material.dart';
import 'package:refresh_rate/refresh_rate.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MaterialApp(
home: Scaffold(body: Center(child: Text('Refresh-rate check'))),
));
WidgetsBinding.instance.addPostFrameCallback((_) async {
final result = await RefreshRate.enable();
debugPrint('Request: ${result.status.name}; backend: ${result.backend}');
final info = await RefreshRate.refresh();
debugPrint('Reported display Hz: ${info.nativeReportedDisplayHz}');
});
}What each platform can actually do
RefreshRate.capabilities() describes operation support at runtime. unavailable can mean the native target is not ready; failed is different from unsupported. A content-rate vote targets the Flutter surface, not a video plugin's private playback surface. Widget scopes arbitrate ownership on a shared surface; they do not create independent refresh rates for separate widgets.
3. Own preferences and release them
enable() and preferMax() request the high-rate preference. preferDefault() releases the manual preference; separately owned leases may still be active. Do not repeatedly request rates inside build().
For a route, prefer the scope below: it acquires/releases its lease as visibility and lifecycle change. If custom tab navigation keeps widgets mounted and offstage, pass active: false for hidden content. For explicit jobs, acquire a lease once and release it in finally or the owner's teardown. Higher priority wins; newer requests break ties. Nested scopes add their depth to their priority.
// Inside a route's build method:
return RefreshRateScope(
preference: const RatePreference.high(),
child: Scaffold(
appBar: AppBar(title: const Text('Smooth scrolling')),
body: ListView.builder(
itemCount: 200,
itemBuilder: (_, index) => ListTile(title: Text('Item $index')),
),
),
);
// For explicitly owned work instead:
// final lease = RefreshRate.request(
// const RatePreference.high(), owner: 'preview', priority: 100);
// await lease.release(); // when that work finishesiOS: configure ProMotion without promising a rate
In ios/Runner/Info.plist, place this key/value inside the root dict, then perform a full rebuild. It allows eligible iPhone configurations to use high-refresh timing; it is not a guarantee of a fixed 120 Hz rate. Device support, power state, and the Flutter engine still matter.
Use isProMotionConfigured to inspect the configuration signal and supportsHighRefreshRate for the capability signal. They are different questions. The package does not use process-wide display-link swizzling in v2, and does not claim to control Flutter's engine scheduling on iOS or macOS.
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>Read trustworthy diagnostics
RefreshRate.info is a synchronous cached snapshot; call refresh() before making a decision that requires fresh data. Prefer nullable observation fields: missing evidence is not 60 Hz, and an unknown power state is not confirmed normal power.
nativeReportedDisplayHz is an OS report, displayModeMaxHz is reported capability, and nativeCallbackCadenceHz is observer cadence (for example on web). None is measured physical Flutter presentation FPS. diagnostics() keeps these sources separate; doctor() checks configuration. An explicitly requested native cadence observer adds workload and is not a Flutter presentation measurement.
Cancel onChanged subscriptions when their owner is disposed. Avoid logging device/environment details to production telemetry without a retention and consent policy.
final info = await RefreshRate.refresh();
debugPrint('Display: ${info.nativeReportedDisplayHz ?? 'unavailable'}');
debugPrint('Maximum mode: ${info.displayModeMaxHz ?? 'unavailable'}');
debugPrint('Low power: ${info.isLowPowerMode ?? 'unknown'}');
debugPrint('Stale snapshot: ${info.isStale}');
final diagnostics = await RefreshRate.diagnostics();
debugPrint('Presented FPS: ${diagnostics.presentedFps}');
final subscription = RefreshRate.onChanged.listen((next) {
debugPrint('Reported display Hz: ${next.nativeReportedDisplayHz}');
});
// In the owning service's teardown:
await subscription.cancel();Use the overlay during development
Show the overlay after the app is mounted. Import package:flutter/foundation.dart for kDebugMode if it is not already exported by your UI imports. Pass an expected rate only when the scenario really demands continuous rendering at that rate.
showFPS() shows frame cadence; showHz() shows the display signal; showOverlay() shows the combined diagnostic panel. These are debugging aids, not hardware presentation probes. Turn the overlay off for controlled benchmark comparisons because drawing it changes the workload.
if (kDebugMode) {
RefreshRate.showOverlay(expectedFps: 120);
}
// When leaving the diagnostic screen:
RefreshRate.hideOverlay();Measure a repeatable workload
Run on a physical device in profile or release mode, using the same device settings, app build, workload duration, and initial thermal conditions for each comparison. First warm up the screen; then record an actual continuously animated or scrolling interval. Do not benchmark an idle screen as though it should produce 120 FPS.
Pause/resume intentionally excluded idle portions. Power and thermal changes are recorded as segments rather than discarded to improve the score. Ending a session waits for batched timing delivery (up to 1.1 seconds by default). Missing boundary evidence remains visible in the report.
The example is an integration fragment: place startSession at your scenario's start and end after the interaction has completed, not immediately on the next line.
final session = RefreshRate.startSession('feed_scroll', expectedFps: 120);
session.setTag('screen', 'feed');
session.mark('scroll_start');
// Perform your repeatable scrolling workload here.
// session.pause(); ... excluded idle ... session.resume();
final report = await session.end();
debugPrint('Flutter cadence: ${report.flutterFrameCadenceFps}');
debugPrint('Raster p99: ${report.percentilesMs['rasterP99']}');
debugPrint('Phase overruns: ${report.phaseOverrunPercent}');
debugPrint('Complete boundaries: ${report.boundaryCoverageComplete}');
final json = report.toJson();Interpret reports and CI gates
A phase overrun is not a measured missed physical presentation. The legacy missedFramePercent alias refers to phase overruns; observedAvgHz is unavailable for timing-only sessions. Use the named metrics and their evidence rather than relabeling them as panel FPS.
A 1% low requires at least 100 valid intervals and a 0.1% low requires 1,000. Short runs, missing timestamps, debug mode, or incomplete coverage can make a verdict inconclusive. Histograms approximate quantiles; do not present tiny percentile differences as precise hardware improvements.
For CI, evaluate explicit thresholds using report.evaluate(RefreshRateThresholds(...)), inspect both failures and missingEvidence, and compare only matching device/build/scenario identities. A missing measurement must not pass a gate. Choose thresholds from your product's workload; a 120 Hz target is not appropriate for every screen.
Troubleshoot without hiding the constraint
For an issue, include package/Flutter version, device and OS, request status/backend, reproduction steps, and a redacted diagnostic report. Never replace unknown signals with invented values.
Migration from earlier versions
When upgrading to 2.x, audit assumptions about Apple control, unavailable values, and report meanings. Older code that equates enable() with an achieved 120 Hz rate is misleading. Legacy numeric fields may use zero for unavailable; prefer nullable observations. Treat report schema v2 and source-qualified metrics deliberately when importing existing dashboards.
Keep preference ownership, telemetry collection, and product performance targets separate: they answer different questions.
Source trail and further reference
Verified release: pub.dev 2.0.1 (opens in a new tab). Repository snapshot: d175183 (opens in a new tab).
Consult the public API (opens in a new tab), controller and scopes (opens in a new tab), and measurement implementation (opens in a new tab) when extending this guide. Recheck these against your resolved version before adopting newer repository APIs.