Flutter Performance Profiling & DevTools
Finding and Fixing Performance Problems Systematically
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Flutter DevTools provides systematic tools for diagnosing performance problems: the Performance overlay for quick visual feedback, the Timeline for frame-by-frame analysis, Rebuild Tracker for identifying unnecessary rebuilds, and Memory/CPU profilers for deep investigation. Profile mode eliminates debug overhead for accurate measurements.
Real-world relevance
In a school management platform with 500-student class lists, the initial implementation had jank on scroll. DevTools Timeline showed 35ms frames caused by two issues: (1) each list item decoded a 500x500 student photo at full resolution — fixed with memCacheWidth, (2) the ViewModel was rebuilding the entire 500-item list on any single student's data change — fixed by selecting per-student state with Riverpod. Post-fix: consistent 8ms frames.
Key points
- The 16ms Frame Budget — At 60fps, you have exactly 16.67ms per frame (8.33ms at 120fps). Split between two threads: the UI thread (Dart code, widget rebuilds, layout) and the Raster thread (GPU rendering). If either thread exceeds the budget, a frame is dropped — users see jank. The Performance overlay shows both thread timings with red/green bars.
- Performance Overlay — Enable with MaterialApp(showPerformanceOverlay: true). The top graph shows GPU/raster thread time. The bottom shows UI thread time. Red bars mean the frame took too long. Green bars are within budget. Use this as a quick first check during development — it gives immediate visual feedback without opening DevTools.
- Flutter DevTools Timeline — DevTools Timeline (Performance tab) shows a flame graph of every frame. Zoom into a janky frame to see which functions took the most time. Look for: long widget build() calls, heavy layout computation, synchronous I/O on the UI thread, or large image decoding. The Timeline is the most valuable profiling tool Flutter provides.
- Widget Rebuild Tracking — In DevTools: Highlight Repaints shows yellow flickering on widgets that repaint, and Track Widget Rebuilds counts rebuilds in Flutter Inspector. Also use debugProfileBuildsEnabled and debugProfilePaintsEnabled flags. The goal: identify widgets rebuilding more often than necessary. A well-optimized app rebuilds only what changed.
- Identifying Jank Sources — Common jank causes: (1) Rebuilding a large widget tree on every state change — fix with granular state management. (2) Synchronous work on the UI thread such as JSON parsing of large responses — move to a compute() isolate. (3) Shader compilation jank (first-time frame drops) — fix with Impeller. (4) Large image decoding on UI thread — use ResizeImage.
- compute() and Isolates for Heavy Work — Any operation taking more than 16ms on the UI thread causes a dropped frame. Use compute(function, data) to run heavy work in a background isolate: final parsed = await compute(parseJsonList, rawJsonString). Dart isolates have no shared memory — data is serialized/deserialized on transfer (use simple data structures for lowest overhead).
- Frame Analysis Methodology — Systematic approach: (1) Enable Performance overlay, scroll and interact to find red frames. (2) Open DevTools Timeline, record 5 seconds of the janky interaction. (3) Find the worst frame, zoom in on the flame graph. (4) Identify the longest running function — is it in build(), layout(), or rasterization? (5) Apply the targeted fix, re-profile to confirm improvement.
- Memory Profiling — DevTools Memory tab shows heap size over time. Look for: linear growth (memory leak), large peaks (huge allocations), objects not being garbage collected. Use the Allocation Tracker to find which types are accumulating. Common leaks: StreamSubscription not cancelled, Timer not cancelled, ScrollController not disposed.
- CPU Profiling — DevTools CPU Profiler records function call stacks. Run in profile mode (flutter run --profile) — debug mode is too slow for accurate profiling. The flame chart shows which Dart functions consume the most CPU. Look for unexpected hot paths — a supposedly O(n) operation that is actually O(n squared).
- Flutter Inspector — Widget Tree — DevTools Inspector shows the live widget tree. Use it to: verify widget depth (deep trees are slower to build), find unexpected widget instances, check box constraints to debug layout issues, and inspect padding and margin. A 30-level deep widget tree for a simple card is a red flag.
- Performance Testing in CI — flutter drive with integration tests can catch performance regressions. Use FrameTimingSummarizer to assert that 99th percentile frame times stay below a threshold. This prevents regressions from landing silently. Combine with screenshot testing for comprehensive quality gates.
Code example
import 'package:flutter/foundation.dart';
import 'dart:developer' as developer;
// Performance Overlay — only in profile builds
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
showPerformanceOverlay: kProfileMode,
home: const HomeScreen(),
);
}
}
// compute() for heavy JSON parsing
List<User> _parseUsers(String jsonString) {
final List<dynamic> jsonList = jsonDecode(jsonString) as List;
return jsonList.map((j) => User.fromJson(j as Map<String, dynamic>)).toList();
}
class UserRepository {
final Dio _dio;
UserRepository(this._dio);
Future<List<User>> getUsers() async {
final response = await _dio.get('/users');
final rawJson = response.data.toString();
// Move JSON parsing off the UI thread
return compute(_parseUsers, rawJson);
}
}
// Granular state selection to minimize rebuilds
// BAD: rebuilds every time ANY field in WorkspaceState changes
class BadHeader extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(workspaceProvider);
return Text(state.name);
}
}
// GOOD: only rebuilds when name specifically changes
class GoodHeader extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final name = ref.watch(workspaceProvider.select((s) => s.name));
return Text(name);
}
}
// Custom Timeline markers for DevTools
Future<void> loadDashboard() async {
developer.Timeline.startSync('LoadDashboard');
try {
developer.Timeline.startSync('FetchUsers');
final users = await userRepository.getUsers();
developer.Timeline.finishSync();
developer.Timeline.startSync('BuildState');
final dashboardState = DashboardState(users: users);
developer.Timeline.finishSync();
state = dashboardState;
} finally {
developer.Timeline.finishSync();
}
}
// RepaintBoundary for isolated list item repaints
class StudentListItem extends StatelessWidget {
final Student student;
const StudentListItem({required this.student, super.key});
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: ListTile(
leading: CachedNetworkImage(
imageUrl: student.avatarUrl,
memCacheWidth: 80, memCacheHeight: 80,
),
title: Text(student.name),
subtitle: Text(student.grade),
trailing: AttendanceIndicator(studentId: student.id),
),
);
}
}Line-by-line walkthrough
- 1. kProfileMode ensures the overlay only appears in profile builds — never in production
- 2. compute() sends rawJson to a background isolate — the UI thread is free to render while parsing happens
- 3. _parseUsers must be a top-level function — closures capture context that cannot cross isolate boundaries
- 4. BAD pattern: ref.watch(workspaceProvider) subscribes to the entire state — rebuilds on any field change
- 5. GOOD pattern: .select() creates a derived stream — only rebuilds when the selected field value changes
- 6. Timeline.startSync creates a named event in DevTools — visible as a labeled block in the flame graph
- 7. Nested Timeline events show a hierarchy of work: LoadDashboard contains FetchUsers and BuildState
- 8. RepaintBoundary on each list item isolates repaints — AttendanceIndicator animating does not repaint siblings
- 9. memCacheWidth: 80 = 40 logical pixels times 2.0 DPR — decodes at physical resolution, not full source resolution
Spot the bug
class HeavyViewModel extends ChangeNotifier {
List<Report> _reports = [];
Future<void> loadReports() async {
final response = await _dio.get('/reports');
final jsonString = response.data.toString();
_reports = (jsonDecode(jsonString) as List)
.map((j) => Report.fromJson(j as Map<String, dynamic>))
.toList();
notifyListeners();
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Flutter Performance Profiling (Flutter Official)
- Flutter DevTools (Flutter Official)
- Flutter Performance Best Practices (Flutter Official)
- compute() Function (Flutter Official)
- Widget Rebuild Optimization (Flutter Official)