Performance Optimization for Flutter
Complete guide to building high-performance Flutter applications that maintain 60 FPS (or 120 FPS on capable devices).
Performance Goals
-
60 FPS: Each frame must render in < 16ms
-
120 FPS: Each frame must render in < 8ms (for high refresh rate displays)
-
Jank-free: No dropped frames during scrolling or animations
-
Fast startup: App ready in < 2 seconds
-
Low memory: < 100MB for typical screens
Widget Optimization
Use const Constructors
Const widgets are built once and reused:
// ❌ BAD - Widget rebuilt every time Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(16.0), child: Text('Static Text'), ); }
// ✅ GOOD - Widget built once, reused Widget build(BuildContext context) { return const Padding( padding: EdgeInsets.all(16.0), child: Text('Static Text'), ); }
// ✅ GOOD - Individual widgets const Widget build(BuildContext context) { return Column( children: const [ Icon(Icons.home), SizedBox(height: 8), Text('Home'), ], ); }
Rule: If a widget's properties don't change, make it const.
Minimize Widget Rebuilds
Use Obx strategically to limit rebuild scope:
// ❌ BAD - Entire Column rebuilds Obx(() => Column( children: [ Text(controller.title.value), ExpensiveWidget(), AnotherExpensiveWidget(), ], ))
// ✅ GOOD - Only Text rebuilds Column( children: [ Obx(() => Text(controller.title.value)), const ExpensiveWidget(), const AnotherExpensiveWidget(), ], )
Proper Key Usage
Keys help Flutter identify which widgets to reuse:
// ❌ BAD - No keys, Flutter may rebuild unnecessarily ListView.builder( itemCount: items.length, itemBuilder: (context, index) { return ListTile(title: Text(items[index].name)); }, )
// ✅ GOOD - ValueKey helps Flutter track items ListView.builder( itemCount: items.length, itemBuilder: (context, index) { return ListTile( key: ValueKey(items[index].id), title: Text(items[index].name), ); }, )
// When to use keys: // - ObjectKey: Compare entire object // - ValueKey: Compare single value (id, index) // - UniqueKey: Force rebuild // - GlobalKey: Access widget state from anywhere (expensive, use sparingly)
Extract Widgets
Extract complex widgets to reduce rebuild scope:
// ❌ BAD - All items rebuild when list changes class MyList extends StatelessWidget { @override Widget build(BuildContext context) { return Obx(() => ListView.builder( itemCount: controller.items.length, itemBuilder: (context, index) { final item = controller.items[index]; return Container( padding: const EdgeInsets.all(16), child: Column( children: [ Text(item.title), Text(item.subtitle), Row( children: [ Icon(Icons.star), Text('${item.rating}'), ], ), ], ), ); }, )); } }
// ✅ GOOD - Extract item widget class MyList extends StatelessWidget { @override Widget build(BuildContext context) { return Obx(() => ListView.builder( itemCount: controller.items.length, itemBuilder: (context, index) { return ItemWidget(item: controller.items[index]); }, )); } }
class ItemWidget extends StatelessWidget { final Item item; const ItemWidget({Key? key, required this.item}) : super(key: key);
@override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), child: Column( children: [ Text(item.title), Text(item.subtitle), Row( children: [ const Icon(Icons.star), Text('${item.rating}'), ], ), ], ), ); } }
List Performance
Use ListView.builder
Never use ListView(children: [...]) for large lists:
// ❌ BAD - All items created upfront ListView( children: items.map((item) => ItemWidget(item: item)).toList(), )
// ✅ GOOD - Items created on demand ListView.builder( itemCount: items.length, itemBuilder: (context, index) => ItemWidget(item: items[index]), )
// ✅ GOOD - Separated items (for sticky headers) ListView.separated( itemCount: items.length, itemBuilder: (context, index) => ItemWidget(item: items[index]), separatorBuilder: (context, index) => const Divider(), )
Optimize List Scroll Performance
// Add cacheExtent for smoother scrolling ListView.builder( cacheExtent: 500, // Render items 500px off-screen itemCount: items.length, itemBuilder: (context, index) => ItemWidget(item: items[index]), )
// Use addAutomaticKeepAlives: false if items don't need to preserve state ListView.builder( addAutomaticKeepAlives: false, addRepaintBoundaries: true, itemCount: items.length, itemBuilder: (context, index) => ItemWidget(item: items[index]), )
Infinite Scroll / Pagination
class ProductListController extends GetxController { final _items = <Product>[].obs; List<Product> get items => _items;
final _isLoading = false.obs; bool get isLoading => _isLoading.value;
int _currentPage = 1; bool _hasMore = true;
final ScrollController scrollController = ScrollController();
@override void onInit() { super.onInit(); loadItems(); scrollController.addListener(_onScroll); }
@override void onClose() { scrollController.dispose(); super.onClose(); }
void _onScroll() { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { if (!_isLoading.value && _hasMore) { loadMore(); } } }
Future<void> loadItems() async { _isLoading.value = true; final result = await repository.getProducts(page: 1); result.fold( (failure) => {}, (products) { _items.value = products; _hasMore = products.length >= 20; // Assuming page size of 20 }, ); _isLoading.value = false; }
Future<void> loadMore() async { _isLoading.value = true; _currentPage++; final result = await repository.getProducts(page: _currentPage); result.fold( (failure) => _currentPage--, (products) { _items.addAll(products); _hasMore = products.length >= 20; }, ); _isLoading.value = false; } }
Image Optimization
Use cached_network_image
import 'package:cached_network_image/cached_network_image.dart';
// ❌ BAD - No caching, re-downloads every time Image.network('https://example.com/image.jpg')
// ✅ GOOD - Cached, with placeholders CachedNetworkImage( imageUrl: 'https://example.com/image.jpg', placeholder: (context, url) => const CircularProgressIndicator(), errorWidget: (context, url, error) => const Icon(Icons.error), fadeInDuration: const Duration(milliseconds: 300), memCacheWidth: 400, // Resize for memory efficiency )
Optimize Image Sizes
// Specify dimensions to avoid unnecessary rendering CachedNetworkImage( imageUrl: product.imageUrl, width: 200, height: 200, fit: BoxFit.cover, memCacheWidth: 200 * 2, // 2x for high DPI displays memCacheHeight: 200 * 2, )
// For list thumbnails, use lower resolution CachedNetworkImage( imageUrl: product.thumbnailUrl, // Server-side thumbnail width: 50, height: 50, memCacheWidth: 100, memCacheHeight: 100, )
Precache Images
@override void didChangeDependencies() { super.didChangeDependencies(); // Precache images that will be needed soon precacheImage( CachedNetworkImageProvider(product.imageUrl), context, ); }
Memory Management
Dispose Controllers and Listeners
class MyController extends GetxController { late final StreamSubscription _subscription; late final ScrollController scrollController; late final TextEditingController textController;
@override void onInit() { super.onInit(); scrollController = ScrollController(); textController = TextEditingController(); _subscription = someStream.listen((data) { // Handle data }); }
@override void onClose() { // CRITICAL: Dispose all resources scrollController.dispose(); textController.dispose(); _subscription.cancel(); super.onClose(); } }
Avoid Memory Leaks with GetX
// ❌ BAD - Permanent controller never disposed Get.put(MyController(), permanent: true);
// ✅ GOOD - Controller disposed when not needed Get.lazyPut(() => MyController());
// ✅ GOOD - Explicitly control lifecycle Get.put(MyController(), tag: 'unique-tag'); // Later: Get.delete<MyController>(tag: 'unique-tag');
Use WeakReference for Callbacks
class MyController extends GetxController { Timer? _timer;
@override void onInit() { super.onInit(); // Use WeakReference to avoid keeping controller alive _timer = Timer.periodic(Duration(seconds: 1), (timer) { if (!isClosed) { updateData(); } }); }
@override void onClose() { _timer?.cancel(); super.onClose(); } }
Lazy Loading and Code Splitting
Deferred Imports
// feature_a.dart - Large feature module import 'package:flutter/material.dart';
class FeatureAPage extends StatelessWidget { // Heavy feature implementation }
// main.dart - Lazy load feature import 'feature_a.dart' deferred as feature_a;
void navigateToFeatureA() async { await feature_a.loadLibrary(); // Load code on demand Get.to(() => feature_a.FeatureAPage()); }
Lazy Controller Initialization
// ❌ BAD - All controllers loaded at startup void main() { Get.put(HomeController()); Get.put(ProfileController()); Get.put(SettingsController()); runApp(MyApp()); }
// ✅ GOOD - Controllers loaded when needed class HomeBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => HomeController()); } }
GetPage( name: '/home', page: () => HomePage(), binding: HomeBinding(), // Loaded only when route accessed )
Animation Performance
Use AnimatedWidget
// ❌ BAD - Rebuilds entire widget tree class MyWidget extends StatefulWidget { @override State<MyWidget> createState() => _MyWidgetState(); }
class _MyWidgetState extends State<MyWidget> with SingleTickerProviderStateMixin { late AnimationController _controller;
@override Widget build(BuildContext context) { return Transform.scale( scale: _controller.value, child: ExpensiveWidget(), // Rebuilt every frame! ); } }
// ✅ GOOD - Only animated part rebuilds class ScaleTransition extends AnimatedWidget { const ScaleTransition({ required Animation<double> scale, required this.child, }) : super(listenable: scale);
final Widget child;
@override Widget build(BuildContext context) { final animation = listenable as Animation<double>; return Transform.scale( scale: animation.value, child: child, ); } }
// Usage ScaleTransition( scale: _controller, child: const ExpensiveWidget(), // Not rebuilt! )
Limit Simultaneous Animations
// ❌ BAD - Too many simultaneous animations class MyPage extends StatelessWidget { @override Widget build(BuildContext context) { return Column( children: List.generate(100, (index) => AnimatedContainer( duration: Duration(seconds: 1), // Each container animates independently ), ), ); } }
// ✅ GOOD - Stagger animations, limit concurrent class MyPage extends StatelessWidget { @override Widget build(BuildContext context) { return AnimatedList( initialItemCount: items.length, itemBuilder: (context, index, animation) { return FadeTransition( opacity: animation, child: ItemWidget(item: items[index]), ); }, ); } }
Profiling and Debugging
Use Flutter DevTools
Run app in profile mode (not debug!)
flutter run --profile
Open DevTools
Press 'w' in terminal or visit: http://localhost:9100
Key DevTools Features:
-
Performance: Identify janky frames, long build times
-
Memory: Track heap usage, find leaks
-
Network: Monitor API calls, payload sizes
-
Timeline: Visualize frame rendering
Identify Build Performance Issues
// Add debug print to measure build time @override Widget build(BuildContext context) { final stopwatch = Stopwatch()..start();
final widget = ExpensiveWidget();
if (kDebugMode) { print('Build took: ${stopwatch.elapsedMilliseconds}ms'); }
return widget; }
Performance Overlay
void main() { runApp(MyApp()); }
class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( showPerformanceOverlay: true, // Shows FPS graphs home: HomePage(), ); } }
Best Practices Checklist
Widget Optimization
-
Use const constructors wherever possible
-
Minimize Obx scope to smallest widget
-
Extract complex widgets into separate classes
-
Use proper keys for list items
-
Avoid unnecessary setState or update() calls
List Performance
-
Always use ListView.builder for dynamic lists
-
Implement pagination for large datasets
-
Add cacheExtent for smoother scrolling
-
Use addAutomaticKeepAlives: false when appropriate
Image Performance
-
Use cached_network_image for network images
-
Specify image dimensions
-
Use appropriate image resolutions
-
Precache critical images
Memory Management
-
Dispose all controllers in onClose()
-
Cancel stream subscriptions
-
Dispose animation controllers
-
Use lazyPut instead of put for GetX controllers
Code Organization
-
Use deferred imports for large features
-
Lazy-load controllers with bindings
-
Split large files into smaller modules
Animation
-
Use AnimatedWidget for custom animations
-
Limit simultaneous animations to 2-3
-
Use RepaintBoundary for expensive widgets
-
Prefer implicit animations (AnimatedContainer , AnimatedOpacity )
Profiling
-
Test in profile mode (not debug)
-
Use Flutter DevTools regularly
-
Monitor frame rendering times (< 16ms target)
-
Check memory usage with DevTools
-
Profile on real devices, not just emulators
Common Performance Anti-Patterns
Anti-Pattern 1: Unnecessary Rebuilds
// ❌ Obx wrapping entire screen Obx(() => Scaffold( body: Column(children: [ Text(controller.title.value), LargeWidget(), ]), ))
// ✅ Obx only on changing widget Scaffold( body: Column(children: [ Obx(() => Text(controller.title.value)), const LargeWidget(), ]), )
Anti-Pattern 2: Expensive Build Methods
// ❌ Heavy computation in build @override Widget build(BuildContext context) { final processedData = heavyComputation(rawData); // Runs every build! return Text(processedData); }
// ✅ Compute once, cache result class MyController extends GetxController { final _processedData = ''.obs;
@override void onInit() { super.onInit(); _processedData.value = heavyComputation(rawData); } }
Anti-Pattern 3: Not Disposing Resources
// ❌ Memory leak - controller never disposed class MyController extends GetxController { final StreamController<int> _controller = StreamController(); // Missing onClose() to dispose! }
// ✅ Proper disposal class MyController extends GetxController { final StreamController<int> _controller = StreamController();
@override void onClose() { _controller.close(); super.onClose(); } }
Performance Targets
Metric Target Tool
Frame time < 16ms (60 FPS) DevTools Performance
Build time < 5ms for simple widgets Debug prints
Memory usage < 100MB typical screen DevTools Memory
App startup < 2 seconds Stopwatch
Image load < 1 second Network tab
API response < 500ms Network tab