Making Flutter apps feel instant
An app feels instant when it never misses a frame. This is how we find the slow frames in a Flutter app and fix them: rebuilds, lists, images, painting, isolates and startup.
Sixteen milliseconds, two threads
Nobody calls a good app fast. They say it feels smooth, or they say nothing at all. What people do notice is the opposite: a list that hitches when they fling it, a page transition that stutters, a tap that answers a beat late. Almost every time, the cause is the same. A frame wasn't ready when the screen wanted it.
On a 60 Hz screen, a new frame is due about every 16 milliseconds. Flutter splits the work for each one across two threads. The UI thread runs your Dart code: it builds widgets, lays them out and records what to paint in a layer tree. The raster thread takes that layer tree and turns it into pixels on the GPU.2 The two run side by side, so each gets the full budget for its half of the job: 16 ms to build and 16 ms to render.1 When either one runs over, the frame misses its slot, the screen shows the previous frame again, and that repeat is what people feel as jank.
A 120 Hz screen halves the budget. Flutter's own guidance is that on those devices you'll want frames in under 8 ms.1 So an app that felt smooth on last year's phone can stutter on a better one. The frames didn't get slower. The deadline got closer.
Measure before you fix anything, and measure the right build. Debug mode runs extra checks and compiles Dart just in time, so its frame times say little about what users get. Profile in profile mode, on a physical device rather than a simulator, and ideally on the slowest phone your users might reasonably own.2
# Profile mode, on a real device
flutter run --profileThen open the Performance view in DevTools. Its frames chart draws one pair of bars per frame and marks janky ones in red. Select one and the Frame analysis tab points at what made it slow.3 Read the pair before anything else. A tall UI bar means the problem is in your Dart code: too much building, too much layout, or other work on the main isolate. A tall raster bar means the scene is too expensive to draw. If both are red, start with the UI thread.2
Then write the numbers down. Flutter's performance metrics page recommends watching four stats for build time, and the same four for raster time: the average, the 90th percentile, the 99th percentile and the worst frame.4 Averages hide jank, so the tail is what matters. To collect the same numbers from real sessions, SchedulerBinding.addTimingsCallback hands you a FrameTiming for every frame, with its buildDuration and rasterDuration.5
Rebuild only what changed
The UI-thread problem we run into most is rarely one slow widget. It's a cheap widget built far more often than it needs to be. When you call setState(), every widget below that State rebuilds.1 Keep the state at the top of a screen and a tap on "Add to cart" rebuilds the app bar, the product grid and the bottom navigation, all to change one number on a badge.
The fixes are small habits, and all of them come from Flutter's own docs:
- Push state down to the leaves. Keep it in the smallest widget that shows it, so
setState()only reaches that subtree.6 - Use
constfor anything that never changes. A rebuild stops walking down the tree when it meets the same widget instance as last frame, and aconstwidget is always the same instance.1,6 - Split big
build()methods into widgets, not helper methods. A helper's output rebuilds with its parent every time. A separate widget gives Flutter something it can skip.6 - Listen narrowly.
ValueListenableBuilderrebuilds only its builder when the value changes, and anything you pass as itschildis built once and reused.7AnimatedBuildertakes achildfor the same reason.1
In code, the last setup is a few lines. The badge listens to a ValueNotifier, and the icon it wraps is built once:
final cartCount = ValueNotifier<int>(0);
// Only this builder runs when cartCount changes.
ValueListenableBuilder<int>(
valueListenable: cartCount,
builder: (context, count, child) => Badge(
label: Text('$count'),
child: child,
),
child: const Icon(Icons.shopping_bag_outlined),
)Anywhere else in the app, cartCount.value++ updates the badge and nothing more. To check rebuilds instead of guessing, turn on Track Widget Builds in the Performance view: every build() call then shows up in the timeline under its widget's name.3 Tracing costs a little time of its own, so switch it off again before you judge frame times.
Build only what is on screen
Lists are where rebuild costs multiply. Put a few hundred rows in a Column inside a SingleChildScrollView and Flutter builds and lays out every one of them before the first frame, whether the user ever scrolls that far or not. The docs say to avoid constructors that take a concrete list of children when most of them aren't visible.1ListView.builder takes a callback instead, and calls it only for the children that are actually visible.8 It also keeps a small cache area above and below the screen, so the next rows are laid out before they scroll in.9
If every row has the same height, tell the list. Setting itemExtent, or giving it a prototypeItem to measure, is more efficient than letting each child work out its own extent.8
ListView.builder(
itemCount: products.length,
itemExtent: 72, // every row is 72 logical px
itemBuilder: (context, i) => ProductRow(products[i]),
)Images are the other half of most feeds, and they cost memory before they cost time. Flutter keeps decoded images uncompressed so it can draw them, which means a 4K photo takes over 30 MB of memory, even when it's shown as a thumbnail.10 Pass cacheWidth or cacheHeight and the engine decodes and stores the image at that size instead.10
Set just one of the two and Flutter keeps the image's aspect ratio for the other.11 The values are image pixels, not layout pixels, so we multiply the display size by the device pixel ratio. Otherwise thumbnails look soft on dense screens.
final dpr = MediaQuery.devicePixelRatioOf(context);
Image.network(
product.photoUrl,
width: 320,
cacheWidth: (320 * dpr).round(),
)For an image the user is about to need, like the large photo on the next screen, precacheImage loads it into the image cache ahead of time, so it will probably appear faster when that screen opens. The same docs warn against pinning large images, or lots of them, in memory this way.12
Paint less
When the raster bar is the tall one, the scene is expensive to draw, and the usual suspect is saveLayer(). It allocates an offscreen buffer, draws into it, then blends the result back, and switching render targets like that is particularly disruptive on mobile GPUs.1 You'll rarely call it yourself. Widgets do it for you:ShaderMask, ColorFilter, and clips set to Clip.antiAliasWithSaveLayer among them.1,2 An Opacity with a value between 0 and 1 works the same way: it paints its child into an intermediate buffer first.13
That picture is also the rule for when you can skip the layer. If you're fading one shape, one icon or one line of text, draw it in a semi-transparent colour instead. The docs point out this only works when nothing in the shape overlaps.1 The Opacity docs give the plainest example: Container(color: Color.fromRGBO(255, 0, 0, 0.5)) is much faster than a red container wrapped in Opacity(opacity: 0.5).13
Animated fades need one more rule. Don't drive an Opacity from setState() on every tick, because that rebuilds the widget, and possibly its subtree, on every frame. Use FadeTransition with an animation you already have, or AnimatedOpacity for a simple change.13
// Rebuilds on every tick: avoid
Opacity(opacity: _fade, child: card)
// The animation drives the fade directly
FadeTransition(opacity: _controller, child: card)Clipping is cheaper than a layer, but it still costs. Flutter leaves it off by default (Clip.none). For rounded corners, use a borderRadius instead of a clip, and don't clip inside an animation: clip the image once, before you animate it.1 If a static part of the scene is being faded, rotated or moved, a RepaintBoundary around it might help.2 To find which effect is costing you, the Performance view can switch off clip, opacity and shadow layers one type at a time while the app runs.3
One kind of raster jank used to deserve a chapter of its own: an animation that stutters only the first time it runs, while the GPU compiles the shaders it needs. Impeller, Flutter's renderer, avoids this by precompiling a smaller, simpler set of shaders when the engine is built, so they don't compile while your app runs. As of Flutter 3.27 it's the default on iOS, where it's the only renderer, and on Android API 29 and up. Older Android devices, and ones without Vulkan, fall back to the legacy OpenGL renderer.14 If you still see jank on the first run of an animation, the docs' advice is to make sure you're actually on Impeller.15 In the frames chart, frames that compiled shaders are marked in dark red.3
Keep the main isolate free
By default, a Flutter app does all of its work on one isolate, the main isolate. Taps, your functions and every frame share its event queue.16 So a function that takes 200 ms doesn't just take 200 ms. At 60 Hz it takes about twelve frames with it, and the screen sits still until it's done. The docs' own example is a JSON file of about 20 MB, which would make the UI unresponsive for several seconds if it were parsed on the main isolate.16
The fix is often one call. Isolate.run spawns a new isolate, runs your function there and hands back the result.16
Future<List<Order>> parseOrders(String body) {
return Isolate.run(() {
final data = jsonDecode(body) as List<Object?>;
return data
.cast<Map<String, Object?>>()
.map(Order.fromJson)
.toList();
});
}A few things to know before you move work across:
- Isolates don't share memory; they pass messages. Strings, numbers and simple objects like a list of models travel fine. More complex objects, such as a
Futureor anhttp.Response, can fail.17 - A spawned isolate can't build widgets or read assets through
rootBundle. Load the file on the main isolate and pass the string in, as the docs' own example does.16 - The web has no isolates.
compute()compiles everywhere: on mobile it's the same asIsolate.run, and on the web it runs on the main thread.16,18 - It's meant for work that takes longer than a few milliseconds. For jobs closer to one millisecond, the docs suggest
SchedulerBinding.scheduleTaskinstead.18
Start fast, ship small
The first frame is the one users wait for longest. Flutter's metrics page measures startup as the time until WidgetsBinding.instance.firstFrameRasterized is true.4 Whatever your app does before that frame, the user spends looking at a splash screen.
Dead code is the easy part. In profile and release builds, the AOT compiler tree-shakes your Dart code, so code that's never reached doesn't ship.19 That includes branches for other platforms: wrap Windows-only code in if (Platform.isWindows) and an Android build leaves it out.20
Code you only need later can wait. With a deferred import, a library loads when you call loadLibrary(), and on Android and the web Flutter can download it as a separate component while the app runs.21 It suits a big feature that most users never open, like a reports module or an admin screen. On Android it's built on Play Store dynamic feature modules, so it needs some setup, and deferred loading only happens in profile and release builds.21
import 'reports.dart' deferred as reports;
Future<void> openReports(BuildContext context) async {
await reports.loadLibrary();
if (!context.mounted) return;
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => reports.ReportsScreen(),
));
}Then measure what you ship. Build with --analyze-size and Flutter writes a size file you can open in the DevTools app size tool: a treemap of what each package costs, and a diff view to compare two builds.19,20 For release builds, --split-debug-info writes the debug symbols to a directory you choose instead of into the app, and the docs say it can dramatically reduce code size.20 Keep those symbol files. Paired with --obfuscate, they're what flutter symbolize needs to turn a crash report back into a readable stack trace.22
# What does each package cost?
flutter build appbundle --analyze-size
# Release build, symbols kept out of the app
flutter build appbundle --obfuscate \
--split-debug-info=build/symbolsOne caution about the number you see: neither a debug build nor the release file you upload is what users download, because the stores reprocess uploads for each device. For the real figure, check the Play Console, or Xcode's app thinning report on iOS.20
None of this is exotic. It's a short list of habits: profile the right build, rebuild less, build only what's on screen, decode images at the size you show them, think twice before every layer, and keep heavy work off the main isolate. Users will never notice a frame that arrives on time. They notice every one that doesn't.