← Flutter Reference

Flutter Performance Architecture: Profiling & Optimization

Performance · Advanced

By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.

Flutter performance is about two things: not doing unnecessary work on the UI thread, and not blocking the UI thread with work that belongs elsewhere. Jank (dropped frames) happens when the build, layout, or paint phase of a frame takes longer than 16ms (for 60fps). At iStoria, we serve 5M+ users on devices ranging from flagships to low-end Android phones — keeping 60fps across that range requires disciplined architecture, not just clever tricks.

This guide covers the performance patterns we use: profiling methodology, build optimization, list virtualization, image handling, isolate offloading, and memory management. These are the patterns that separate a smooth app from a janky one at scale.

Profile, Do Not Guess

The first rule of performance optimization: never optimize without measuring. Human intuition about performance is wrong more often than it is right. The Flutter DevTools Performance tab shows you exactly where frames are dropping and what code is responsible.

The profiling workflow:

1. Run the app in profile mode (flutter run --profile). Debug mode is not representative — it disables optimizations and adds overhead. 2. Open DevTools → Performance tab → record a session while interacting with the app. 3. Look at the frame timeline. Red frames are jank (took >16ms). Yellow frames are close to the limit. 4. Click a red frame to see the flame chart — the stack trace of what happened during that frame. 5. Identify the function that took the most time. Optimize it. Re-measure.

Optimizing without profiling leads to cargo-cult optimizations: premature caching, unnecessary complexity, and optimizations in places that were never the bottleneck.

Build Optimization

The build phase (constructing the widget tree) is the most common source of jank. The strategies:

Minimize Rebuilds with const Constructors

Mark widgets const wherever possible. A const widget is constructed once at compile time and never rebuilt. This is the single most impactful build optimization.

// GOOD — const widget, never rebuilds
const Padding(
  padding: EdgeInsets.all(16),
  child: Text('Hello'),
)

// BAD — new Padding and EdgeInsets instance on every parent rebuild
Padding(
  padding: EdgeInsets.all(16),
  child: Text('Hello'),
)

Split Large Widgets into Smaller Ones

A giant build() method that returns a 500-line widget tree rebuilds entirely when any state changes. Split it into smaller widgets, each managing its own state. When a child widget's state changes, only that child rebuilds — not the entire tree.

Use Builder/Selector for Granular Rebuilds

When using BLoC/Provider, use BlocSelector or context.select to rebuild only when the specific piece of state the widget cares about changes:

// Rebuilds only when lessons list changes, not when other LessonState fields change
BlocSelector<LessonBloc, LessonState, List<Lesson>>(
  selector: (state) => state is LessonLoaded ? state.lessons : [],
  builder: (context, lessons) => LessonList(lessons: lessons),
)

This prevents the widget from rebuilding on unrelated state changes. At iStoria, we use selectors on every list and detail widget — it eliminated the majority of our jank on scroll.

List Virtualization

ListView.builder only builds the items visible on screen plus a small cache. ListView() (without builder) builds every item immediately. For any list with more than ~20 items, always use ListView.builder with itemExtent (if items have a fixed height) for maximum scroll performance.

For heterogeneous lists (mixed item types), use SliverList with itemBuilder. For infinite scroll with pagination, use CustomScrollView with slivers.

ListView.builder(
  itemCount: lessons.length,
  itemExtent: 72,  // fixed height → Flutter skips layout calculation
  itemBuilder: (context, index) => LessonTile(lesson: lessons[index]),
)

Image Optimization

Images are the heaviest objects in a Flutter app. Unoptimized images cause memory spikes and jank on low-end devices.

1. Use cacheWidth / cacheHeight — decodes the image at the display size, not the source size. A 4000×3000 photo displayed at 200×150 should be decoded at 200×150, saving massive memory.

Image.network(
  lesson.thumbnailUrl,
  cacheWidth: 200,  // decodes at 200px width, not the full source
  cacheHeight: 150,
)

2. Use WebP instead of PNG/JPEG — WebP is ~30% smaller at the same quality. At iStoria, our build pipeline converts all article and lesson images to WebP automatically.

