Profiling #

In software performance engineering, there’s a first golden rule you must obey: never optimize without profiling data. Changing code lines, restructuring widget trees, or redesigning data flows only based on “assumptions” that a part is slow is a speculative action wasting development time. Often, what you suspect as the cause of an app slowing down actually runs very fast, while the real bottleneck is tightly hidden in an area you never suspected at all.

Profiling is the activity of recording, analyzing, and dissecting your app’s computing resource consumption in real-time while running. Flutter provides a very mature world-class performance analysis toolset called Flutter DevTools. Through DevTools, you can peek at the rendering duration of every pixel frame, monitor CPU usage per function, track RAM memory cycles, and identify memory leaks. In this article, we’ll learn systematic profiling tactics using Flutter DevTools to find the root cause of jank (stuttering interfaces) in your app.

Profile Mode: The Main Prerequisite for Profiling #

Before opening DevTools and starting to record metrics, you must ensure your app runs in the right compilation mode. Flutter supports three main modes: Debug, Profile, and Release.

There’s an absolute rule that must not be violated: never do performance profiling in Debug Mode. In debug mode, Flutter includes lots of overhead to support Hot Reload features, assertion checks, and Dart VM runtime diagnostic code. Recording results in debug mode will show very slow performance and don’t reflect what real users of your app experience in production.

To get accurate data, you must run the app in Profile Mode. Profile mode compiles your Dart code into fully optimized native machine code (equivalent to Release Mode), but still retains diagnostic channels (tracing ports) so it can connect to Flutter DevTools.

Use the following terminal commands to run the app in profile mode:

# 1. Run the app on a physical device in Profile Mode
flutter run --profile

# 2. Build high-performance test package binaries for distribution to test devices
flutter build apk --profile  # For Android
flutter build ios --profile  # For iOS

[!IMPORTANT] Must Use Real Physical Devices: When profiling, avoid using your computer’s Android emulators or iOS simulators. Emulators map mobile device CPU instructions to computer architecture CPUs (x86/ARM hosts) which usually have much higher computing power than real mobile chips. Additionally, emulators don’t accurately use native mobile GPU rendering pipelines. Always connect real physical devices (recommended using low-end devices) to get the true worst-case performance picture.


Performance View: Dissecting the Frame Chart #

When users complain that “the screen feels choppy when scrolling (scrolling jank)”, the first tool you should open in Flutter DevTools is the Performance tab. This page displays a bar chart visualization called the Frame Chart.

The Frame Chart shows the rendering history of every pixel frame drawn by Flutter. The vertical axis (Y) represents the time duration needed to draw that frame (in milliseconds / ms), while the horizontal axis (X) represents the frame sequence over time.

Understanding the Frame Budget #

For the human eye to perceive smooth animation movement, the app must draw new frames at a minimum speed of 60 frames per second (FPS). You can calculate the work time limit (budget) for each frame:

  • 60 FPS Speed: Each frame must finish drawing within a maximum of 16.6 milliseconds ($1,000\text{ ms} / 60\text{ frames}$).
  • 90 FPS Speed: Each frame must finish within a maximum of 11.1 milliseconds.
  • 120 FPS Speed (Modern devices / High Refresh Rate): Each frame must finish within a maximum of 8.3 milliseconds.

In the DevTools Frame Chart, you’ll see a bar chart with three color indications:

  • Green Bars: Frames successfully drawn under the budget time limit (less than 16.6 ms for 60 Hz screens). Users see smooth animations.
  • Blue Bars: Frames exceeding 16.6 ms but still below 33 ms. Animations are slightly hindered but not too disruptive.
  • Red Bars (Jank): Frames requiring work time above the critical time limit. When this happens, frames are dropped, and users will feel the screen stutter (jank).

UI Thread vs Raster Thread #

Each frame bar in DevTools is divided into two main thread work sections:

  1. UI Thread (Dart VM): This section is responsible for running your Dart code. Scenarios inside it include event processing, state calculations, widget tree building (build), layout size determination (layout), and drawing instruction recording (paint).
  2. Raster Thread (GPU Engine): This section is responsible for taking drawing instructions from the UI thread, converting them into native graphics engine commands (Impeller/Skia), and sending them to the device’s physical graphics card (GPU) for drawing to the screen.

Frame Budget Work Division Architecture #

To visualize how the UI thread and Raster thread collaborate dividing the 16.6ms time limit to produce one stable pixel frame, observe the flow diagram below:

graph TD
    Start["New Frame Trigger (Vsync Signal)"] --> UI["UI Thread (Dart Code)"]
    UI -->|1. Initiate Build & Layout| Build["Build & Layout (Widget Tree)"]
    Build -->|2. Record Drawing Commands| Paint["Paint (RenderObjects)"]
    Paint -->|Send Layout Layers| Raster["Raster Thread (GPU Engine / Impeller)"]
    
    Raster -->|3. Convert to GPU Instructions| GPU["GPU Driver Rendering"]
    GPU -->|4. Display Image on Screen| Screen["Device Screen (Display)"]
    
    subgraph Budget["Frame Time Limit (60 FPS = 16ms)"]
        UI
        Raster
    end

