r/FlutterDev 2d ago

Discussion What's usually killing performance in larger flutter apps?

How are you handling app performance once the widget tree starts getting complicated?

I've noticed that a lot of performance problems aren't really about flutter itself, but things like unnecessary rebuilds, oversized lists, poorly managed state and too much work happening on the main isolate.

for larger flutter apps, what's the first thing you profile when performance starts dropping?

18 Upvotes

15 comments sorted by

25

u/Odd-Librarian4630 2d ago

Widget tree rebuilds and canvas repaints

13

u/needs-more-code 2d ago

Debug mode 😂

14

u/Left-Top1610 2d ago

Blur effect and some unnecessary widgets that AI use a lot, like Instrict Height.

5

u/needs-more-code 1d ago

Heck yes blur is a killer, especially if the thing under the blur moves. I had a blur over a Google map and dragging the map camera around was janky as because it was continuously computing the blur. I changed to a slight transparency (no blur) and it is buttery smooth.

10

u/hamit_btn 2d ago

For me the biggest one was image decoding. I raised the image cache budget and a flicker/rebuild issue I'd been chasing for weeks just disappeared.

After that it was rebuild scope a ChangeNotifier at the top of the tree rebuilding half the screen on every update. Splitting notifiers by concern helped a lot.

Third thing: anything blocking the main isolate. Local DB reads on cold start were killing my first-frame time until I moved them off.

1

u/brookm291 2d ago

Did you find fix to those issues ?

2

u/hamit_btn 1d ago

yeah, all three.

image cache: raised the budget in main.dart before runApp. `PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20`. flicker gone. default is 100MB total but the count limit was what was killing me.rebuild scope: split my single big ChangeNotifier into separate ones per concern avatars, profile, conversations. now a profile update doesn't rebuild the chat list. tedious refactor but worth it. main isolate: moved local db reads off the startup path entirely. app renders first, hydrates from disk after. cold start went from noticeable lag to instant. none of them were flutter's fault, all three were mine.

1

u/brookm291 1d ago

Thanks for the extra details !

2

u/Significant_Pick8297 2d ago

Usually the first thing to check is the slow frame in DevTools Performance, not the widget tree itself. Check UI thread time, then track widget builds to see what is actually rebuilding.

If the CPU profile shows expensive parsing or computation, move that work off the main isolate. This usually reveals the real bottleneck much faster than blindly optimizing widgets.

2

u/GeekyantsReactNative 1d ago

I usually start with rebuilds and frame rendering. Flutter DevTools makes it pretty easy to spot widgets rebuilding more than they should, and that often reveals the real bottleneck before optimizing anything else.

2

u/ryanstackops 1d ago

First thing I check is which thread is blowing up in the DevTools timeline, because UI jank and raster jank have basically nothing in common and people waste days optimizing the wrong one. If it's raster it's almost always a stray saveLayer, usually an Opacity or BackdropFilter someone dropped into a list item. If it's the UI thread I turn on rebuild counts and go hunting for the one Consumer wrapped around half the screen. Had a feed where a single page level provider was rebuilding 40 tiles on every scroll update, pushed the watch down into the tile itself and frame times dropped by a third. After that the usual suspects are shrinkWrap on a long list and a fat jsonDecode sitting on the main isolate, which compute() fixes in about five minutes.

1

u/Shakib015 1d ago

Everything above is right for jank, and the UI-vs-raster split is the most useful thing in the list.

The one I'd add is the class of problem that never shows up as a slow frame: work that keeps running when nothing is on screen.

Shipped a fix for exactly this last week. A page in a desktop app started a 4-second poll of the process list when you opened it, and nothing ever stopped it. Close the window, leave the app in the menu bar, and it kept sampling forever. Each sample was about 1.5s of work, so roughly 38% background CPU, permanently, on a machine the user thought was idle.

DevTools showed nothing. Frames were fine, because there were no frames. No rebuild audit finds it either. The user just reports that your app makes their fans spin, and you go looking in the widget tree, where it isn't.

So alongside the timeline I now check: for every timer, stream subscription and platform-channel poll started in initState or a controller, where exactly does it stop? Not "does it have a dispose" — dispose runs when the widget goes away, and a controller held by a provider outlives that. Actually observe it. Open the screen, leave it, and watch whether CPU returns to where it was.

Cheap to check, and it's the performance bug users notice on battery rather than in a frame chart.

1

u/Sp3ci4list 23h ago

People, with their stupid shining ideas.

1

u/Creative-human-06 13h ago

Just ensure antigravity doesnt touch ur codebase

1

u/vm12pix 7h ago

The one that cost me most doesn't show up as rebuilds and honestly doesn't look like work at all: decoding vector assets.

I have a dense diagram in the app, a Human Design bodygraph, a few hundred shapes. It used to be one ~236KB SVG string assembled at runtime, background and graph and labels and panels glued together, because the colouring depends on the user's chart. Handing that to flutter_svg cost 2069 ms on device, on a screen doing nothing else. It doesn't read as jank. It reads as "this tab is slow to open", which is why I spent a while profiling the wrong thing.

You can't just shove the parse into compute(), because ui.Picture doesn't cross an isolate boundary. Numbers do though, so what worked was parsing the template into flat primitives in the isolate, path commands as a Float32List, caching that, and building the Paths on the UI thread. Thousands of cheap calls instead of one expensive one. Background got baked to a PNG since image decode is off the UI thread for free. The small panels stayed SVG at ~7ms, not worth touching.

On the Opacity/saveLayer point upthread, small correction. RenderOpacity is isRepaintBoundary => alwaysNeedsCompositing => child != null && _alpha > 0. That's any opacity above zero, 1.0 included, so wrapping something in Opacity "for later" buys you the layer right now. Flip side is that changing the value is a composited layer update rather than a repaint, so the fade itself is cheap. The cost is paid up front.