Damn Technologies
Damn.Technologies
The Flutter jank wasn't Impeller. It was a setState in the wrong place.
FlutterPerformanceMobile

The Flutter jank wasn't Impeller. It was a setState in the wrong place.

We spent a week blaming the renderer on a mid-range Android phone. The dropped frames were coming from a parent widget that rebuilt the whole scaffold every time a status ticked.

Jeevaprakash G
Jeevaprakash G
Developer
Published On
August 11, 2026
Read Time
8 min read
The Flutter jank wasn't Impeller. It was a setState in the wrong place.

I still remember the phone. A Redmi, two years old, brightness cranked because we were sitting under a tube light in the client's office. The booth list should have been boring. Scroll, tap, update a status, done.

It wasn't boring. Every time a status flipped on someone else's device, the list hiccuped. Not a crash. Just that little stutter you feel in your thumb and immediately start lying to yourself about. "It's the device." "It's Impeller." "It's the emulator being weird." It wasn't the emulator. We were on hardware.

The week we wasted

We did the respectable things first. Bumped Flutter. Confirmed Impeller was actually on. Ran the app in profile mode. Opened DevTools like we were about to catch a villain.

The timeline was not subtle. The whole scaffold was lighting up. App bar. Bottom nav. A clock widget nobody had asked for. The list. All of it, every time one document changed.

That is the part I am slightly embarrassed to write down. I had put a listener too high in the tree. One setState on the shell, subscribed to "anything in this collection moved," and Flutter did exactly what I asked: it rebuilt the world.

A live operations list on a phone — the kind of screen that looks simple until it rebuilds too often

What the code actually looked like

This is the shape of the mistake. I have cleaned the names. The sin is the same.

class OpsShell extends StatefulWidget {
  const OpsShell({super.key});

  @override
  State<OpsShell> createState() => _OpsShellState();
}

class _OpsShellState extends State<OpsShell> {
  List<Booth> booths = [];

  @override
  void initState() {
    super.initState();
    boothRepo.watchAll().listen((next) {
      setState(() => booths = next);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(clockLabel())),
      body: BoothList(booths: booths),
    );
  }
}

If you have shipped Flutter for more than a month, you already know what happens next. The clock did not need the booths. The app bar did not need the booths. The list needed one row.

I had treated "realtime" like a personality trait for the entire page.

The fix was local, not clever

We pulled the listen down to the row. The shell went back to being a shell. The list became a ListView.builder with stable keys. The row that changed was the row that rebuilt.

class BoothTile extends StatelessWidget {
  const BoothTile({super.key, required this.boothId});

  final String boothId;

  @override
  Widget build(BuildContext context) {
    final booth = ref.watch(boothProvider(boothId));
    return booth.when(
      data: (value) => BoothCard(booth: value),
      loading: () => const BoothSkeleton(),
      error: (error, _) => BoothError(message: error.toString()),
    );
  }
}

That const BoothSkeleton() looks like nothing. It is not nothing. Const widgets are one of the few free wins left in Flutter, and we keep throwing them away because a generated screen used Container() everywhere.

We also stopped parsing the payload on the UI thread. The payload was not huge. It was just enough JSON, often enough, on a cheap phone, to make the raster thread wait. compute is unglamorous. It also made the scroll feel like a phone again.

DevTools will not flatter you. If the whole tree is yellow, you put the listen too high.

What I check now before I blame the framework

I have a short, slightly superstitious list. I run it before I tweet about Impeller, Skia, or "Flutter is slow on Android."

  1. Who calls setState, and how much of the tree can hear it?
  2. Does the list have real keys, or did I pass the index because it compiled?
  3. Are images decoded at the size they are shown, or at the size they were uploaded?
  4. Is any JSON, sorting, or filtering happening in build?
  5. Did I profile a debug build and then invent a performance story?

Impeller did help a little on that Redmi. I am not going to pretend it did nothing. It was not the bug. The bug was me broadcasting a single document change to a widget that owned the chrome.

The part that still surprises people

Clients do not describe this as "rebuild scope." They say the app feels cheap. That is the whole game on mid-range Android. You can have a clean architecture diagram and still ship a product that feels like a webview if one parent is too curious.

I like Flutter. We keep shipping it for Android, iOS, and web because the alternative is three codebases and a meeting about whose backlog is more important. I just do not let a status tick rebuild the clock anymore.

If your list stutters only when the data is live, do not start in the renderer. Start at the widget that is listening too loudly.

Share this article