If either of the two threads above exceeds its portion until the total time surpasses the 16.6ms limit, the frame bar turns red in DevTools. Checking which thread takes the longest is a very important initial diagnostic step:

  • If the UI thread portion is long: The main problem is in your Dart code (e.g., excessive rebuilds or complex mathematical computations on the main thread).
  • If the Raster thread portion is long: The problem is at the GPU level (e.g., loading images with too-large resolutions, or using heavy graphics effects like Opacity/Blur that burden the GPU).

CPU Profiler: Analyzing Flame Charts #

When you find red frames in the Frame Chart caused by a too-slow UI thread, you can click that red bar and open the CPU Profiler tab to see the Flame Chart.

The Flame Chart is a visualization of the function call stack hierarchy over time. The horizontal axis (X) shows work time duration, while the vertical axis (Y) shows the function call stack depth. Functions at the top position call the functions below them (Caller $\rightarrow$ Callee).

How to Read Total Time vs Self Time #

When analyzing the Flame Chart, you’ll be presented with a data table containing the following important metrics:

  • Total Time: The total duration spent executing a function along with all the child functions (callees) called inside it.
  • Self Time: The pure duration spent executing the code inside that function itself, without counting the work time of child functions inside it.

[!TIP] Finding Bottlenecks through Self Time: A function with a large Total Time isn’t necessarily the cause of slowness. It could be calling another slow function. However, if a function has a high Self Time, that function is the real root cause (bottleneck). That function spends lots of CPU cycles on its own code lines. Focus your optimizations on functions with the highest Self Time values.

Adding Custom Markers to the Timeline #

Sometimes, it’s very hard to find your own functions among thousands of internal Flutter framework functions in the Flame Chart. To make searching easier, you can manually mark your functions using the dart:developer library to show special labels in DevTools:

// lib/features/products/data/repositories/product_repository_impl.dart

import 'dart:developer' as developer;
import '../../domain/entities/product.dart';

class ProductRepositoryImpl {
  
  Future<List<Product>> fetchHeavyProducts() async {
    // 1. Start the timeline synchronization marker with a custom label
    developer.Timeline.startSync('ProductRepository:ParsingHeavyJson');
    
    try {
      final String jsonRaw = await _loadJsonFromAssets();
      
      // The operation suspected to be heavy
      final List<Product> list = _parseBigJson(jsonRaw);
      return list;
    } finally {
      // 2. Must end the marker in the finally block so the marker closes even if an error occurs
      developer.Timeline.finishSync();
    }
  }

  List<Product> _parseBigJson(String json) {
    // Parsing logic...
    return [];
  }
  
  Future<String> _loadJsonFromAssets() async => '[]';
}

When you run profiling, the label 'ProductRepository:ParsingHeavyJson' will appear as a colored block line at the top of the DevTools timeline, so you can directly click it to measure that function’s work duration precisely.


Flame Chart Call Stack Visualization Example #

Here’s an illustration of how the Flame Chart arranges function call stacks from top to bottom. Blocks with the widest horizontal span represent the longest-running operations:

graph TD
    Build["build() Function <br/> Total: 50ms, Self: 5ms"] --> Layout["layout() Function <br/> Total: 10ms, Self: 10ms"]
    Build --> BuildList["_buildList() Function <br/> Total: 35ms, Self: 15ms"]
    BuildList --> ListTile["ListTile() Function <br/> Total: 20ms, Self: 20ms"]

By observing the tree structure above, you can conclude that ListTile is the pure bottleneck area because its Self Time value equals its Total Time (20ms), while the build() function above it is only slow because it must wait for the rendering process at the lower level to finish.


Memory Profiler: Tracking Memory Leaks #

Performance problems aren’t only choppy animation displays (jank). Another much more dangerous problem is RAM memory accumulation that keeps increasing over app usage time, which will eventually trigger the operating system to force-stop the app because it runs out of memory (OOM - Out of Memory Crash).

Memory leaks occur when objects no longer used by the app (e.g., a Screen page that’s already closed) can’t be cleaned up by the Dart Garbage Collector (GC) engine because there are still active references from outside pointing to those objects.

Leak Detection Flow with Heap Snapshot Diff #

To track which objects leak in Flutter DevTools, you use the Heap Snapshot feature in the Memory tab. The tracking tactic is as follows:

  1. Snapshot 1 (Base): Open the app, enter the home page, then click the Take Snapshot button in DevTools to record all currently active objects in memory.
  2. Trigger the Action: Enter the page suspected of leaking (e.g., the Product Detail Page), do interactions there, then go back (pop) to the home page.
  3. Garbage Collection (GC): Click the trash can icon (Trigger GC) in DevTools 2-3 times to force Dart to clean all unused objects from RAM memory.
  4. Snapshot 2 (Target): Click the Take Snapshot button again to record the current memory condition.
  5. Diffing: Select the Diff tab in DevTools, then compare Snapshot 2 with Snapshot 1.

