+1 (415) 480-3939

Profiling Flutter Performance with Dart DevTools (Observatory's Replacement)

If you learned Flutter before 2023, you may remember profiling with Observatory. It is gone — removed from the Dart SDK in Dart 3.0 — and its replacement, Dart DevTools, is a much better tool. This tutorial walks through the profiling workflow we use on client apps: finding jank with the frame timeline, diagnosing CPU hot spots, tracking down memory growth, and checking Impeller shader behavior.

Setup: profile mode on a real device

Debug builds are not representative. They run the Dart VM in JIT mode with assertions enabled, and frame times can be several times worse than release. Always profile in profile mode on a physical device:

flutter run --profile -d <device-id>

The command prints a DevTools URL. Open it in a browser, or launch DevTools from VS Code or Android Studio and attach to the running app. If you prefer standalone:

dart devtools

Profile mode keeps the VM service available (so DevTools can connect) while compiling your code ahead of time like a release build.

Reading the frame timeline

Open the Performance tab. The top chart plots each frame as a bar split into UI (Dart) time and Raster (GPU) time. The budget is 16.6 ms at 60 Hz and 8.3 ms on a 120 Hz display — a bar crossing the red line is a dropped frame.

Interact with the app — scroll the busiest list, open the heaviest screen — and then click a slow frame. The flame chart below shows what was executing. Two patterns account for most jank we see:

UI-thread jank: too much work in build. If the UI bar dominates, the Dart side is slow. Look for wide build frames belonging to your widgets, JSON parsing on the main isolate, or setState calls that rebuild far more than they need to.

Raster-thread jank: expensive painting. If the Raster bar dominates, the GPU side is slow. The usual culprits are Opacity widgets wrapping large subtrees, ClipRRect and BackdropFilter on scrolling content, saveLayer calls, and shaders compiling on first use.

Toggle Track widget builds in the Performance tab to see every widget rebuild in the timeline. An obviously oversized rebuild — the whole screen repainting because a counter changed — is the cheapest fix there is: move the state down, or split the widget.

Adding your own timeline events

The frame timeline is more useful when your own code shows up in it. Wrap suspect work with Timeline from dart:developer:

import 'dart:developer' as developer;

Future<List<Product>> loadProducts() async {
  developer.Timeline.startSync('loadProducts.parse');
  try {
    final raw = await api.fetchProducts();
    return parseProducts(raw);
  } finally {
    developer.Timeline.finishSync();
  }
}

For async work, developer.Timeline.timeSync and TimelineTask give the same result. Named events appear as labeled blocks in the flame chart, so you can see at a glance whether the parse took 2 ms or 40 ms.

CPU profiler

When the timeline says "the UI thread was busy" but not why, switch to the CPU Profiler tab. Record while you reproduce the slow interaction, then read the Bottom Up view: it lists the functions where time was actually spent, with callers expanded underneath. Sort by self time.

Common findings:

  • jsonDecode on the main isolate for a large payload. Move it to compute() or an Isolate.run call:
final products = await Isolate.run(() => parseProducts(rawJson));
  • DateFormat or NumberFormat being constructed inside build for every list item. Construct once and reuse.
  • Sorting or filtering a list inside build. Do it when the data changes, not when the widget rebuilds.

Memory tab

Slow leaks are the bugs that reach production. The Memory tab shows heap usage over time; the pattern to watch for is a sawtooth that never returns to baseline after you navigate into and out of a screen several times.

Take a heap snapshot before the navigation loop and another after, then diff them. The diff lists classes whose instance count grew. In Flutter apps the usual suspects are:

  • StreamSubscription and AnimationController instances not cancelled or disposed in dispose().
  • Listeners added to a ChangeNotifier in initState and never removed.
  • Image caches holding full-size decoded images for thumbnails. Use cacheWidth/cacheHeight on Image or ResizeImage to decode at display size.

The Allocation tracing view can record where instances of a chosen class are allocated, which turns "something is holding a UserSession" into a stack trace.

Impeller and shader behavior

Impeller is Flutter's default renderer on iOS and Android. One of its design goals was to eliminate the first-run shader compilation jank that Skia suffered from, and in practice it does. If you still see first-frame stutter on a screen:

  • Check the Raster bar in the Performance tab for that first frame; if it is a one-off spike, it is probably a texture upload (large image decode), not a shader.
  • Custom fragment shaders declared under flutter: shaders: are precompiled by the toolchain. A shader that does compile at runtime usually means it is being loaded outside the declared list.
  • The Raster Stats tool lets you inspect a single frame's raster timing layer by layer, which makes an expensive BackdropFilter easy to prove.

You can compare renderers during an investigation by temporarily disabling Impeller in Info.plist (FLTEnableImpeller to false) or AndroidManifest.xml — useful for confirming whether a regression is Impeller-specific — but ship with the default.

Flutter Inspector for layout problems

Not every performance issue is a timing issue. The Inspector tab shows the widget tree and the render tree with layout constraints; turn on Select widget mode and tap a widget on the device to jump to it. It is the fastest way to understand why a ListView is laying out every child at once (unbounded constraints, no shrinkWrap), or why a Column overflows.

A repeatable workflow

  1. Profile mode, physical device.
  2. Performance tab: find the slow frame, decide UI versus Raster.
  3. UI-bound: CPU profiler, bottom-up, fix the top self-time function. Raster-bound: look for opacity, clips, filters, and big images.
  4. Add Timeline events around the suspect code so the fix is measurable.
  5. Memory diff after every navigation-heavy feature.
  6. Re-profile after fixing and keep the DevTools screenshot in the pull request.

Teams that do step 6 consistently stop arguing about whether the app is fast. If you would like a senior engineer to run a performance audit on your app, contact us.