You open your app, tap into a screen, and it feels fine. Your phone is fast, your Wi-Fi is fast, and the data is cached from the last ten times you tested it. Then a review comes in: “App is very slow, the reports page takes forever.”
The problem is that “it works on my phone” tells you nothing about how the app behaves for someone on a three-year-old budget Android phone with a patchy 4G connection. To fix performance, you first need to see it, and you need to see it from your real users’ devices, not just your own.
That’s what this post is about. We’ll use Firebase Performance Monitoring in a Flutter app to answer three practical questions:
- When does a page actually finish loading, including all its data and calculations?
- How long do heavy calculations take, and are they freezing the UI?
- Are animations and scrolling smooth, or are frames being dropped?
We’ll start simple, then build small reusable tools you can drop into any project.
The Basic Idea: Traces
Firebase Performance works around something called a trace. A trace is just a stopwatch with a name. You start it when something begins, stop it when it ends, and Firebase records how long it took for every user who ran that code.
On top of the duration, a trace can carry two kinds of extra data:
- Metrics are numbers, like how many items were loaded or how many milliseconds a sub-step took.
- Attributes are short text labels, like
status: loadedorpayment: upi, that let you filter results later in the Firebase Console.
Firebase also collects some things automatically, such as app startup time and general screen rendering stats. But the automatic data doesn’t know what “page fully loaded” means for your screen. That part is up to us.
Setup
If your project isn’t connected to Firebase yet, the FlutterFire CLI does most of the work:
dart pub global activate flutterfire_cli
flutterfire configure
flutter pub add firebase_core firebase_performanceThen initialize Firebase before running the app:
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Don't pollute your production data with debug-mode numbers.
await FirebasePerformance.instance
.setPerformanceCollectionEnabled(!kDebugMode);
runApp(const MyApp());
}That last line matters more than it looks. Debug builds in Flutter are intentionally slow due to JIT compilation, runtime assertions, and debugging instrumentation. Any timings you collect from them are misleading. To understand the differences between execution environments, read our guide on Flutter Build Modes Explained: Debug, Profile, and Release. Turn collection on only for profile and release builds.
Here’s how we’ll organize the code we write in this post:
lib/
performance/
page_load_tracker.dart
page_load_tracking_mixin.dart
frame_jank_tracker.dart
scroll_jank_listener.dartPart 1: Knowing When a Page Is Really Loaded
What Does “Loaded” Even Mean?
This is the question most people skip. A page isn’t loaded when you call your API. It isn’t loaded when the API returns. It isn’t even loaded when you call setState().
The Definition of "Fully Loaded": A page is loaded when the data has arrived, the calculations are done, and the resulting UI is completely painted on the screen where the user can visually perceive it.
That last part is where a lot of measurements go wrong. Calling setState() only schedules a rebuild. The actual frame gets laid out and drawn a little later. To measure the exact moment the user sees the content, we wait for the next frame to finish using WidgetsBinding.instance.addPostFrameCallback.
A Reusable Page Load Tracker
Rather than sprinkling stopwatch code into every screen, let’s write one small class that handles it:
// lib/performance/page_load_tracker.dart
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/widgets.dart';
/// Measures how long a page takes to become fully visible to the user,
/// with an optional breakdown of each loading step.
class PageLoadTracker {
PageLoadTracker(String pageName)
: _trace = FirebasePerformance.instance.newTrace('page_load_$pageName');
final Trace _trace;
final Stopwatch _totalTime = Stopwatch();
bool _started = false;
bool _finished = false;
Future<void> start() async {
if (_started) return;
_started = true;
_totalTime.start();
await _trace.start();
}
/// Runs [task] and records how long it took as `<stepName>_ms`.
Future<T> step<T>(String stepName, Future<T> Function() task) async {
final stopwatch = Stopwatch()..start();
try {
return await task();
} finally {
_trace.setMetric('${stepName}_ms', stopwatch.elapsedMilliseconds);
}
}
/// Call this right after the final setState().
/// The trace stops once the next frame has actually been drawn.
void markLoaded() {
WidgetsBinding.instance.addPostFrameCallback((_) => _finish('loaded'));
WidgetsBinding.instance.scheduleFrame();
}
Future<void> markFailed() => _finish('failed');
/// If the user leaves before loading finishes, record that too.
Future<void> markAbandonedIfPending() => _finish('abandoned');
Future<void> _finish(String status) async {
if (!_started || _finished) return;
_finished = true;
_trace.putAttribute('load_status', status);
_trace.setMetric('total_ms', _totalTime.elapsedMilliseconds);
await _trace.stop();
}
}A few details worth pointing out:
- The
_finishedflag makes sure the trace only stops once, no matter how many code paths try to end it. - The
step()helper lets you see which specific sub-operation is slow (API vs JSON parsing vs DB query), instead of just the total duration. - The
abandonedstatus is one of the most useful signals you can collect: if lots of users navigate away before loading completes, that page is too slow, full stop.
Making It Effortless with a Mixin
Starting the tracker in initState and cleaning up in dispose is the same boilerplate on every screen. A mixin handles that for us:
// lib/performance/page_load_tracking_mixin.dart
import 'package:flutter/widgets.dart';
import 'page_load_tracker.dart';
mixin PageLoadTracking<T extends StatefulWidget> on State<T> {
/// A short, fixed name like 'report' or 'product_detail'.
String get pageName;
late final PageLoadTracker pageLoad = PageLoadTracker(pageName);
@override
void initState() {
super.initState();
pageLoad.start();
}
@override
void dispose() {
pageLoad.markAbandonedIfPending();
super.dispose();
}
}Using It on a Real Page
Here’s a report screen that fetches data, crunches it, and displays the result:
import 'dart:isolate';
import 'package:flutter/material.dart';
import '../performance/page_load_tracking_mixin.dart';
class ReportPage extends StatefulWidget {
const ReportPage({super.key});
@override
State<ReportPage> createState() => _ReportPageState();
}
class _ReportPageState extends State<ReportPage> with PageLoadTracking {
@override
String get pageName => 'report';
ReportResult? _result;
bool _hasError = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final rawData = await pageLoad.step(
'api',
() => reportApi.fetchRawData(),
);
final result = await pageLoad.step(
'calculation',
() => Isolate.run(() => buildReport(rawData)),
);
if (!mounted) return;
setState(() => _result = result);
pageLoad.markLoaded();
} catch (_) {
if (!mounted) return;
setState(() => _hasError = true);
await pageLoad.markFailed();
}
}
@override
Widget build(BuildContext context) {
if (_hasError) return const Center(child: Text('Something went wrong'));
if (_result == null) return const Center(child: CircularProgressIndicator());
return ReportView(result: _result!);
}
}If your page loads several independent resources in parallel, wrap each one in a step and run them concurrently using Future.wait:
final [chart, list, profile] = await Future.wait([
pageLoad.step('chart_api', () => api.getChart()),
pageLoad.step('list_api', () => api.getList()),
pageLoad.step('profile_api', () => api.getProfile()),
]);In the Firebase Console, your page_load_report trace will now show:
total_msfor the full screen load experience,api_msandcalculation_msfor the granular breakdown, and- A
load_statusattribute you can filter by to compare successful loads against failures and abandoned visits.
Part 2: Heavy Calculations Without Frozen Screens
Why Heavy Work Freezes Flutter
Flutter executes your Dart code and builds your UI on the same single main thread (the UI Isolate). That’s normally fine, because most code executes in microseconds. But if you run a computation that takes 400ms on that thread, nothing else can happen for 400ms. The loading spinner halts. Touch gestures are ignored. To the user, the app looks broken or frozen.
The fix is to move heavy work to a separate Dart isolate, which is Dart’s version of a background thread with its own isolated memory heap:
// ❌ Blocks the UI main thread while it runs
final report = buildReport(rawData);
// ✅ Runs in a background isolate; the UI stays responsive at 60/120 FPS
final report = await Isolate.run(() => buildReport(rawData));Best Practice: The function you pass to
Isolate.runmust be a top-level or static function, and the data sent across isolate boundaries must be plain serializable data (primitives, lists, maps, or simple model objects). Never captureBuildContextor aStateobject inside the background closure.
For architectural patterns on isolating business logic and repositories cleanly, refer to our comprehensive guides on Mastering Flutter Clean Architecture and Flutter Production App Architecture.
Measuring Inside the Background Calculation
Sometimes you want to know which exact step of a complex calculation is causing the bottleneck. Firebase traces live on the main isolate, so the trick is to record step timings inside the background work and return the timings dictionary back with the final result.
class ReportResult {
ReportResult(this.rows, this.timings);
final List<ReportRow> rows;
final Map<String, int> timings;
}
// Top-level function so it can safely execute inside an isolate.
ReportResult buildReport(RawData data) {
final stopwatch = Stopwatch()..start();
final filtered = filterRows(data);
final filterMs = stopwatch.elapsedMilliseconds;
stopwatch.reset();
final grouped = groupByCategory(filtered);
final groupMs = stopwatch.elapsedMilliseconds;
stopwatch.reset();
final totals = calculateTotals(grouped);
final totalsMs = stopwatch.elapsedMilliseconds;
return ReportResult(totals, {
'calc_filter_ms': filterMs,
'calc_group_ms': groupMs,
'calc_totals_ms': totalsMs,
});
}Back on the main page, attach those numbers directly to your active trace. Add a helper method to PageLoadTracker:
void addMetrics(Map<String, int> metrics) {
metrics.forEach(_trace.setMetric);
}And call it after the background isolate completes:
pageLoad.addMetrics(result.timings);Now in your Firebase dashboard, you’ll know not just that the calculation took 900ms, but that 700ms of it was spent in the grouping algorithm.
Part 3: Is That Animation Smooth?
Frames, Refresh Budgets, and Jank
Smoothness is governed by frame rendering budgets:
- On a standard 60Hz display, the screen redraws 60 times a second. Each frame has 16.6ms to complete layout, paint, and rasterization.
- On modern 120Hz displays (Apple ProMotion / 120Hz Android flagships), that budget shrinks to just 8.3ms.
When a frame takes longer than its budget, the engine misses the refresh deadline, displays the previous frame again, and the user experiences a stutter. That dropped frame is what is termed jank.
Flutter splits each frame into two distinct phases, and knowing which one is slow reveals exactly what to optimize:
- The Build Phase (UI Thread): Runs your Dart code—building widgets, evaluating layout, and running animations. If this is slow, you’re rebuilding too much or doing computation inside
build(). - The Raster Phase (GPU Thread): Transforms the layer tree into actual pixels on the GPU. If this is slow, the painting operations are too expensive (e.g., stacked blurs, complex clip paths, heavy drop shadows, or uncompressed images).
A Frame Jank Tracker
Flutter provides exact millisecond timings for every frame through SchedulerBinding.instance.addTimingsCallback. We can listen to those timings while an animation executes, categorize the slow frames, and report the aggregated metrics to Firebase.
// lib/performance/frame_jank_tracker.dart
import 'dart:ui' show FrameTiming;
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/scheduler.dart';
class FrameJankTracker {
FrameJankTracker(this.name, {this.refreshRate = 60});
final String name;
final double refreshRate;
Trace? _trace;
int _totalFrames = 0;
int _slowFrames = 0;
int _frozenFrames = 0;
int _slowBuildFrames = 0;
int _slowRasterFrames = 0;
double _worstFrameMs = 0;
double get _frameBudgetMs => 1000 / refreshRate;
Future<void> start() async {
if (_trace != null) return;
final trace = FirebasePerformance.instance.newTrace('jank_$name');
trace.putAttribute('refresh_rate', refreshRate.round().toString());
_trace = trace;
SchedulerBinding.instance.addTimingsCallback(_onFrameTimings);
await trace.start();
}
void _onFrameTimings(List<FrameTiming> timings) {
for (final timing in timings) {
final buildMs = timing.buildDuration.inMicroseconds / 1000;
final rasterMs = timing.rasterDuration.inMicroseconds / 1000;
final totalMs = timing.totalSpan.inMicroseconds / 1000;
_totalFrames++;
final slowBuild = buildMs > _frameBudgetMs;
final slowRaster = rasterMs > _frameBudgetMs;
if (slowBuild) _slowBuildFrames++;
if (slowRaster) _slowRasterFrames++;
if (slowBuild || slowRaster) _slowFrames++;
if (totalMs > 700) _frozenFrames++;
if (totalMs > _worstFrameMs) _worstFrameMs = totalMs;
}
}
Future<void> stop() async {
final trace = _trace;
if (trace == null) return;
_trace = null;
// Frame timings are delivered in batches, so give the last batch
// a moment to arrive before removing the listener.
await Future.delayed(const Duration(milliseconds: 200));
SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
final jankPercent =
_totalFrames == 0 ? 0 : (_slowFrames * 100 ~/ _totalFrames);
trace
..setMetric('total_frames', _totalFrames)
..setMetric('slow_frames', _slowFrames)
..setMetric('frozen_frames', _frozenFrames)
..setMetric('slow_build_frames', _slowBuildFrames)
..setMetric('slow_raster_frames', _slowRasterFrames)
..setMetric('worst_frame_ms', _worstFrameMs.round())
..setMetric('jank_percent', jankPercent);
await trace.stop();
}
}Notice that we query the device’s actual refresh rate dynamically (View.of(context).display.refreshRate) rather than hardcoding 60Hz. A 10ms frame is perfectly smooth on a 60Hz display, but represents a dropped frame on a 120Hz screen.
Tracking an Animation
AnimationController.forward() returns a TickerFuture that completes once the animation finishes, allowing for clean tracking:
class _FlipCardState extends State<FlipCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
Future<void> flip() async {
final tracker = FrameJankTracker(
'card_flip',
refreshRate: View.of(context).display.refreshRate,
);
await tracker.start();
await _controller.forward(from: 0);
await tracker.stop();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: flip,
child: AnimatedBuilder(
animation: _controller,
// The child is instantiated once and reused across every frame.
child: const CardFace(),
builder: (context, child) => Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..rotateY(_controller.value * 3.1416),
child: child,
),
),
);
}
}Performance Tip: Supplying the static
childparameter onAnimatedBuilderprevents rebuildingCardFace60 or 120 times every second.
Tracking Scroll Smoothness
Scrolling through long feeds (ListView, CustomScrollView) is the most common place apps drop frames. We can wrap scroll notifications with our tracker:
// lib/performance/scroll_jank_listener.dart
import 'package:flutter/widgets.dart';
import 'frame_jank_tracker.dart';
class ScrollJankListener extends StatefulWidget {
const ScrollJankListener({
super.key,
required this.name,
required this.child,
});
final String name;
final Widget child;
@override
State<ScrollJankListener> createState() => _ScrollJankListenerState();
}
class _ScrollJankListenerState extends State<ScrollJankListener> {
FrameJankTracker? _tracker;
bool _onScroll(ScrollNotification notification) {
if (notification is ScrollStartNotification && _tracker == null) {
_tracker = FrameJankTracker(
widget.name,
refreshRate: View.of(context).display.refreshRate,
)..start();
} else if (notification is ScrollEndNotification) {
_tracker?.stop();
_tracker = null;
}
return false;
}
@override
void dispose() {
_tracker?.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: widget.child,
);
}
}Using it is a clean one-line wrap:
ScrollJankListener(
name: 'product_feed_scroll',
child: ListView.builder(
itemCount: products.length,
itemBuilder: (context, i) => ProductTile(product: products[i]),
),
)Bonus: Diagnosing If Calculations Cause Jank
To confirm whether a calculation is causing UI freezing, run the frame jank tracker concurrently with the computation:
final jank = FrameJankTracker('report_calculation');
await jank.start();
final result = await Isolate.run(() => buildReport(rawData));
await jank.stop();If frozen_frames reports greater than zero, heavy work is still executing synchronously on the main thread.
Reading the Results in the Firebase Console
Telemetry data displays in the Firebase Console within minutes to a few hours after real users run the app. Open Performance Monitoring > Custom traces tab.
When evaluating durations, focus on the 90th percentile (p90) rather than just the median (p50). The median represents typical users on capable hardware, whereas the 90th percentile highlights budget devices and network edge cases where negative app reviews originate.
User Experience Benchmarks for Jank Traces
| Metric / Value | User Experience Impact | Architectural Action |
|---|---|---|
jank_percent < 5% |
Smooth | Frame budget maintained consistently. |
jank_percent 5% – 15% |
Minor Stutter | Noticeable on quick scrolls; inspect widget rebuild scopes. |
jank_percent > 15% |
Choppy | Critical UX degradation; profile layout and GPU layers. |
Any frozen_frames (>700ms) |
App Freeze | Main thread blocked; immediately offload to Isolate.run(). |
Build Phase vs. Raster Phase Diagnostics
| Bottleneck Category | Metrics Symptom | Root Causes | How to Fix |
|---|---|---|---|
| Build Thread (UI) | High slow_build_frames |
Large widget subtrees rebuilding, missing const, calculations running in build() |
Use const widgets, target state with Selector/ValueListenableBuilder, offload calculations to Isolate.run(). See our Flutter App Performance Guide. |
| Raster Thread (GPU) | High slow_raster_frames |
Offscreen rendering layers, clipping, excessive blurs, oversized image assets | Replace Opacity with FadeTransition, add RepaintBoundary on animations, supply cacheWidth/cacheHeight on images. |
Mistakes Worth Avoiding
- Measuring in debug mode. Debug Flutter is slow on purpose. Test with
flutter run --profileand rely on release builds for real data. - Putting IDs in trace names.
page_load_productis good.page_load_product_48213creates a separate trace for every product and makes your dashboard useless. - Forgetting to stop traces on errors. A trace that never stops is never uploaded. That’s why our tracker has
markFailed()and why we usetry/finallyinstep(). - Storing personal data. Never put emails, phone numbers, or names in attributes. It’s against Firebase’s policies and it’s a privacy risk.
- Assuming Dart HTTP calls are tracked automatically. Firebase’s automatic network monitoring watches native networking, but requests made with Dart’s
httpordiopackages need manualHttpMetrictracking or an interceptor. - Only testing on your own phone. Keep a cheap, older Android device around. It will show you problems your flagship phone hides.
Frequently Asked Questions (FAQ)
What is considered a "fully loaded" page in Flutter?
A page is only truly loaded when:
- All initial asynchronous network requests or database queries resolve.
- Necessary data computations and JSON parsing are finished.
setState()or reactive state updates have scheduled the frame.- The Flutter rendering pipeline has successfully laid out, composited, and painted the frame on screen. In code, this exact visual timestamp is captured using
WidgetsBinding.instance.addPostFrameCallback.
Why does setState() not mark when the user sees the page?
setState() only marks the element dirty and schedules a rebuild for the upcoming frame. The actual UI layout, compositing, and GPU rasterization happen asynchronously afterward. Measuring immediately upon setState() ignores frame raster time and gives an overly optimistic load time.
How does Firebase Performance Monitoring capture real-world user metrics?
Firebase Performance Monitoring operates as a lightweight Real User Monitoring (RUM) SDK. It measures durations via Custom Traces, records counts via Metrics, and segments data via Attributes (e.g. device model, app version, connection type). This lets engineering teams discover regressions on budget devices that never appear on development hardware.
What is the maximum acceptable frame time before users perceive jank?
- 60 Hz displays: Each frame has 16.6 ms to build and render.
- 120 Hz displays (ProMotion/High refresh): Each frame has 8.3 ms. Any frame exceeding this budget causes a dropped frame (jank). A frame exceeding 700 ms is categorized as a "frozen frame," which causes visible UI lockup.
How does Dart Isolate.run() prevent frame drops?
Flutter executes your application on a single thread (the UI isolate). Executing a synchronous, CPU-intensive calculation on this thread halts widget layout and gesture processing. Isolate.run() spawns a separate background worker thread with its own memory heap, performs the work, and passes only the final result back without freezing the UI.
Flutter Performance Telemetry Checklist
Before deploying your Flutter application to production, ensure your monitoring strategy covers:
- Disabled in Debug Mode: Verified
setPerformanceCollectionEnabled(!kDebugMode)to avoid polluting data. - End-to-End Page Load Traces: Tracked with
addPostFrameCallbackon critical funnel screens (Checkout, Dashboard, Reports). - Sub-Step Metrics: API request duration vs calculation duration tagged as separate metrics.
- Abandoned Page Rate: Tracked via
markAbandonedIfPending()indispose()to catch users leaving slow screens. - Dynamic Display Refresh Rates: Evaluated frame timing against
View.of(context).display.refreshRateinstead of hardcoding 60Hz. - Isolate Timings: Expensive background computations timed internally and sent back to main traces.
- Console Alerting: Automated Slack/Email alerts configured for 90th percentile spikes.
Wrapping Up
Firebase Performance tells you what is slow and for whom. Flutter DevTools, specifically its Performance tab, tells you why. The best workflow uses both: let Firebase point you to the problem screen and device type, then reproduce it locally in profile mode and dig in with DevTools.
With the three small tools from this post—PageLoadTracker, FrameJankTracker, and ScrollJankListener—you can measure the parts of your app that users actually feel: how long they wait, and how smooth things look while they’re waiting. Start by adding them to your two or three most important screens, ship a release, and see what the numbers say. They’re often surprising.