Skip to content
ALI HAITHAM·TECH·responds in <1h
  • Homestart here
  • Workcase studies, projects
  • Serviceswhat I will build for you
  • Writingessays, series
  • Traincourses, labs, cohorts
  • Aboutthe engineer behind this site
Sign inStart a project
ALI HAITHAM · TECH
  • Home↗
  • Work↗
  • Services↗
  • Writing↗
  • Train↗
  • About↗
Sign inStart a project
Online
ALI HAITHAM·TECH

Engineering studio. Damascus, GMT+3.

aliyosef.online

Studio

  • Work
  • Writing
  • Training
  • About
  • Contact me

Portal

  • Sign in
  • Open a ticket
  • Track project
  • My account

Resources

  • Docs
  • Status
  • Changelog
  • Brand kit
  • Privacy
  • Terms

Newsletter

Field notes and tech news. Weekly. No fluff.

Free. Unsubscribe anytime.

Find me elsewhere
© 2026 Ali Haitham Yosef. All rights reserved.Hand-built in React 19. No frameworks of frameworks.Last deployed · 2026-05-08All systems operational
  1. Home/
  2. Train/
  3. Labs/
  4. Profile a slow Flutter list
LAB03

Profile a slow Flutter list

A scrolling list that drops to 35fps on a budget Android is not "the platform being slow," it is a stack of small mistakes you can find with the tools shipped in the Flutter SDK. This lab gives you a deliberately slow list, walks you through three diagnostic tools (DevTools timeline, the performance overlay, and a small custom frame logger), and applies four fixes that bring it to a steady 58fps on a Snapdragon 4-series device.

Start the lab↓Open the repo↗
min
75
STACK
Flutter
LEVEL
Beginner
/02 — SETUP

What you will build

A 35fps list, three diagnostic tools, four fixes. From 35 to 58fps on a budget Android.

Prerequisites

  1. 01Flutter 3.16+ installed and a working device or emulator.
  2. 02You have built at least one Flutter screen that uses ListView.builder.
  3. 03A profile-mode build target. Debug-mode is misleading for performance work.
/03 — WALKTHROUGH
/04 — RESOURCES

Take it with you.

  • ⌥
    RepositoryStarter code on GitHub.
  • ↓
    Zip downloadSame code, no git required.
/USED IN

Courses that pair with this lab.

  • Offline-first Flutter→
/05 — NEXT
Try this next04Set up an LLM eval suiteAnother short, free lab.→
Or go deep

Offline-first Flutter

Profiling lists is one chapter. The full course covers offline storage, sync engines, and the architecture decisions that make those budget Androids feel native.

See the course→

What we are measuring against#

Open the starter project and run it in profile mode on a budget Android (Snapdragon 4-series, 4GB RAM, mid-tier OEM skin). The home screen renders a list of 5,000 items, each with an image, two text rows, and a trailing chevron. The list scrolls at roughly 35fps with periodic dropped frames into the low 20s. Our goal is a steady 58fps.

If you only have an emulator or a high-end phone, the numbers will look different but the shape of the fixes is identical. Profile mode is non-negotiable: debug-mode overhead lies about everything.

flutter run --profile -d <device_id>

Step 1: turn on the performance overlay#

Inside the running app, the first signal is also the cheapest. Add the overlay to your MaterialApp:

return MaterialApp(
  showPerformanceOverlay: true,
  // ...
);

You will see two graphs at the top of the screen. The top one is the GPU (raster) timeline, the bottom is the UI (build + layout + paint) timeline. The horizontal bar on each is 16.6ms. Anything that crosses the bar is a dropped frame.

In our starter project, the UI bar is consistently spilling over. That tells us the work is in build(), layout(), or paint(). Not the GPU.

Step 2: find the hot path with DevTools timeline#

Open Flutter DevTools and switch to the Performance tab. Hit Record, scroll the list for two seconds, hit Stop. The flame graph shows you which methods are eating the frame budget.

For our starter list, the largest bar is _HomeListItem.build at ~9ms per frame. There are 14 items visible, so that is 14 × 9ms = 126ms of build per frame, way over budget. The cost is in three places:

  1. A Text widget that does its own date formatting on every build.
  2. A MemoryImage that re-decodes the same bytes on every scroll.
  3. A Padding widget that wraps a Padding widget that wraps a Padding widget. (Yes, really. Production code.)

Step 3: the four fixes#

Fix 1: precompute the date string outside build().

// before
class _HomeListItem extends StatelessWidget {
  final Order order;
  @override
  Widget build(BuildContext context) {
    final date = DateFormat('d MMM, h:mm a').format(order.createdAt);
    return Row(children: [Text(date), /* ... */]);
  }
}
// after
class _HomeListItem extends StatelessWidget {
  _HomeListItem({required this.order})
      : _date = DateFormat('d MMM, h:mm a').format(order.createdAt);
  final Order order;
  final String _date;
  @override
  Widget build(BuildContext context) {
    return Row(children: [Text(_date), /* ... */]);
  }
}

This drops the per-build cost of formatting from ~0.4ms to zero on hot scrolls. Across the visible viewport, that is ~5ms per frame back.

Fix 2: cache decoded images.

MemoryImage does not cache by default; it allocates a fresh ImageStream on every build of the same byte payload. The cheapest fix is to lift the Image widget into a const or to switch to Image.memory(... cacheWidth: 96) so the framework caches by (bytes, cacheWidth). For network images, use cached_network_image.

Image.memory(
  order.thumbnailBytes,
  cacheWidth: 96,
  fit: BoxFit.cover,
)

This is the largest single win in the lab: ~3ms per visible item back.

Fix 3: collapse nested padding.

Three nested Padding widgets create three layout passes. Collapse them into one EdgeInsets.fromLTRB(...) on the outermost container. This sounds trivial; the layout tree depth in our starter list is so deep that flattening it cuts ~1.5ms per item.

Fix 4: extract the static parts to const.

The trailing chevron is a static Icon(Icons.chevron_right). Marking it const excludes it from the rebuild tree entirely. Same for any divider, spacer, or text label whose value never changes.

Step 4: measure again#

Re-record the timeline after the fixes. The flame graph for _HomeListItem.build should now sit around 1.5ms. Across 14 visible items, that is 21ms total, comfortably within the 16.6ms budget per frame because the 14 items are not all rebuilt every frame; only the ones that scroll into view.

The performance overlay should show both bars holding steady under the line. Your scroll feel goes from "rough" to "is this the same app?" at exactly the right pixel.

Step 5: the discipline trap#

Any of the four fixes alone gets you a 2–4fps improvement. All four together get you the 23fps you needed. The discipline trap is to find one obvious culprit, ship the fix, declare victory, and never measure the rest. Performance work is multiplicative: small fixes compound. Single fixes rarely move the needle alone.

The same is true for sync-engine work, for backend latency, for cold-start budgets. The full Offline-first Flutter course teaches the systemic version of this: how to set frame-budget alerts in CI, how to keep a perf log for the team, and how to argue against features that would push you back over the line. This lab is the first muscle. The course is the workout plan.