Skip to content
Tech Stack15 August 2026 · 10 min read

Flutter Performance in 2026: Lists, Images, and Animations

Lists that build more than they show, images that decode larger than they display, animations that rebuild too wide. Where Flutter frames actually go, and how to find yours.

Flutter Performance in 2026: Lists, Images, and Animations

Most Flutter performance optimization work lands in three places: lists that build more than they show, images that decode larger than they display, and animations that rebuild wider than they move. Everything else is measurement, and measurement is the part people skip.

I build Flutter apps across six platforms from one codebase. BookBed, the property management SaaS I have been running solo since 16 October 2025, ships to iOS, Android, Web, macOS, Linux and Windows out of a single Dart tree on Firebase and Stripe. Its main screen is a Gantt-style booking timeline that scrolls in two directions at once. That screen punishes all three mistakes above, and it punishes them differently on every target.

What Actually Causes Flutter Jank?

Two strips of pale paper tape running across a warm beige desk in hard directional light, the left strip lying perfectly flat while the right strip has buckled into a tight concertina of ripples that throws a sawtooth shadow

Jank comes from blowing a frame budget on one of two threads: the UI thread that runs your Dart, or the raster thread that draws what your Dart produced.

Flutter's performance best practices put a hard number on it. On a 60 Hz display you have 16 ms per frame, split as 8 ms to build and 8 ms to raster. On a 120 Hz panel, and most flagship Android phones sold in the last three years ship one, the entire budget drops under 8 ms.

Which thread blew it matters more than the total. A slow UI thread means your Dart is doing too much inside a frame: rebuilding subtrees that did not change, or sorting a list inside build(). A slow raster thread means the layer tree you handed the engine is expensive to draw, usually from saveLayer(), stacked opacity, or clipping. Flutter's profiling guide is blunt about the second case. You cannot reach into the raster thread, so when it is slow, it is still your Dart code that put it there.

Measure in profile mode, on a physical device. Debug-mode numbers are fiction. For the shortlist of what to actually run while you do that, I keep one in my roundup of Flutter developer tools, and DevTools' Frame Analysis tab is the first stop, because it names the guilty thread before you start guessing.

Why Is My Flutter ListView Slow?

A long accordion-folded paper concertina spilling off the edge of a wooden desk toward a concrete floor, with a lamp lighting only four folds in the middle of its run while every other fold falls into deep shadow

Almost always because something forced it to size or build items it never puts on screen: shrinkWrap, intrinsic sizing, or a non-builder constructor holding every child at once.

That ListView.builder is lazy and ListView(children: [...]) is not is the flutter listview performance advice everyone already has. The interesting failures start after you have done that part.

Take shrinkWrap. The API docs say shrink wrapping "is significantly more expensive than expanding to the maximum allowed size because the content can expand and contract during scrolling" (ScrollView.shrinkWrap). People reach for it to nest a list inside a Column, and the honest fix is almost always a CustomScrollView with slivers instead, because a SliverList composes with other scrollable pieces without needing to know its own height.

Then intrinsic passes, which are quieter and worse. If you want every cell sized to the biggest or smallest cell, the framework has to poll all of them twice. The best-practices page gives two ways out: set cell sizes up front, or nominate one anchor cell and size the rest relative to it. Same idea powers itemExtent and prototypeItem, which the ListView docs describe as "more efficient than letting the children determine their own extent because the scrolling machinery can make use of the foreknowledge of the children's extent."

This is exactly why BookBed's timeline cells are locked at 50, 42, 100 and 60 logical pixels across every platform, with no responsive breakpoints reshaping them. The original reason was drag math, not frames. The scroll win was a side effect I only noticed later, profiling on a mid-range Android handset.

One more thing the docs say out loud and everyone forgets: off-screen children are not recycled the way an Android RecyclerView recycles rows. "When a child is scrolled out of view, the associated element subtree, states and render objects are destroyed." Scroll back and you pay the full construction cost again. If your item widget does real work in its constructor or initState, a fast scroll is a loop of that work.

ApproachBuilds off-screen items?Extra costReach for it when
ListView(children: [...])Builds all of them, alwaysWhole list constructed at startupUnder ~20 static rows and you know it
ListView.builderOnly the viewport plus a cache bandPer-item construction repeats on scroll-backA standalone list is the whole screen
SliverList in CustomScrollViewSame laziness as the builderSlightly more setup codeThe list shares a scroll view with headers, grids, or a collapsing app bar
ListView(shrinkWrap: true)Lazy, but re-measures constantlySize recomputed as the scroll position movesGenuinely never, if a sliver will do

