ON THIS PAGE

No sections found

Related Posts

Flutter App Performance Optimization: The Complete 2026 Guide

Flutter App Performance Optimization: The Complete 2026 Guide

Mar 15, 2026

Flutter Production App Architecture: Clean Architecture, BLoC & Dependency Injection

Flutter Production App Architecture: Clean Architecture, BLoC & Dependency Injection

Mar 20, 2026

Flutter App Development in 2026: The Complete Engineering Guide

Flutter App Development in 2026: The Complete Engineering Guide

Mar 1, 2026

Quick Navigation

AboutProjectsEducationExperienceSkillsAwards

Connect

LinkedInXFacebookInstagramMediumRSS

Resources

Developer ToolsBlogDownload CV

Dependencies

Quick Settings TileFlutter Ex KitDotted Line Flutter

Contact

Jaipur, Rajasthan, IndiaSupport
© 2026 Puneet Sharma•All rights reserved
Privacy Policy•Terms of Service•Disclaimer
Last updated: Oct 2026
Made withby Puneet
Crawler Verification Trap

Is Your Flutter Page Actually Fast? Measuring Real Load Time, Heavy Calculations, and Animation Jank with Firebase Performance

Published onToday

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:

  1. When does a page actually finish loading, including all its data and calculations?
  2. How long do heavy calculations take, and are they freezing the UI?
  3. 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: loaded or payment: 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_performance

Then 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.dart

Part 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 _finished flag 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 abandoned status 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_ms for the full screen load experience,
  • api_ms and calculation_ms for the granular breakdown, and
  • A load_status attribute 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.run must 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 capture BuildContext or a State object 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:

  1. 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().
  2. 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 child parameter on AnimatedBuilder prevents rebuilding CardFace 60 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 --profile and rely on release builds for real data.
  • Putting IDs in trace names. page_load_product is good. page_load_product_48213 creates 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 use try/finally in step().
  • 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 http or dio packages need manual HttpMetric tracking 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:

  1. All initial asynchronous network requests or database queries resolve.
  2. Necessary data computations and JSON parsing are finished.
  3. setState() or reactive state updates have scheduled the frame.
  4. 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 addPostFrameCallback on 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() in dispose() to catch users leaving slow screens.
  • Dynamic Display Refresh Rates: Evaluated frame timing against View.of(context).display.refreshRate instead 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.

Previous Post

Flutter Production App Architecture: Clean Architecture, BLoC & Dependency Injection