Skip to content
FlutterLearn

Advanced · Lesson 3 · 14 min read

Profiling and Fixing Performance

Find real jank with DevTools, understand UI versus raster thread costs, and apply the fixes that actually move the numbers.

Updated July 31, 2026

What you will learn

  • Profile correctly in profile mode with DevTools
  • Tell UI-thread jank apart from raster-thread jank
  • Fix the most common causes of dropped frames
  • Find and stop memory leaks

A 60Hz display gives you 16.7ms per frame; a 120Hz display gives you 8.3ms. Exceed it and the frame is dropped — that is jank. Fixing it starts with measuring, because the cause is almost never where developers guess.

Profile in profile mode

Terminal
# Debug builds are 3–10x slower and have assertions everywhere.
# Never draw performance conclusions from them.
flutter run --profile

# On a real device — simulators use your desktop GPU and lie to you
flutter run --profile -d <device-id>

# Open DevTools from the printed URL, or:
dart devtools

UI thread vs raster thread

The performance overlay and DevTools timeline show two bars per frame. Which one is over budget tells you where to look.

Slow threadWhat it meansTypical causes
UI (Dart)Your Dart code is too slow — build/layout is over budgetRebuilding too much, expensive build methods, JSON parsing, unbounded loops
Raster (GPU)The scene is too expensive to drawOpacity layers, saveLayer, large blurs, clipping with anti-alias, huge images, shader compilation
Dart
MaterialApp(
  showPerformanceOverlay: true,   // two graphs: UI on top, raster below
  home: const HomeScreen(),
)

Fixing UI-thread jank

  • Rebuild less. Use the DevTools rebuild counter to find widgets rebuilding per frame that should not be. Move state listeners as low as possible.
  • Make subtrees const. A const widget is skipped entirely when its parent rebuilds.
  • Never build off-screen work. Replace Column inside SingleChildScrollView with ListView.builder when the list is long — builder constructs only visible items.
  • Move heavy computation off the main isolate with compute or a long-lived isolate: JSON parsing, image decoding, cryptography, big sorts.
  • Cache derived values. Recomputing a filtered/sorted list inside build runs on every frame; compute it when the source changes instead.
Dart
// Before: sorts the whole list on every single rebuild
@override
Widget build(BuildContext context) {
  final sorted = [...widget.items]..sort((a, b) => a.name.compareTo(b.name));
  return ListView(children: sorted.map(ItemTile.new).toList());
}

// After: sort only when the input actually changes
List<Item> _sorted = const [];

@override
void initState() {
  super.initState();
  _resort();
}

@override
void didUpdateWidget(covariant ItemList old) {
  super.didUpdateWidget(old);
  if (!listEquals(old.items, widget.items)) _resort();
}

void _resort() {
  _sorted = [...widget.items]..sort((a, b) => a.name.compareTo(b.name));
}

@override
Widget build(BuildContext context) => ListView.builder(
      itemCount: _sorted.length,
      itemBuilder: (context, i) => ItemTile(_sorted[i]),
    );

Fixing raster-thread jank

  • Avoid `saveLayer`. Opacity, ShaderMask, ColorFilter and BackdropFilter can each trigger an offscreen buffer. Prefer FadeTransition, or apply opacity to a colour rather than wrapping in Opacity.
  • Prefer decoration radii to clips. BoxDecoration(borderRadius: ...) is cheaper than ClipRRect with anti-aliasing.
  • Right-size images. Decoding a 4000px JPEG to show a 100px avatar burns memory and raster time. Set cacheWidth/cacheHeight or use ResizeImage.
  • Watch blurs. BackdropFilter over a large area is one of the most expensive things you can draw. Keep it small and static.
  • Warm up shaders. First-run animation stutter on older devices is often shader compilation; Impeller compiles shaders ahead of time, which is a strong reason to keep it enabled.
Dart
// Decode at display size, not source size
Image.network(
  url,
  width: 100,
  height: 100,
  cacheWidth: 200,   // 2x for high-DPI screens, not 4000
  cacheHeight: 200,
  fit: BoxFit.cover,
)

// Cheaper than wrapping the whole subtree in Opacity
DecoratedBox(
  decoration: BoxDecoration(
    color: Colors.black.withValues(alpha: 0.5),
  ),
  child: child,
)

Long lists

Dart
ListView.builder(
  itemCount: items.length,
  // Give a fixed height when you know it — Flutter skips measuring
  itemExtent: 72,
  // Keep offscreen build work bounded
  cacheExtent: 500,
  itemBuilder: (context, index) => ItemTile(
    // Stable keys preserve state correctly when items reorder
    key: ValueKey(items[index].id),
    item: items[index],
  ),
)

Memory leaks

The classic Flutter leak is a subscription or controller that outlives its widget. DevTools' Memory view will show your State objects accumulating as you navigate back and forth.

Dart
class _FeedScreenState extends State<FeedScreen> {
  StreamSubscription<Event>? _subscription;
  Timer? _pollTimer;
  late final AnimationController _controller;
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _subscription = eventBus.stream.listen(_onEvent);
    _pollTimer = Timer.periodic(const Duration(seconds: 30), (_) => _refresh());
    _controller = AnimationController(vsync: this, duration: kThemeAnimationDuration);
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    _subscription?.cancel();
    _pollTimer?.cancel();
    _controller.dispose();
    _scrollController
      ..removeListener(_onScroll)
      ..dispose();
    super.dispose();
  }
}
  • Image cache growthPaintingBinding.instance.imageCache has a size limit; loading many large images can still spike memory. Cap it, or use cached_network_image with a disk cache.
  • Closures capturing State — a long-lived callback holding this keeps the whole element subtree alive. Cancel the registration in dispose.
  • Global caches with no eviction — a Map you only ever add to is a leak with extra steps.

Startup time

  • Keep main() thin — do not await slow initialisation before runApp. Render a splash or skeleton and initialise in the background.
  • Defer plugin setup you do not need on the first screen.
  • Use deferred imports on web to split rarely used features out of the initial bundle.
  • Measure with flutter run --profile --trace-startup and read start_up_info.json.
Dart
void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // Do NOT await everything here — it delays the first frame
  runApp(const MyApp());

  // Fire-and-forget work that the first screen does not need
  unawaited(analytics.initialise());
}

Key takeaways

  • Always profile in profile mode on a physical device.
  • UI-thread jank means your Dart is slow; raster jank means the scene is expensive to draw.
  • ListView.builder with itemExtent and stable keys is the baseline for long lists.
  • Every subscription, timer and controller needs a matching cancel or dispose.

Practice

Diagnose a janky list

Build a list of 1,000 items with full-size network images, a blur behind the app bar and no itemExtent. Profile it, record the frame times, then fix it step by step — measuring after each change — and write down which change produced the largest improvement.

Show hints
  • Fix one thing at a time; changing three things at once teaches you nothing.
  • The image decode size is usually the biggest single win.