The cache band is worth a number. Flutter's viewport keeps a default cache extent of 250.0 logical pixels beyond each edge of the visible area, so a "visible items only" mental model is already slightly wrong.

And that knob changed recently. Flutter 3.44 deprecated cacheExtent and cacheExtentStyle across ListView, GridView, CustomScrollView and Viewport, replacing both with a single scrollCacheExtent taking a ScrollCacheExtent.pixels(500.0) or ScrollCacheExtent.viewport(0.5) object (Flutter breaking changes, 2026). The migration guide sells it as clearer intent. The pull request behind it, merged by chunhtai on 10 February 2026, quietly fixes something nastier: with shrinkWrap: true the main-axis extent is double.infinity, the viewport-style cache calculation produced double.NaN, and the app crashed. Another reason to stop reaching for shrinkWrap.

Images Decode Bigger Than You Think

A huge unrolled architectural drawing covering a wooden desk, with a single stamp-sized print of the same design resting alone at its centre and a faint coffee ring stain near the curled top corner

A decoded image costs roughly width times height times four bytes in memory, regardless of the box you display it in. A 4000 by 3000 photo is about 48 MB resident while it renders into a 120-pixel avatar.

Flutter's ImageCache is an LRU holding "up to 1000 images, and up to 100 MB". Two of those photos and you have consumed most of the cache. Worse, a single image larger than maxByteSize is silently not cached at all, which has been the behaviour since Flutter 1.17 removed the old auto-expansion. So the biggest images in your app are also the ones being re-decoded most often. That is the failure mode: not a crash, just a scroll that gets slower the more photos it has passed.

The fix is decoding at display size instead of source size. cacheWidth and cacheHeight on Image.network and Image.asset, or a ResizeImage wrapper when you are using CachedNetworkImageProvider and cannot pass them directly. Saropa Contacts documented the payoff in November 2024, cutting per-image memory from 1.5 MB to under 250 KB across a screen holding more than 300 images, using WebP plus memCacheWidth and memCacheHeight (Saropa engineering write-up).

Ask the server for dimensions alongside the URL. If your API returns width and height with each image record, every client can size its decode correctly without a round of guessing, and you stop shipping cacheWidth: 300 constants that go stale the first time a designer changes a card layout.

Animations: Scope the Rebuild, Not the Frame Rate

An animation that drops frames is usually rebuilding a subtree that does not animate, sixty times a second, for nothing.

AnimatedBuilder has a child parameter for exactly this. The AnimatedBuilder docs put it plainly: if the builder callback contains a subtree that does not depend on the animation, "it's more efficient to build that subtree once instead of rebuilding it on every animation tick." Pass it as child and it comes back into the builder already built. Move the static half across and the tick gets cheap.

const constructors do the same job one level up. Flutter's best-practices page says they "allow Flutter to short-circuit most of the rebuild work", and the discipline is worth enforcing with flutter_lints rather than remembering. Pair it with keeping setState() low in the tree, since every descendant of the calling State rebuilds whether or not it changed.

RepaintBoundary is where I see the most cargo-culting. It isolates painting, not building and not layout, so it does nothing for a subtree whose problem is rebuild scope. Wrap something whose content changes every single frame and you have added a texture upload per frame to the GPU's workload. Actually, let me back up. It is still the right tool for a small, constantly-repainting widget sitting inside a large static one, like a progress spinner over a rendered document. The mistake is applying it before you know whether paint is the bottleneck.

Rendering itself got quieter here. Impeller is now the only engine on iOS, the default on Android API 29 and above with an OpenGL fallback below that, and as of Flutter 3.47 the default on macOS, Linux and Windows too (Impeller docs). It precompiles shaders at build time, which retired the first-run animation stutter that used to make people think their animation code was wrong when it was fine.

Where Flutter Performance Optimization Actually Starts

Not with a technique. With one janky screen, in profile mode, on the cheapest device you support. Everything above is a hypothesis until a timeline chart tells you which thread is late.

When Does Flutter Need an Isolate?

Only when a computation is long enough to make the UI stutter. Flutter's concurrency guide refuses to give a size threshold and names jank as the sole trigger.