3. Use precacheImage for critical images — preloads the image before it is displayed, avoiding a pop-in on first render.

4. Use FadeInImage with a placeholder — shows a lightweight placeholder while the real image loads, avoiding layout shift.

Isolate Offloading

Heavy computation (JSON parsing, image processing, cryptographic operations) blocks the UI thread and causes jank. Move it to an isolate via compute() or Isolate.run():

final parsed = await compute(parseLessonJson, rawJsonString);

compute() spins up an isolate, runs the function, and returns the result. The UI thread stays free. Use this for any operation that processes more than a few kilobytes of data or takes more than a few milliseconds.

At iStoria, we offload all JSON deserialization of large API responses to isolates. On low-end devices, this was the difference between 40fps and 60fps on the lesson loading screen.

Memory Management

Memory leaks in Flutter are usually caused by listeners and streams that are not disposed. Every StreamSubscription, TextEditingController, ScrollController, and AnimationController must be disposed in dispose().

The common leak: a BLoC that holds a reference to a widget's BuildContext (via a listener or callback) after the widget is unmounted. The BLoC outlives the widget, and the widget's element stays in memory.

The fix: never pass BuildContext to a BLoC or repository. If a BLoC needs to trigger navigation, use a navigation service or a global router key, not the widget's context.

Use DevTools Memory tab to detect leaks. Take a snapshot, navigate through the app, take another snapshot, and compare. If objects accumulate, you have a leak.

Avoiding Platform Channel Jank

Platform channel calls (MethodChannel) are asynchronous, but if the native side does heavy work on the main thread, it causes jank on the Flutter side. Move native-side heavy work to a background thread (dispatch queue on iOS, background thread on Android).

At iStoria, our PowerSync sync engine runs entirely on a background thread on the native side. We learned this the hard way — the initial implementation did SQLite operations on the main thread, causing jank on every sync cycle.

Recommended folder structure

lib/
├── core/
│   ├── performance/
│   │   ├── frame_monitor.dart        # tracks FPS, logs jank in profile mode
│   │   └── memory_tracker.dart       # detects retention spikes in DevTools
│   └── utils/
│       └── isolate_runner.dart       # compute() wrapper for typed offloading
├── features/
│   └── lesson/
│       └── presentation/
│           └── widgets/
│               ├── lesson_list.dart       # ListView.builder with itemExtent
│               ├── lesson_tile.dart        # const constructor, granular selector
│               └── cached_lesson_image.dart # cacheWidth/cacheHeight optimized

Code example

// core/utils/isolate_runner.dart — typed compute() wrapper
import 'package:flutter/foundation.dart';

/// Offloads heavy computation to a background isolate.
/// Use for JSON parsing, data transformation, or any CPU-intensive work.
Future<T> runInIsolate<T, P>(
  T Function(P) work,
  P param,
) {
  return compute(work, param);
}

// Example: parse a large API response off the UI thread
List<LessonModel> parseLessons(String json) {
  final decoded = jsonDecode(json) as List;
  return decoded.map((e) => LessonModel.fromJson(e)).toList();
}

// In the repository:
@override
Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
  final rawJson = await remoteDatasource.fetchLessonsRaw(courseId);
  // Parse on a background isolate — UI thread stays smooth
  final models = await runInIsolate(parseLessons, rawJson);
  return Right(models.map((m) => m.toEntity()).toList());
}

// presentation/widgets/lesson_tile.dart — const + granular selector
class LessonTile extends StatelessWidget {
  final Lesson lesson;

  const LessonTile({super.key, required this.lesson});

  @override
  Widget build(BuildContext context) {
    return BlocSelector<LessonBloc, LessonState, bool>(
      // Rebuild this tile ONLY when this specific lesson's completion changes
      selector: (state) {
        if (state is! LessonLoaded) return false;
        return state.lessons
            .firstWhere((l) => l.id == lesson.id,
                orElse: () => lesson)
            .isCompleted;
      },
      builder: (context, isCompleted) {
        return ListTile(
          leading: CachedLessonImage(url: lesson.thumbnailUrl),
          title: Text(lesson.title),
          trailing: AnimatedSwitcher(
            duration: const Duration(milliseconds: 200),
            child: isCompleted
                ? const Icon(Icons.check_circle, key: ValueKey('done'))
                : const SizedBox.shrink(key: ValueKey('empty')),
          ),
        );
      },
    );
  }
}