Observe the class list with positive Delta values (object instance counts increased). If after leaving the Product Detail Page, the delta for the ProductDetailScreen class or related RenderObject is positive (+1 or more), that page has a memory leak.

A Classic Memory Leak Example & How to Fix It #

Memory leaks are most often caused by developer negligence in canceling data stream subscriptions (Stream Subscriptions) or animation stoppers (Animation Controllers) when widgets are destroyed (dispose).

Here’s an example of code experiencing a memory leak because a listener isn’t cleaned up:

// lib/features/dashboard/presentation/screens/dashboard_screen.dart

import 'dart:async';
import 'package:flutter/material.dart';

class DashboardScreen extends StatefulWidget {
  final Stream<int> notificationStream;
  const DashboardScreen({super.key, required this.notificationStream});

  @override
  State<DashboardScreen> createState() => _DashboardScreenState();
}

class _DashboardScreenState extends State<DashboardScreen> {
  int _unreadNotificationsCount = 0;
  StreamSubscription<int>? _subscription;

  @override
  void initState() {
    super.initState();
    // Listen to the data stream
    _subscription = widget.notificationStream.listen((count) {
      setState(() {
        _unreadNotificationsCount = count;
      });
    });
  }

  // ANTI-PATTERN: Forgetting to define the dispose() method to cancel the subscription.
  // The data stream will keep pointing to this widget's callback in RAM memory,
  // preventing _DashboardScreenState from being cleaned up by the Garbage Collector even when the screen closes.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text('Notifications: $_unreadNotificationsCount')),
    );
  }
}

Here’s the correct code fix by canceling the registration inside the dispose() block:

// Code fix inside the _DashboardScreenState class

@override
void dispose() {
  // 1. Safely cancel the stream subscription
  _subscription?.cancel();
  
  // 2. Always call super.dispose() at the end of cleanup
  super.dispose();
}

Every time you create controllers (like TextEditingController, ScrollController, AnimationController) or do listening on streams/ChangeNotifiers inside a StatefulWidget, you must include their cleanup logic in the dispose() method.


Widget Inspector: Analyzing Component Rebuilds #

When the jank problem is on the UI thread and you suspect the cause is widget rebuilding happening too often in unnecessary areas, you can use the Flutter Widget Inspector.

In the Flutter Inspector tab, you can enable several visualization assistance features:

  1. Track Widget Builds (Performance Tab): When enabled, DevTools counts in real-time how many times each widget in your app calls the build() function. If a static widget (like an icon or text) counts hundreds of build calls while you scroll the screen, that’s an indication of state management errors.
  2. Highlight Repaints: This feature shows blinking border lines with random colors around widgets being redrawn (repainted) on the device’s physical screen. If when you tap a small button, the entire screen area blinks colorfully, your drawing area is too wide and not well isolated.
  3. Show Guidelines: Shows layout boundary lines on the device screen to make it easier to analyze whether any widgets have wrong constraint sizes triggering expensive size recalculations.

You can also display visual performance graphs directly on your device screen during development by enabling the showPerformanceOverlay parameter on your MaterialApp class:

// lib/main.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // Enable the visual performance graph overlay only in debug/development mode
      showPerformanceOverlay: kDebugMode,
      
      // Helps detect whether any images are cached with wrong sizes
      checkerboardRasterCacheImages: kDebugMode,
      
      home: const HomeScreen(),
    );
  }
}

Reading the performance overlay graph is very simple: the top graph represents GPU duration (Raster thread) and the bottom graph represents CPU duration (UI thread). If the graph jumps over the horizontal red boundary line, jank occurred on that frame.

Summary #

  • Profile Prerequisite: Always do profiling activities using Profile Mode (flutter run --profile) on real physical devices. Avoid emulators or Debug Mode because their overhead is very large.
  • Thread Filtering: Use the Performance tab to distinguish between problems on the UI Thread (Dart logic, rebuilds) and problems on the Raster Thread (GPU, shaders, large image assets).
  • CPU Root Causes: When analyzing CPU Flame Charts, focus your search on functions with high Self Time values to find the real bottleneck.
  • Custom Markers: Insert developer.Timeline.startSync() markers to label your own function flows so they’re easy to identify in DevTools.
  • Leak Detection: Use Heap Snapshot Diff comparisons in the Memory tab to find objects that aren’t deleted after pages are popped (positive delta values).
  • Cleanup Discipline: Prevent memory leaks by always calling the cancel() method on stream subscriptions and dispose() on all types of controllers.
  • Rebuild Visualization: Leverage the Highlight Repaints feature in the Widget Inspector to detect redraw areas that are too wide and trigger GPU waste.

← Previous: Best Practice   Next: Rendering Optimization →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact