Mobile Apps

Making Flutter apps feel instant

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.

0 of 0 frames over 16.7 msIllustrative frame times
A frames chart in the style of DevTools: one pair of bars per frame, UI thread first, then raster. Switch to 120 Hz and the same frames are judged against an 8.3 ms budget. The frame times are illustrative.

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 --profile

Then 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 const for anything that never changes. A rebuild stops walking down the tree when it meets the same widget instance as last frame, and a const widget 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.ValueListenableBuilder rebuilds only its builder when the value changes, and anything you pass as itschild is built once and reused.7 AnimatedBuilder takes a child for the same reason.1
ShopPage0
TopBar0
Logoconst0
CartBadge00
ProductGridconst0
ProductCard0
ProductCard0
ProductCard0
BottomNavconst0
NavItem0
NavItem0
0/11widgets rebuilt on the last tap
setState() in ShopPage rebuilds every widget below it.
The same tap under three setups. Each row is a widget, and a flash means it rebuilt. Press “Add to cart” yourself, or switch setups.

In code, the last setup is a few lines. The badge listens to a ValueNotifier, and the icon it wraps is built once:

cart_badge.dart
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

Shop40 items
001Linen shirtClothing₹499
002Desk lampHome₹872
003Ceramic mugKitchen₹1,245
004Wool scarfClothing₹1,618
005NotebookStationery₹1,991
006Water bottleOutdoor₹2,364
007Canvas toteBags₹2,737
008Wall clockHome₹710
009Linen shirtClothing₹1,083
010Desk lampHome₹1,456
011Ceramic mugKitchen₹1,829
012Wool scarfClothing₹2,202
013NotebookStationery₹2,575
014Water bottleOutdoor₹548
015Canvas toteBags₹921
016Wall clockHome₹1,294
017Linen shirtClothing₹1,667
018Desk lampHome₹2,040
019Ceramic mugKitchen₹2,413
020Wool scarfClothing₹2,786
021NotebookStationery₹759
022Water bottleOutdoor₹1,132
023Canvas toteBags₹1,505
024Wall clockHome₹1,878
025Linen shirtClothing₹2,251
026Desk lampHome₹2,624
027Ceramic mugKitchen₹597
028Wool scarfClothing₹970
029NotebookStationery₹1,343
030Water bottleOutdoor₹1,716
031Canvas toteBags₹2,089
032Wall clockHome₹2,462
033Linen shirtClothing₹2,835
034Desk lampHome₹808
035Ceramic mugKitchen₹1,181
036Wool scarfClothing₹1,554
037NotebookStationery₹1,927
038Water bottleOutdoor₹2,300
039Canvas toteBags₹2,673
040Wall clockHome₹646
Items built
0/40builton screen
A phone scrolling a 40-item list, and which items exist as built widgets. A Column builds all 40 up front. ListView.builder builds what is on screen plus a small cache, and lets rows go as they scroll away.

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

3840 × 2160 px decoded384 × 216 px decoded
One cell is 384 × 216 px, the size the screen shows
Decoded in memory0 MB
Full-size decode3840 × 2160 × 4 B33.2 MB
cacheWidth: 384384 × 216 × 4 B0.33 MB
100× less memory, same thumbnail on screen
The example from Flutter's Image docs, drawn to scale: a 4K image shown at 384 × 216. Each cell is 384 × 216 pixels. A full decode keeps all hundred in memory. With cacheWidth, only the one the screen needs.

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

Opacity(opacity: 0.5)One fade for the whole groupRender target switches 0
Offscreen buffer
Screen
color: Color(0x80000000)50% alpha on each shapeRender target switches 0
No buffer
Screen
Why a group fade needs a buffer. Both lanes draw two shapes at 50%. Opacity draws them offscreen and blends the result once, so the overlap stays flat. A 50% colour on each shape goes straight to the screen, so the overlap gets darker.

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

Loading orders
Frames drawn 0Frames missed 0
Frames due
Main isolate
jsonDecode
Background
jsonDecode
on timemissedframe work200 ms parseSlowed 10×, illustrative
A 200 ms parse, slowed down ten times. The spinner turns one step per drawn frame. On the main isolate, frames stop and the spinner freezes. Inside Isolate.run, the parse gets its own lane and frames keep coming. Timings are illustrative.

The fix is often one call. Isolate.run spawns a new isolate, runs your function there and hands back the result.16

orders_repository.dart
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 Future or an http.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 as Isolate.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.scheduleTask instead.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/symbols

One 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.

References
1Flutter (2026). Performance best practices. docs.flutter.dev.
2Flutter (2026). Flutter performance profiling. docs.flutter.dev.
3Flutter (2026). Use the Performance view. docs.flutter.dev, DevTools.
4Flutter (2026). Performance metrics. docs.flutter.dev.
5Flutter (2026). FrameTiming class. api.flutter.dev.
6Flutter (2026). StatefulWidget class: performance considerations. api.flutter.dev.
7Flutter (2026). ValueListenableBuilder class. api.flutter.dev.
8Flutter (2026). ListView class. api.flutter.dev.
9Flutter (2026). ScrollView.scrollCacheExtent property. api.flutter.dev.
10Flutter (2026). Image class: memory usage. api.flutter.dev. Source of the 4K image at 384 × 216 example.
11Flutter (2026). ResizeImagePolicy enum. api.flutter.dev.
12Flutter (2026). precacheImage function. api.flutter.dev.
13Flutter (2026). Opacity class. api.flutter.dev.
14Flutter (2026). Impeller rendering engine. docs.flutter.dev.
15Flutter (2026). Improving rendering performance. docs.flutter.dev.
16Flutter (2026). Concurrency and isolates. docs.flutter.dev.
17Flutter (2026). Parse JSON in the background. docs.flutter.dev, Cookbook.
18Flutter (2026). compute function. api.flutter.dev.
19Flutter (2026). Use the app size tool. docs.flutter.dev, DevTools.
20Flutter (2026). Measuring your app's size. docs.flutter.dev.
21Flutter (2026). Deferred components for Android and web. docs.flutter.dev.
22Flutter (2026). Obfuscate Dart code. docs.flutter.dev.

I want to a.

Tell us where it is today, what you’re trying to achieve and what’s getting in the way.