// presentation/widgets/lesson_list.dart — virtualized list with itemExtent
class LessonList extends StatelessWidget {
  final List<Lesson> lessons;

  const LessonList({super.key, required this.lessons});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: lessons.length,
      itemExtent: 72,  // fixed height → skips per-item layout, maximum scroll FPS
      itemBuilder: (context, index) {
        return LessonTile(
          key: ValueKey(lessons[index].id),
          lesson: lessons[index],
        );
      },
    );
  }
}

// presentation/widgets/cached_lesson_image.dart — memory-optimized images
class CachedLessonImage extends StatelessWidget {
  final String url;

  const CachedLessonImage({super.key, required this.url});

  @override
  Widget build(BuildContext context) {
    // Determine display dimensions from the layout
    const displayWidth = 56.0;
    const displayHeight = 56.0;
    // Convert to physical pixels for cacheWidth/cacheHeight
    final dpr = MediaQuery.devicePixelRatioOf(context);
    final cacheW = (displayWidth * dpr).round();
    final cacheH = (displayHeight * dpr).round();

    return CachedNetworkImage(
      imageUrl: url,
      cacheWidth: cacheW,    // decode at display size, not full source resolution
      cacheHeight: cacheH,
      fadeInDuration: const Duration(milliseconds: 150),
      placeholder: (_, __) => Container(
        width: displayWidth,
        height: displayHeight,
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
      ),
    );
  }
}

Implementation checklist

  • Always profile in profile mode (flutter run --profile), never in debug mode — debug is not representative.
  • Mark every stateless widget const and split large build() methods into smaller, granularly-rebuilding widgets.
  • Use BlocSelector or context.select so widgets rebuild only when their specific slice of state changes.
  • Use ListView.builder with itemExtent for any list over 20 items — never use ListView() for dynamic lists.
  • Set cacheWidth and cacheHeight on every image to decode at display size, not full source resolution.
  • Offload JSON parsing and CPU-intensive work to isolates via compute() or Isolate.run().
  • Dispose every StreamSubscription, controller, and listener to prevent memory leaks — never pass BuildContext to a BLoC.

Common mistakes

  • Optimizing based on intuition instead of profiling, leading to cargo-cult changes that do not address the actual bottleneck.
  • Using ListView() instead of ListView.builder for dynamic lists, causing every item to build immediately instead of being virtualized.
  • Forgetting cacheWidth/cacheHeight on images, decoding 4000x3000 photos at full resolution into memory for a 56px thumbnail.
  • Passing BuildContext to BLoCs via listeners or callbacks, causing memory leaks when the BLoC outlives the widget.
  • Running JSON parsing or heavy computation on the UI thread instead of an isolate, causing jank on large data loads.

Related definitions

Related reading

Related case studies

FAQ

How do I know which widgets are rebuilding unnecessarily?

Add a print or a debugPrint in the build() method of the widget you suspect. If it prints more often than expected, it is rebuilding unnecessarily. In profile mode, use the DevTools Performance tab to see rebuild counts per frame. Alternatively, use the RepaintBoundary widget to isolate paint regions and the Flutter inspector's 'Select Widget Mode' to inspect the widget tree during interaction.

Should I use RepaintBoundary everywhere?

No. RepaintBoundary creates a separate layer, which helps when a complex subtree repaints independently (like a list item with an animation). But each RepaintBoundary adds memory and compositing overhead. Use it strategically on complex, independently-animating widgets — not on every widget. Profile before and after to confirm it helps.

How do I test performance on low-end devices?

Use an actual low-end device (1-2GB RAM, budget CPU) — the Android emulator on a slow setting is not representative. Run in profile mode and interact with the app normally. The DevTools Performance tab shows frame times and dropped frames. Focus on the 90th-percentile frame time, not the average — the worst frames are what users perceive as jank.


Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →

Book a 20-minute call · Download the CV (PDF) · See how I work