The 16-Millisecond Budget
At 60 frames per second, Flutter has 16.67 milliseconds to prepare each frame. At 120fps, that's 8.33ms. When a frame takes longer than its budget, it gets dropped. The user feels that as a stutter, a freeze, or a general sense that the app is sluggish.
Jank — the industry term for this — isn't a vague quality problem. It's a measurable engineering failure: a frame exceeded its time budget. Everything in performance profiling is about finding where that budget went.
Flutter prepares each frame in four phases:
- Build — your widget
build()methods run. Flutter constructs the widget tree. - Layout — Flutter calculates sizes and positions for every element.
- Paint — Flutter records drawing operations (doesn't draw yet, just records).
- Composite/Raster — the GPU executes those drawing operations and produces the actual pixels.
Phases 1–3 run on the UI thread (your Dart code). Phase 4 runs on the Raster thread (the GPU pipeline). Both threads have their own 16ms budget. DevTools shows both.
This distinction matters immediately: a slow UI thread and a slow Raster thread have different causes and different fixes. Most performance guides treat them as one problem. They aren't.
Opening DevTools
Run your app in profile mode, not debug mode. Debug mode disables optimisations and adds overhead that makes everything look slower than it is.
flutter run --profileThen open DevTools. In VS Code, the shortcut is in the Flutter toolbar. From the terminal, flutter devtools opens it in the browser.
Navigate to the Performance tab. You'll see two things immediately:
- A frame chart at the top — each frame is a vertical bar. Blue bars are UI thread time, orange bars are Raster thread time. Red or yellow means the frame exceeded its budget.
- A timeline below — a detailed breakdown of what happened during the selected frame.
Click on a red bar. The timeline expands to show every event that happened during that frame on both threads. You're looking for the tall blocks — the operations that consumed the most time.
That's the starting point for everything that follows.
The Most Common Culprit: Rebuilding Widgets That Didn't Need to Change
In most Flutter apps, the biggest performance problem is rebuilding too much of the widget tree too often. It's the first thing to check and the most frequently fixable.
Why it happens. When state changes, Flutter rebuilds the widget subtree below the point where that state lives. If state lives high in the tree — in a parent widget, in a BLoC listened to by a top-level widget — then a small change triggers an enormous rebuild.
The Widget Inspector in DevTools shows this. Enable Track Widget Rebuilds in the Performance settings, then interact with your app. The inspector will highlight widgets that rebuilt during each frame with a colour overlay — the brighter the colour, the more times it rebuilt.
The `const` constructor fix. A const widget is created once and cached. Flutter knows it can't change, so it skips it entirely during rebuilds. This is the lowest-effort, highest-value habit in Flutter development.
// ❌ Flutter rebuilds this on every parent rebuild
AppBar(
title: Text('Orders'),
leading: Icon(Icons.arrow_back),
)
// ✅ Flutter skips this entirely — it's constant
const AppBar(
title: Text('Orders'),
leading: Icon(Icons.arrow_back),
)Apply const to every widget that doesn't depend on runtime state. Your IDE will flag the opportunities. It takes seconds and meaningfully reduces build phase time.
The state scope problem. const only helps for static widgets. For the ones that depend on state, the question is: how much of the tree is listening to how much of the state?
// ❌ This BlocBuilder rebuilds the entire screen whenever ANY order state changes
BlocBuilder<OrderBloc, OrderState>(
builder: (context, state) {
return Scaffold(
appBar: const OrderAppBar(),
body: OrderList(orders: state.orders),
floatingActionButton: OrderActionButton(isLoading: state.isLoading),
);
},
)
// ✅ Each widget rebuilds only when its specific piece of state changes
Scaffold(
appBar: const OrderAppBar(),
body: BlocBuilder<OrderBloc, OrderState>(
buildWhen: (previous, current) => previous.orders != current.orders,
builder: (context, state) => OrderList(orders: state.orders),
),
floatingActionButton: BlocBuilder<OrderBloc, OrderState>(
buildWhen: (previous, current) => previous.isLoading != current.isLoading,
builder: (context, state) => OrderActionButton(isLoading: state.isLoading),
),
)buildWhen is one of the most underused tools in flutter_bloc. It gives each builder a condition: only rebuild if this specific part of the state changed. The rest of the tree stays untouched.
`RepaintBoundary` for expensive subtrees. Some widgets are expensive to paint — a complex gradient, a custom chart, an animated background. If those widgets don't change when surrounding state changes, wrap them in RepaintBoundary:
RepaintBoundary(
child: ComplexChartWidget(data: staticData),
)Flutter treats everything inside a RepaintBoundary as its own layer. When the parent repaints, this subtree is composited from a cached texture rather than repainted from scratch. The GPU handles it in microseconds instead of milliseconds.
Lists: The `ListView.builder` Lesson
This one is concrete and immediate. If you're rendering a list without ListView.builder, you're building every item whether the user can see it or not.
// ❌ Builds all 500 items immediately, even if only 8 are visible
ListView(
children: orders.map((order) => OrderCard(order: order)).toList(),
)
// Also ❌ — same problem
Column(
children: orders.map((order) => OrderCard(order: order)).toList(),
)
// ✅ Builds only visible items plus a small buffer
ListView.builder(
itemCount: orders.length,
itemBuilder: (context, index) => OrderCard(order: orders[index]),
)ListView.builder is lazy. It asks for item at index n only when that item is about to scroll into view. A list of 500 orders renders as fast as a list of 10, because at any given moment only ~10 are actually built.
For lists with separators between items:
ListView.separated(
itemCount: orders.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) => OrderCard(order: orders[index]),
)For very long lists where items have variable heights and need to jump to a specific position, CustomScrollView with SliverList gives more control:
CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => OrderCard(order: orders[index]),
childCount: orders.length,
),
),
],
)The rule is simple: if a list can grow past a handful of items in real usage, use a builder variant. Always.
What Blocks the UI Thread
The UI thread runs all your Dart code. Every build() method, every onEvent handler in your BLoC, every use case execution — all of it runs on one thread, and that thread is sharing its 16ms budget with the frame pipeline.
Heavy synchronous work on the UI thread is a freeze. Not jank — a full freeze. The app stops responding entirely until the work is done.
Common culprits:
// ❌ Parsing a large API response on the UI thread
final data = jsonDecode(response.body); // 5MB JSON — this might take 80ms
final products = (data['products'] as List)
.map((e) => ProductModel.fromJson(e))
.toList();
// ❌ Sorting a large list on the UI thread
final sorted = products.sortedBy((p) => p.price); // 10,000 items
// ❌ Writing to disk synchronously
final file = File(path);
file.writeAsStringSync(largeContent); // Blocks until doneIf you see a long solid block in the UI thread timeline — not many small events but one large one — this is the likely cause.
The fix is Isolates.
Isolates: Moving Work Off the Main Thread
Dart is single-threaded. But "single-threaded" means the default execution context is single-threaded — it doesn't mean you can't run code in parallel. Isolates are separate execution contexts, each with their own memory, running in parallel with the UI thread.
When you move heavy computation to an Isolate, the UI thread stays free. The frame pipeline keeps running. The user keeps scrolling. When the Isolate finishes, it sends the result back and the UI updates.
The simplest way to use an Isolate is compute() — a Flutter convenience function that spawns an Isolate, runs a function, returns the result, and cleans up:
// The function that will run in the Isolate
// Must be a top-level function or static method — not a closure
List<Product> _parseProducts(String responseBody) {
final data = jsonDecode(responseBody) as Map<String, dynamic>;
return (data['products'] as List)
.map((e) => ProductModel.fromJson(e as Map<String, dynamic>).toDomain())
.toList();
}
// In your use case or repository
Future<List<Product>> fetchProducts() async {
final response = await _httpClient.get('/products');
// ✅ Heavy parsing runs in a separate Isolate — UI thread stays free
final products = await compute(_parseProducts, response.body);
return products;
}compute() covers most cases. For longer-running work that needs to send progress updates back, or for persistent background tasks, Isolate.run() (available since Dart 2.19) gives a cleaner API:
// Isolate.run — like compute() but cleaner syntax
final products = await Isolate.run(() => _parseProducts(response.body));For truly persistent two-way communication — a background sync process, a WebSocket handler, real-time processing — you need Isolate.spawn() with a ReceivePort:
void _backgroundSyncIsolate(SendPort mainSendPort) {
// This runs in its own Isolate
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort); // Send our port to main
receivePort.listen((message) {
if (message == 'sync') {
// Do the sync work
final result = performSync();
mainSendPort.send(result);
}
});
}
// In your service
Future<void> startBackgroundSync() async {
final receivePort = ReceivePort();
await Isolate.spawn(_backgroundSyncIsolate, receivePort.sendPort);
final isolateSendPort = await receivePort.first as SendPort;
// Now you can send messages to the Isolate
isolateSendPort.send('sync');
}One important constraint: Isolates can't share memory. You send data between them by message passing — the data gets copied. This means sending a 10MB object between Isolates copies 10MB. For large data transfers, prefer TransferableTypedData which transfers ownership without copying.
The connection to Clean Architecture. If you've been following the Clean Architecture article, the right place for Isolate usage is in data layer classes. The domain layer doesn't know about Isolates — it just defines what needs to happen. The repository implementation decides how it happens, which might include handing off heavy work to an Isolate.
class GetProductsUseCase {
final ProductRepository _repository;
Future<Either<Failure, List<Product>>> execute() async {
// Repository handles the Isolate internally — use case doesn't care
return _repository.getAll();
}
}The use case stays clean. The heavy lifting is an infrastructure decision.
Memory Leaks: The Slow Burn
Jank is immediate and obvious. Memory leaks are slower and more dangerous — the app gradually consumes more and more RAM until the OS kills it, or it becomes so slow the user abandons it.
The DevTools Memory tab shows a live graph of heap usage. Interact with your app normally. If the heap climbs and never comes back down — if navigating away from a screen doesn't release its memory — you have a leak.
The most common causes in Flutter:
Streams not cancelled:
// ❌ StreamSubscription lives forever
class OrderBloc extends Bloc<OrderEvent, OrderState> {
late StreamSubscription _subscription;
OrderBloc(Stream<OrderUpdate> updates) : super(OrderInitial()) {
_subscription = updates.listen((update) {
add(OrderUpdateReceived(update));
});
// Never cancelled — the Bloc and Stream are stuck alive
}
}
// ✅ Cancel in close()
@override
Future<void> close() {
_subscription.cancel();
return super.close();
}Controllers not disposed:
// ❌ These hold onto resources after the widget is gone
class _OrderFormState extends State<OrderForm> {
final _controller = TextEditingController();
final _animation = AnimationController(vsync: this, duration: Duration(milliseconds: 300));
// No dispose() — both objects leak
}
// ✅
@override
void dispose() {
_controller.dispose();
_animation.dispose();
super.dispose();
}The rule: anything you create in initState or a constructor that holds resources — controllers, subscriptions, timers — needs a corresponding dispose() or cancel() call. Without it, the object keeps its resources alive after the widget is gone.
To find leaks in DevTools: take a heap snapshot, navigate away from a screen, take another snapshot, compare. Objects from the first screen that appear in the second snapshot are candidates for leaks. Filter by the class name of your widgets or BLoCs.
The Raster Thread: When the Problem Isn't Your Dart Code
If the UI thread timeline looks fine but frames are still dropping, look at the Raster thread. A slow Raster thread means the GPU pipeline is struggling — usually one of these causes:
Too many layers. Every RepaintBoundary, every opacity animation, every clipping operation creates a GPU layer. Layers need memory and compositing time. Hundreds of layers is a problem.
`saveLayer()` calls. This is the most expensive GPU operation in Flutter. It happens when you use certain effects: ColorFilter, ShaderMask, BackdropFilter, blending modes on Container. Used intentionally and sparingly, fine. Used inside a ListView.builder item — the GPU executes it once per visible item per frame.
// ❌ BackdropFilter inside a list item — saveLayer() per item per frame
ListView.builder(
itemBuilder: (context, index) => Stack(
children: [
OrderCard(order: orders[index]),
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: const SizedBox.expand(),
),
],
),
)
// ✅ Apply blur effects sparingly, outside of scrolling listsImpeller and why it changed things. Before Impeller, Flutter used Skia as its rendering engine. Skia compiled GPU shaders at runtime — the first time a particular animation or effect played, the GPU spent time compiling the shader. Users felt this as a one-time stutter when a screen first loaded or an animation first ran. There was no way to fully eliminate it.
Impeller, Flutter's current default renderer, pre-compiles shaders at build time. There's no runtime compilation stutter. For most apps this is transparent — you get better frame consistency without doing anything. The practical implication: if you're seeing Raster thread slowness in a modern Flutter app on Impeller, it's almost certainly a layer count or saveLayer() problem, not a shader issue.
Putting It Together: A Profiling Workflow
When an app feels slow, the process is:
- Run in profile mode.
flutter run --profile - Open DevTools → Performance tab.
- Reproduce the slow behaviour.
- Click the worst red frame. Look at the timeline.
- Is it the UI thread or Raster thread?
- UI thread: look for expensive build() calls, long operations in event handlers, or heavy computation. Check if ListView.builder is missing.
- Raster thread: look for excessive layers, saveLayer(), complex clipping.
- Enable Widget Rebuild tracking for UI thread problems. Find what's rebuilding too often.
- Move heavy computation to an Isolate. Anything over ~2ms of synchronous work.
- Check the Memory tab for leaks if the app slows down over time rather than immediately.
Performance profiling is not a phase at the end of a project. It's a practice. The first time you use DevTools on a real app, you will find something surprising — something that looked fine in the code and was quietly consuming budget on every frame. The second time, you'll look for that category of problem earlier. Eventually the habits become automatic: const where possible, ListView.builder for lists, Isolates for heavy work, and DevTools when something still feels wrong.