By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.
Flutter performance is about two things: not doing unnecessary work on the UI thread, and not blocking the UI thread with work that belongs elsewhere. Jank (dropped frames) happens when the build, layout, or paint phase of a frame takes longer than 16ms (for 60fps). At iStoria, we serve 5M+ users on devices ranging from flagships to low-end Android phones — keeping 60fps across that range requires disciplined architecture, not just clever tricks.
This guide covers the performance patterns we use: profiling methodology, build optimization, list virtualization, image handling, isolate offloading, and memory management. These are the patterns that separate a smooth app from a janky one at scale.
The first rule of performance optimization: never optimize without measuring. Human intuition about performance is wrong more often than it is right. The Flutter DevTools Performance tab shows you exactly where frames are dropping and what code is responsible.
The profiling workflow:
1. Run the app in profile mode (flutter run --profile). Debug mode is not representative — it disables optimizations and adds overhead. 2. Open DevTools → Performance tab → record a session while interacting with the app. 3. Look at the frame timeline. Red frames are jank (took >16ms). Yellow frames are close to the limit. 4. Click a red frame to see the flame chart — the stack trace of what happened during that frame. 5. Identify the function that took the most time. Optimize it. Re-measure.
Optimizing without profiling leads to cargo-cult optimizations: premature caching, unnecessary complexity, and optimizations in places that were never the bottleneck.
The build phase (constructing the widget tree) is the most common source of jank. The strategies:
Mark widgets const wherever possible. A const widget is constructed once at compile time and never rebuilt. This is the single most impactful build optimization.
// GOOD — const widget, never rebuilds
const Padding(
padding: EdgeInsets.all(16),
child: Text('Hello'),
)
// BAD — new Padding and EdgeInsets instance on every parent rebuild
Padding(
padding: EdgeInsets.all(16),
child: Text('Hello'),
)
A giant build() method that returns a 500-line widget tree rebuilds entirely when any state changes. Split it into smaller widgets, each managing its own state. When a child widget's state changes, only that child rebuilds — not the entire tree.
When using BLoC/Provider, use BlocSelector or context.select to rebuild only when the specific piece of state the widget cares about changes:
// Rebuilds only when lessons list changes, not when other LessonState fields change
BlocSelector<LessonBloc, LessonState, List<Lesson>>(
selector: (state) => state is LessonLoaded ? state.lessons : [],
builder: (context, lessons) => LessonList(lessons: lessons),
)
This prevents the widget from rebuilding on unrelated state changes. At iStoria, we use selectors on every list and detail widget — it eliminated the majority of our jank on scroll.
ListView.builder only builds the items visible on screen plus a small cache. ListView() (without builder) builds every item immediately. For any list with more than ~20 items, always use ListView.builder with itemExtent (if items have a fixed height) for maximum scroll performance.
For heterogeneous lists (mixed item types), use SliverList with itemBuilder. For infinite scroll with pagination, use CustomScrollView with slivers.
ListView.builder(
itemCount: lessons.length,
itemExtent: 72, // fixed height → Flutter skips layout calculation
itemBuilder: (context, index) => LessonTile(lesson: lessons[index]),
)
Images are the heaviest objects in a Flutter app. Unoptimized images cause memory spikes and jank on low-end devices.
1. Use cacheWidth / cacheHeight — decodes the image at the display size, not the source size. A 4000×3000 photo displayed at 200×150 should be decoded at 200×150, saving massive memory.
Image.network(
lesson.thumbnailUrl,
cacheWidth: 200, // decodes at 200px width, not the full source
cacheHeight: 150,
)
2. Use WebP instead of PNG/JPEG — WebP is ~30% smaller at the same quality. At iStoria, our build pipeline converts all article and lesson images to WebP automatically.
3. Use precacheImage for critical images — preloads the image before it is displayed, avoiding a pop-in on first render.
4. Use FadeInImage with a placeholder — shows a lightweight placeholder while the real image loads, avoiding layout shift.
Heavy computation (JSON parsing, image processing, cryptographic operations) blocks the UI thread and causes jank. Move it to an isolate via compute() or Isolate.run():
final parsed = await compute(parseLessonJson, rawJsonString);
compute() spins up an isolate, runs the function, and returns the result. The UI thread stays free. Use this for any operation that processes more than a few kilobytes of data or takes more than a few milliseconds.
At iStoria, we offload all JSON deserialization of large API responses to isolates. On low-end devices, this was the difference between 40fps and 60fps on the lesson loading screen.
Memory leaks in Flutter are usually caused by listeners and streams that are not disposed. Every StreamSubscription, TextEditingController, ScrollController, and AnimationController must be disposed in dispose().
The common leak: a BLoC that holds a reference to a widget's BuildContext (via a listener or callback) after the widget is unmounted. The BLoC outlives the widget, and the widget's element stays in memory.
The fix: never pass BuildContext to a BLoC or repository. If a BLoC needs to trigger navigation, use a navigation service or a global router key, not the widget's context.
Use DevTools Memory tab to detect leaks. Take a snapshot, navigate through the app, take another snapshot, and compare. If objects accumulate, you have a leak.
Platform channel calls (MethodChannel) are asynchronous, but if the native side does heavy work on the main thread, it causes jank on the Flutter side. Move native-side heavy work to a background thread (dispatch queue on iOS, background thread on Android).
At iStoria, our PowerSync sync engine runs entirely on a background thread on the native side. We learned this the hard way — the initial implementation did SQLite operations on the main thread, causing jank on every sync cycle.
lib/
├── core/
│ ├── performance/
│ │ ├── frame_monitor.dart # tracks FPS, logs jank in profile mode
│ │ └── memory_tracker.dart # detects retention spikes in DevTools
│ └── utils/
│ └── isolate_runner.dart # compute() wrapper for typed offloading
├── features/
│ └── lesson/
│ └── presentation/
│ └── widgets/
│ ├── lesson_list.dart # ListView.builder with itemExtent
│ ├── lesson_tile.dart # const constructor, granular selector
│ └── cached_lesson_image.dart # cacheWidth/cacheHeight optimized
// core/utils/isolate_runner.dart — typed compute() wrapper
import 'package:flutter/foundation.dart';
/// Offloads heavy computation to a background isolate.
/// Use for JSON parsing, data transformation, or any CPU-intensive work.
Future<T> runInIsolate<T, P>(
T Function(P) work,
P param,
) {
return compute(work, param);
}
// Example: parse a large API response off the UI thread
List<LessonModel> parseLessons(String json) {
final decoded = jsonDecode(json) as List;
return decoded.map((e) => LessonModel.fromJson(e)).toList();
}
// In the repository:
@override
Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
final rawJson = await remoteDatasource.fetchLessonsRaw(courseId);
// Parse on a background isolate — UI thread stays smooth
final models = await runInIsolate(parseLessons, rawJson);
return Right(models.map((m) => m.toEntity()).toList());
}
// presentation/widgets/lesson_tile.dart — const + granular selector
class LessonTile extends StatelessWidget {
final Lesson lesson;
const LessonTile({super.key, required this.lesson});
@override
Widget build(BuildContext context) {
return BlocSelector<LessonBloc, LessonState, bool>(
// Rebuild this tile ONLY when this specific lesson's completion changes
selector: (state) {
if (state is! LessonLoaded) return false;
return state.lessons
.firstWhere((l) => l.id == lesson.id,
orElse: () => lesson)
.isCompleted;
},
builder: (context, isCompleted) {
return ListTile(
leading: CachedLessonImage(url: lesson.thumbnailUrl),
title: Text(lesson.title),
trailing: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: isCompleted
? const Icon(Icons.check_circle, key: ValueKey('done'))
: const SizedBox.shrink(key: ValueKey('empty')),
),
);
},
);
}
}
// presentation/widgets/lesson_list.dart — virtualized list with itemExtent
class LessonList extends StatelessWidget {
final List<Lesson> lessons;
const LessonList({super.key, required this.lessons});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: lessons.length,
itemExtent: 72, // fixed height → skips per-item layout, maximum scroll FPS
itemBuilder: (context, index) {
return LessonTile(
key: ValueKey(lessons[index].id),
lesson: lessons[index],
);
},
);
}
}
// presentation/widgets/cached_lesson_image.dart — memory-optimized images
class CachedLessonImage extends StatelessWidget {
final String url;
const CachedLessonImage({super.key, required this.url});
@override
Widget build(BuildContext context) {
// Determine display dimensions from the layout
const displayWidth = 56.0;
const displayHeight = 56.0;
// Convert to physical pixels for cacheWidth/cacheHeight
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheW = (displayWidth * dpr).round();
final cacheH = (displayHeight * dpr).round();
return CachedNetworkImage(
imageUrl: url,
cacheWidth: cacheW, // decode at display size, not full source resolution
cacheHeight: cacheH,
fadeInDuration: const Duration(milliseconds: 150),
placeholder: (_, __) => Container(
width: displayWidth,
height: displayHeight,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
);
}
}
Add a print or a debugPrint in the build() method of the widget you suspect. If it prints more often than expected, it is rebuilding unnecessarily. In profile mode, use the DevTools Performance tab to see rebuild counts per frame. Alternatively, use the RepaintBoundary widget to isolate paint regions and the Flutter inspector's 'Select Widget Mode' to inspect the widget tree during interaction.
No. RepaintBoundary creates a separate layer, which helps when a complex subtree repaints independently (like a list item with an animation). But each RepaintBoundary adds memory and compositing overhead. Use it strategically on complex, independently-animating widgets — not on every widget. Profile before and after to confirm it helps.
Use an actual low-end device (1-2GB RAM, budget CPU) — the Android emulator on a slow setting is not representative. Run in profile mode and interact with the app normally. The DevTools Performance tab shows frame times and dropped frames. Focus on the 90th-percentile frame time, not the average — the worst frames are what users perceive as jank.
Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →
Book a 20-minute call · Download the CV (PDF) · See how I work