Its list of usual suspects is short and specific: reading from a local database, parsing large data files, processing photos or audio, applying filters to complex lists. Not "any loop over 1000 items."

The reason to be conservative is that isolates are not free. Spawning one costs, and every object you pass in or out gets copied. For repeated small jobs, the copy tax can outrun what you saved.

When it is genuinely warranted:

  1. Reproduce the jank in profile mode and confirm the UI thread, not the raster thread, is the one over budget.
  2. Find the function in the CPU profiler's flame chart. Guessing which one it is has a poor hit rate.
  3. Move it behind Isolate.run, or compute() if the app also targets web, where isolates do not exist and compute() falls back to the main thread.
  4. Re-measure. If the frame time did not move, the work was not the problem and you have added copying overhead for nothing.
  5. Only if the same job runs repeatedly, replace the short-lived isolate with a long-lived one and a message port.

BookBed's iCal sync is the one place I reach for this. Parsing several aggregator calendars and diffing them against existing bookings is exactly the "parsing and decoding large data files" case, and it happens while the user is looking at a calendar that must keep scrolling.

Pick One List and Time It

Two fixes carry most of the weight for almost no effort. Pass child to your AnimatedBuilder, and set cacheWidth on every remote image that appears in a scrolling list.

For anyone weighing FlutterFlow against hand-written Flutter: none of this changes. FlutterFlow generates Flutter, so a generated list has the same frame budget and the same intrinsic-pass trap. I ship custom widgets into that ecosystem myself, eight templates and 40+ custom widgets on the FlutterFlow Marketplace, and the performance questions buyers send me are the same three every time.

So, which one is it in your app? Open DevTools on your worst screen, scroll it for ten seconds in profile mode, and look at which thread is over 8 ms. I would genuinely like to know how often it turns out to be images.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Why is my Flutter ListView slow even with ListView.builder?

Because laziness only covers construction, and something else in the list is still doing per-frame work. The usual culprits are `shrinkWrap: true`, which forces the scroll view to recompute its size as the position changes, and intrinsic sizing, where asking every cell to match the biggest or smallest one makes the framework measure all of them twice. Set `itemExtent` or `prototypeItem` if your rows are a known height. Also check what your item widget does in its constructor and `initState`, because off-screen children are destroyed rather than recycled, so a fast scroll pays that cost repeatedly.

How does Flutter image caching work, and what are its limits?

Flutter keeps a least-recently-used `ImageCache` of up to 1000 decoded images and up to 100 MB total. Decoded is the important word: memory cost is roughly width times height times four bytes, independent of the widget you display the image in, so one 4000x3000 photo occupies about 48 MB. Since Flutter 1.17, an image larger than the cache's `maxByteSize` is not cached at all rather than growing the cache, which means your heaviest images get re-decoded most often. Decode at display size with `cacheWidth` and `cacheHeight`, or wrap the provider in `ResizeImage`.

What is the fastest way to fix Flutter animation performance?

Pass the non-animating part of the subtree to `AnimatedBuilder`'s `child` parameter instead of building it in the callback. Anything inside the builder is rebuilt on every animation tick, so a static header or card body sitting there costs you sixty rebuilds a second for no visual change. After that, add `const` constructors wherever the analyzer allows, and keep `setState()` as low in the tree as the change permits, since every descendant of the calling `State` rebuilds. Only reach for `RepaintBoundary` once profiling shows painting, rather than building, is the bottleneck.

When should I move work to a Flutter isolate?

Only when a computation is long enough to cause visible UI jank, which is the one hard rule Flutter's concurrency guide gives. There is no item count or millisecond threshold that automatically justifies an isolate, because spawning one costs time and every object passed in or out is copied. Confirm in profile mode that the UI thread rather than the raster thread is over budget, find the function in the CPU profiler, then move it behind `Isolate.run`. Use `compute()` instead if the app also targets web, where isolates do not exist. Re-measure afterwards.

Is Impeller enabled by default in Flutter now?

Yes on every platform except web. Impeller is the only rendering engine on iOS with no way to switch back to Skia, and it is the default on Android API 29 and above, falling back to the legacy OpenGL renderer on older devices or hardware without Vulkan. As of Flutter 3.47 it is also the default on macOS, Linux and Windows, with the opt-out flag slated for removal in a later release. Flutter web still renders with Skia. Because Impeller precompiles shaders at build time, first-run animation stutter is largely gone.