Back to Articles
Flutter Platform Optimization and Profiling Guide

Flutter Platform Optimization and Profiling Guide


1. Platform-Specific Refresh Rate Settings

Android: High Refresh Rate (120Hz/240Hz)

Flutter on Android does NOT automatically request the highest refresh rate. By default, it may run at 60Hz even on 120Hz/240Hz capable displays.

Option A: flutter_displaymode package (recommended)

# pubspec.yaml
dependencies:
  flutter_displaymode: ^0.7.0
import 'package:flutter_displaymode/flutter_displaymode.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await FlutterDisplayMode.setHighRefreshRate();
  runApp(const MyApp());
}

Full API:

// Get all supported modes
List<DisplayMode> modes = await FlutterDisplayMode.supported;
// e.g., [DisplayMode(0, 1080x2400 @ 60Hz), DisplayMode(1, 1080x2400 @ 120Hz)]

// Set specific mode
await FlutterDisplayMode.setPreferredMode(modes[1]); // 120Hz

// Get active mode
DisplayMode active = await FlutterDisplayMode.active;
DisplayMode preferred = await FlutterDisplayMode.preferred;

Important caveats:

  • Settings reset per app session; call in root widget’s initState
  • The system may reject requests based on battery saver, thermal state, etc.
  • Ineffective on LTPO panels (variable refresh rate) and iOS ProMotion
  • Requires Android Marshmallow (API 23)+

Option B: Native Android code via platform channel

In your MainActivity.kt:

import android.os.Build
import android.view.WindowManager

class MainActivity : FlutterActivity() {
    override fun onCreate(savedInstanceState: android.os.Bundle?) {
        super.onCreate(savedInstanceState)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            // Request highest refresh rate
            val display = windowManager.defaultDisplay
            val modes = display.supportedModes
            val highestMode = modes.maxByOrNull { it.refreshRate }
            highestMode?.let {
                val params = window.attributes
                params.preferredDisplayModeId = it.modeId
                window.attributes = params
            }
        }
    }
}

Option C: Android Frame Rate API (API 30+)

// In a FlutterActivity or Fragment
surface.setFrameRate(120f, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT)

iOS: ProMotion (120Hz) Configuration

On iPhone 13 Pro and later, iOS caps apps to 60Hz by default. You must opt in to ProMotion.

Info.plist configuration:

<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>

This key tells Core Animation to remove the minimum frame duration cap on iPhone, allowing frame rates up to 120Hz on ProMotion displays.

For iPad Pro (which has had ProMotion since 2017), no plist key is needed — apps already run at the display’s native refresh rate.

Flutter’s engine respects this plist setting. Once set, Flutter’s frame scheduler will target the display’s native refresh rate.

Detecting Display Refresh Rate at Runtime

import 'dart:ui' as ui;

// Access from a widget's build context
void checkRefreshRate(BuildContext context) {
  final view = View.of(context);
  final display = view.display;

  // Get refresh rate in FPS
  double refreshRate = display.refreshRate; // e.g., 60.0, 120.0, 240.0

  // Get display properties
  Size displaySize = display.size;
  double devicePixelRatio = display.devicePixelRatio;
  int displayId = display.id;

  // Calculate frame budget
  double frameBudgetMs = 1000.0 / refreshRate; // 8.33ms for 120Hz
}

2. FlutterView.render() and Frame Scheduling

FlutterView Class (dart:ui)

FlutterView represents a rendering surface where Flutter draws scenes.

Key properties:

  • render(Scene scene) — Updates GPU rendering with a new Scene
  • physicalSize — Current physical size of the rendering rectangle
  • display — The Display object containing this view
  • devicePixelRatio — Logical-to-physical pixel ratio
  • physicalConstraints — Sizing constraints in physical pixels
  • viewInsets — Areas obscured by system UI (keyboard, status bar)
  • viewPadding — Areas potentially obscured by notches, etc.
  • displayFeatures — Hardware obstructions (fold lines on foldables)
  • gestureSettings — Touch gesture configuration

Frame Scheduling with SchedulerBinding

import 'package:flutter/scheduler.dart';

// Schedule a callback for the next frame
SchedulerBinding.instance.scheduleFrameCallback((Duration timeStamp) {
  // Called once before the next frame
});

// Schedule a persistent callback (called every frame)
SchedulerBinding.instance.addPersistentFrameCallback((Duration timeStamp) {
  // Called every frame -- use sparingly
});

// Add post-frame callback (runs after frame is rendered)
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
  // Runs after the current frame completes
});

Frame Timing Monitoring

import 'package:flutter/scheduler.dart';

SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
  for (final timing in timings) {
    // Build phase duration
    Duration buildDuration = timing.buildDuration;

    // Raster/GPU phase duration
    Duration rasterDuration = timing.rasterDuration;

    // Total frame span
    Duration totalSpan = timing.totalSpan;

    // Vsync overhead (delay before build starts)
    Duration vsyncOverhead = timing.vsyncOverhead;

    // Raster cache stats
    int layerCacheCount = timing.layerCacheCount;
    int layerCacheBytes = timing.layerCacheBytes;
    int pictureCacheCount = timing.pictureCacheCount;
    int pictureCacheBytes = timing.pictureCacheBytes;

    // Frame number
    int frameNumber = timing.frameNumber;

    // Detect jank: check if frame exceeded budget
    final frameBudget = Duration(milliseconds: 16); // 60fps
    if (totalSpan > frameBudget) {
      print('JANK: Frame $frameNumber took ${totalSpan.inMilliseconds}ms '
            '(build: ${buildDuration.inMilliseconds}ms, '
            'raster: ${rasterDuration.inMilliseconds}ms)');
    }
  }
});

Performance overhead: ~0% when no callbacks registered; ~0.01% CPU when enabled (measured on iPhone 6s). Batching: reported ~once/second in release, every ~100ms in debug/profile.


3. DisplayFeatures and Display Refresh Rate Detection

Display Class (dart:ui)

import 'dart:ui' as ui;

// Access display from a FlutterView
void inspectDisplay(BuildContext context) {
  final view = View.of(context);
  final display = view.display;

  // Properties:
  double refreshRate = display.refreshRate;      // FPS (e.g., 120.0)
  Size size = display.size;                      // Physical size
  double dpr = display.devicePixelRatio;         // Device pixel ratio
  int id = display.id;                           // Unique identifier
}

Display Features (Foldable Devices)

void checkDisplayFeatures(BuildContext context) {
  final view = View.of(context);
  final features = view.displayFeatures;

  for (final feature in features) {
    // feature.bounds -- Rectangle of the display feature
    // feature.type -- DisplayFeatureType (hinge, fold, cutout)
    // feature.state -- DisplayFeatureState (unknown, flat, halfOpened, etc.)
  }
}

Adaptive Frame Budget Based on Refresh Rate

class FrameBudgetMonitor {
  late double _frameBudgetMs;

  void init(BuildContext context) {
    final refreshRate = View.of(context).display.refreshRate;
    _frameBudgetMs = 1000.0 / refreshRate;
    // 60Hz  -> 16.67ms
    // 90Hz  -> 11.11ms
    // 120Hz -> 8.33ms
    // 240Hz -> 4.17ms
  }

  bool isJanky(Duration frameDuration) {
    return frameDuration.inMicroseconds > (_frameBudgetMs * 1000);
  }
}

4. Profile Mode vs Release Mode Performance Differences

Build Mode Comparison

FeatureDebugProfileRelease
AssertionsEnabledDisabledDisabled
Service extensionsEnabledPartialDisabled
DevTools connectionYesYes (mobile)No
Hot reloadYesNoNo
CompilationJIT (mobile)AOTAOT
OptimizationsNoneFullFull
Binary sizeLargeMediumSmall
PerformancePoorNear-releaseBest
Emulator/SimulatorYesNoNo
Web compilerdartdevcdart2jsdart2js
Web minificationNoNoYes
Web tree shakingNoYesYes

Commands

# Debug mode (default)
flutter run

# Profile mode -- use for all performance measurement
flutter run --profile

# Release mode
flutter run --release
flutter build apk --release
flutter build ios --release

Critical Rule

Never measure performance in debug mode. Debug mode:

  • Uses JIT compilation (pauses for compilation)
  • Runs assertion checks on every frame
  • Includes extra validation logic
  • Does not represent production performance

Profile mode matches release performance while keeping profiling tools available.

VS Code Profile Configuration

{
  "configurations": [
    {
      "name": "Flutter Profile",
      "request": "launch",
      "type": "dart",
      "flutterMode": "profile"
    }
  ]
}

5. Flutter DevTools Timeline Analysis for Jank Detection

Opening DevTools Performance View

# Run in profile mode
flutter run --profile

# DevTools opens automatically, or:
flutter pub global activate devtools
flutter pub global run devtools

Frame Chart Analysis

The Flutter Frames Chart displays paired bars for each frame:

  • UI Thread bar — Time spent building the widget tree (Dart code)
  • Raster Thread bar — Time spent rendering to GPU (Impeller/Skia)
  • Green bars — Within budget (<16ms at 60Hz)
  • Red bars — Exceeded budget (jank/dropped frames)
  • Dark red bars — Shader compilation occurring (first-time rendering of effects)

Enhance Tracing Options

Enable in DevTools Performance view:

  1. Track Widget Builds — Shows build() method events in timeline with widget names; identifies excessive rebuilds
  2. Track Layouts — Shows render object layout events; detects layout performance issues
  3. Track Paints — Shows render object paint events; identifies expensive painting operations

These add overhead so only enable when investigating specific issues.

Render Layer Debugging

Toggle rendering layers off to diagnose GPU bottlenecks:

  • Render Clip Layers — Disable to test clipping impact
  • Render Opacity Layers — Disable to test opacity impact
  • Render Physical Shape Layers — Disable to test shadow/elevation impact

Method: Disable layer -> reproduce activity -> compare raster time.

Timeline Events Tab

Shows all traced events including:

  • Framework events (build frames, draw scenes)
  • HTTP request timings
  • Garbage collection events
  • Custom Timeline events from dart:developer

Frame Analysis Tab

Selecting a janky (red) frame shows:

  • Debugging hints
  • Detected expensive operations
  • Optimization recommendations

Export/Import Snapshots

Export performance snapshots for sharing/comparison. Only files exported from DevTools can be reimported.


6. dart:developer Timeline API Usage

Basic Synchronous Tracing

import 'dart:developer';

// Method 1: Explicit start/finish
void expensiveOperation() {
  Timeline.startSync('ExpensiveOperation');
  // ... do work ...
  Timeline.finishSync();
}

// Method 2: Automatic bracketing with timeSync
void anotherOperation() {
  final result = Timeline.timeSync('AnotherOperation', () {
    // ... do work ...
    return computeResult();
  });
}

// Method 3: Instant event (point-in-time marker)
void markEvent() {
  Timeline.instantSync('UserTappedButton');
}

With Arguments (Metadata)

Timeline.startSync('ParseJSON', arguments: {
  'dataSize': '${data.length} bytes',
  'source': 'network',
});
parseJson(data);
Timeline.finishSync();

TimelineTask for Async Operations

import 'dart:developer';

Future<void> fetchAndProcess() async {
  final task = TimelineTask()..start('FetchAndProcess');

  task.start('NetworkFetch');
  final data = await fetchData();
  task.finish(); // finish NetworkFetch

  task.start('ProcessData');
  final result = processData(data);
  task.finish(); // finish ProcessData

  task.finish(); // finish FetchAndProcess
}
import 'dart:developer';

void producer() {
  final flow = Flow.begin();
  Timeline.startSync('ProduceItem', flow: flow);
  // ... produce ...
  Timeline.finishSync();
  sendFlowId(flow.id); // pass ID to consumer
}

void consumer(int flowId) {
  Timeline.startSync('ConsumeItem', flow: Flow.end(flowId));
  // ... consume ...
  Timeline.finishSync();
}

Getting Timestamps

// Current timestamp in microseconds
int now = Timeline.now;

Widget Rebuild Profiling

// Enable in main() for web profiling
void main() {
  debugProfileBuildsEnabled = true;           // Timeline events for every Widget built
  debugProfileBuildsEnabledUserWidgets = true; // Only user-created Widgets
  debugProfileLayoutsEnabled = true;          // RenderObject layout events
  debugProfilePaintsEnabled = true;           // RenderObject paint events

  runApp(const MyApp());
}

Custom Performance Logging

import 'dart:developer' as developer;
import 'dart:convert';

void logPerformanceMetrics(Map<String, dynamic> metrics) {
  developer.log(
    'performance',
    name: 'app.performance',
    error: jsonEncode(metrics),
  );
}

7. PerformanceOverlay Widget Usage

Enable via MaterialApp

MaterialApp(
  showPerformanceOverlay: true,  // Displays performance overlay
  home: const HomePage(),
);

Enable via WidgetsApp

WidgetsApp(
  showPerformanceOverlay: true,
  // ...
);

Programmatic Usage

import 'package:flutter/widgets.dart';

// Full control
PerformanceOverlay.allEnabled()

// Custom options via bitmask
PerformanceOverlay(optionsMask: 0x0F)

What the Overlay Shows

Two graphs rendering the last 300 frames:

  • Top graph: GPU/Raster thread timing (rendering to GPU)
  • Bottom graph: UI thread timing (Dart execution, widget building)

Visual indicators:

  • White lines mark 16ms increments (60fps threshold)
  • Green vertical bars = current frame timing
  • Red bars = frame exceeded 16ms budget (jank)

Command Line Toggle

While running with flutter run, press P to toggle the performance overlay on/off.

Checkerboard Debugging

MaterialApp(
  checkerboardOffscreenLayers: true,  // Visualize saveLayer calls
  checkerboardRasterCacheImages: true, // Visualize cached images
);

8. Benchmark Best Practices

Using benchmark_harness Package

dev_dependencies:
  benchmark_harness: ^2.4.0
import 'package:benchmark_harness/benchmark_harness.dart';

class MyBenchmark extends BenchmarkBase {
  const MyBenchmark() : super('MyBenchmark');

  @override
  void setup() {
    // Initialization (not measured)
  }

  @override
  void run() {
    // The code to benchmark
    for (int i = 0; i < 1000; i++) {
      someOperation();
    }
  }

  @override
  void teardown() {
    // Cleanup (not measured)
  }
}

void main() {
  const MyBenchmark().report(); // Prints: MyBenchmark(RunTime): X.XX us.
}

Default measurement: average of 10 consecutive run() calls, executed for 2 seconds.

Async Benchmarks

class MyAsyncBenchmark extends AsyncBenchmarkBase {
  const MyAsyncBenchmark() : super('MyAsyncBenchmark');

  @override
  Future<void> run() async {
    await someAsyncOperation();
  }
}

void main() async {
  await const MyAsyncBenchmark().report();
}

Integration Test Benchmarking

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('scrolling performance', (tester) async {
    await tester.pumpWidget(const MyApp());

    final listFinder = find.byType(ListView);

    // Warm up
    await tester.fling(listFinder, const Offset(0, -500), 10000);
    await tester.pumpAndSettle();

    // Benchmark with tracing
    await binding.traceAction(
      () async {
        await tester.fling(listFinder, const Offset(0, -500), 10000);
        await tester.pumpAndSettle();
      },
      reportKey: 'scrolling_timeline',
    );
  });
}

FrameTiming-Based Benchmarking

import 'package:flutter/scheduler.dart';

class PerformanceBenchmark {
  final List<FrameTiming> _timings = [];

  void start() {
    SchedulerBinding.instance.addTimingsCallback(_onTimings);
  }

  void _onTimings(List<FrameTiming> timings) {
    _timings.addAll(timings);
  }

  Map<String, double> stop() {
    SchedulerBinding.instance.removeTimingsCallback(_onTimings);

    if (_timings.isEmpty) return {};

    final buildTimes = _timings.map((t) => t.buildDuration.inMicroseconds / 1000.0).toList()..sort();
    final rasterTimes = _timings.map((t) => t.rasterDuration.inMicroseconds / 1000.0).toList()..sort();
    final totalTimes = _timings.map((t) => t.totalSpan.inMicroseconds / 1000.0).toList()..sort();

    return {
      'frame_count': _timings.length.toDouble(),
      'avg_build_ms': buildTimes.reduce((a, b) => a + b) / buildTimes.length,
      'p90_build_ms': buildTimes[(buildTimes.length * 0.9).floor()],
      'p99_build_ms': buildTimes[(buildTimes.length * 0.99).floor()],
      'worst_build_ms': buildTimes.last,
      'avg_raster_ms': rasterTimes.reduce((a, b) => a + b) / rasterTimes.length,
      'p90_raster_ms': rasterTimes[(rasterTimes.length * 0.9).floor()],
      'p99_raster_ms': rasterTimes[(rasterTimes.length * 0.99).floor()],
      'worst_raster_ms': rasterTimes.last,
      'avg_total_ms': totalTimes.reduce((a, b) => a + b) / totalTimes.length,
      'p90_total_ms': totalTimes[(totalTimes.length * 0.9).floor()],
      'p99_total_ms': totalTimes[(totalTimes.length * 0.99).floor()],
      'worst_total_ms': totalTimes.last,
    };
  }
}

Key Metrics to Track (from Flutter’s own perf infrastructure)

  • average_frame_build_time_millis
  • 90th_percentile_frame_build_time_millis
  • 99th_percentile_frame_build_time_millis
  • worst_frame_build_time_millis
  • average_frame_rasterizer_time_millis
  • 90th_percentile_frame_rasterizer_time_millis
  • 99th_percentile_frame_rasterizer_time_millis
  • worst_frame_rasterizer_time_millis
  • average_cpu_usage / average_gpu_usage
  • release_size_bytes

Startup Time Measurement

import 'package:flutter/widgets.dart';

void main() {
  final stopwatch = Stopwatch()..start();

  WidgetsBinding.instance.addPostFrameCallback((_) {
    stopwatch.stop();
    print('Time to first frame: ${stopwatch.elapsedMilliseconds}ms');
  });

  // Or use the built-in API
  WidgetsBinding.instance.firstFrameRasterized.then((_) {
    print('First frame rasterized');
  });

  runApp(const MyApp());
}

Best Practices

  1. Always benchmark on physical devices, never emulators
  2. Always use profile or release mode
  3. Run on the slowest device you support
  4. Warm up before measuring (first runs include compilation overhead)
  5. Compare results only across identical environments
  6. Measure p50, p90, p99, and worst-case, not just average
  7. Use bench command from benchmark_harness for cross-runtime validation

9. Native Platform Channel Overhead and Optimization

Platform Channel Architecture

Flutter (Dart)                    Platform Channel                    Native
+-----------------+                                              +------------------+
|  MethodChannel  | <---- Async message passing (binary) ---->  | MethodChannel    |
|  EventChannel   | <---- Stream-based events ----------------> | EventChannel     |
|  BasicMessage   | <---- Raw message codec ------------------>  | BasicMessage     |
+-----------------+                                              +------------------+

Overhead Sources

  1. Serialization/Deserialization: StandardMessageCodec converts Dart objects to binary and back
  2. Thread hopping: Messages cross from Dart isolate to platform thread
  3. Asynchronous nature: Every call involves Future/callback overhead
  4. Main thread requirement: Native handlers must run on platform’s main thread

Optimization Techniques

1. Batch Calls

// BAD: Multiple individual calls
for (final item in items) {
  await platform.invokeMethod('processItem', item);
}

// GOOD: Single batched call
await platform.invokeMethod('processItems', items);

2. Use Correct Codec

// StandardMessageCodec (default) -- general purpose, auto-serialization
const channel = MethodChannel('com.app/data');

// BinaryCodec -- zero serialization overhead for raw bytes
const binaryChannel = BasicMessageChannel('com.app/binary', BinaryCodec());

// JSONMessageCodec -- when you need JSON specifically
const jsonChannel = BasicMessageChannel('com.app/json', JSONMessageCodec());

3. Use EventChannel for Streams

// Instead of polling with MethodChannel:
static const eventChannel = EventChannel('com.app/sensor');

final stream = eventChannel.receiveBroadcastStream();
stream.listen((event) {
  // Continuous data without repeated invocations
});

4. Background Thread Handlers (Android)

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
  // Move heavy work off the main thread
  val taskQueue = binding.binaryMessenger.makeBackgroundTaskQueue()
  channel = MethodChannel(
    binding.binaryMessenger,
    "com.app/heavy",
    StandardMethodCodec.INSTANCE,
    taskQueue  // Handlers run on background thread
  )
  channel.setMethodCallHandler(this)
}

5. Use Pigeon for Type Safety and Efficiency

// pigeon/messages.dart
import 'package:pigeon/pigeon.dart';

class DataRequest {
  final String id;
  final int limit;
  DataRequest({required this.id, required this.limit});
}

class DataResponse {
  final List<String> items;
  final int total;
  DataResponse({required this.items, required this.total});
}

@HostApi()
abstract class DataApi {
  @async
  DataResponse fetchData(DataRequest request);
}

Then generate:

dart run pigeon --input pigeon/messages.dart

6. Use Isolates for Platform Channel Calls

import 'dart:isolate';
import 'package:flutter/services.dart';

void main() {
  final rootIsolateToken = RootIsolateToken.instance!;
  Isolate.spawn(_backgroundWork, rootIsolateToken);
}

Future<void> _backgroundWork(RootIsolateToken token) async {
  BackgroundIsolateBinaryMessenger.ensureInitialized(token);
  // Now you can use platform channels from background isolate
  const channel = MethodChannel('com.app/data');
  final result = await channel.invokeMethod('heavyOperation');
}

Threading Requirements

  • Android: Must invoke on UI thread (use @UiThread annotation); use Handler(Looper.getMainLooper()).post { } to hop to UI thread
  • iOS: Must invoke on main thread; use DispatchQueue.main.async { } to hop to main thread

10. MethodChannel vs EventChannel vs FFI Performance Comparison

Architecture Differences

AspectMethodChannelEventChanneldart:ffi
CommunicationRequest-responseStream-basedDirect function call
SerializationStandardMessageCodecStandardMessageCodecNone (direct memory)
ThreadingAsync, main threadAsync, main threadSynchronous by default
Overhead per call~0.1-1ms~0.05ms (after setup)~0.001-0.01ms
Use caseOne-off callsContinuous data streamsHigh-frequency/compute
Type safetyManual / PigeonManualGenerated bindings (ffigen)
Platform supportAll (incl. web)All (incl. web)All except web

Performance Characteristics

MethodChannel:

  • Each call: serialize args -> cross thread boundary -> deserialize -> execute -> serialize result -> cross back -> deserialize result
  • Typical latency: 0.1-1ms per invocation
  • Good for: Settings changes, one-off queries, low-frequency operations
  • Problem: Each call has fixed overhead regardless of payload

EventChannel:

  • Setup cost once, then continuous stream
  • Lower per-event overhead than repeated MethodChannel calls
  • Good for: Sensor data, location updates, real-time streams
  • Advantage: Single subscription model, no repeated setup overhead

dart:ffi (Foreign Function Interface):

  • Direct C function calls with no serialization
  • Orders of magnitude faster than platform channels
  • ~100x-1000x faster than MethodChannel for simple calls
  • Good for: Image processing, crypto, audio processing, game logic, ML inference
  • Limitation: Only works with C ABI; not available on web

FFI Setup and Usage

import 'dart:ffi';
import 'dart:io' show Platform;

// Load native library
final DynamicLibrary nativeLib = Platform.isAndroid
    ? DynamicLibrary.open('libnative_add.so')
    : DynamicLibrary.process();

// Bind function
typedef NativeAdd = Int32 Function(Int32, Int32);
typedef DartAdd = int Function(int, int);

final add = nativeLib.lookupFunction<NativeAdd, DartAdd>('native_add');

// Call directly -- no serialization, no thread hop, no Future
int result = add(3, 5); // ~microseconds

Generate Bindings Automatically with ffigen

# pubspec.yaml
dev_dependencies:
  ffigen: ^9.0.0

# ffigen.yaml
name: NativeBindings
description: Auto-generated bindings
output: lib/src/native_bindings.dart
headers:
  entry-points:
    - src/native_lib.h
dart run ffigen

When to Use What

ScenarioBest Choice
Read battery levelMethodChannel
Get device infoMethodChannel
Listen to accelerometerEventChannel
Stream GPS updatesEventChannel
Image manipulation (pixel ops)FFI
Crypto operationsFFI
Audio signal processingFFI
Game physics engineFFI
SQLite database accessFFI
Push notification handlingMethodChannel
Bluetooth data streamingEventChannel

11. Reduce App Startup Time

Deferred Components (Android & Web)

Split your app into downloadable modules that load on demand.

Setup in pubspec.yaml:

flutter:
  deferred-components:
    - name: heavyFeature
      libraries:
        - package:my_app/heavy_feature.dart
      assets:
        - assets/heavy_images/

Dart deferred imports:

import 'heavy_feature.dart' deferred as heavy;

class FeatureLoader extends StatefulWidget {
  @override
  State<FeatureLoader> createState() => _FeatureLoaderState();
}

class _FeatureLoaderState extends State<FeatureLoader> {
  late Future<void> _loadFuture;

  @override
  void initState() {
    super.initState();
    _loadFuture = heavy.loadLibrary();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<void>(
      future: _loadFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasError) return Text('Error: ${snapshot.error}');
          return heavy.HeavyWidget();
        }
        return const CircularProgressIndicator();
      },
    );
  }
}

Android setup:

  1. Add Play Core dependency:
// build.gradle.kts
dependencies {
    implementation("com.google.android.play:core:1.8.0")
}
  1. Configure AndroidManifest.xml:
<application
    android:name="io.flutter.embedding.android.FlutterPlayStoreSplitApplication">
    <meta-data
        android:name="io.flutter.embedding.engine.deferredcomponents.DeferredComponentManager.loadingUnitMapping"
        android:value="2:heavyFeature"/>
</application>
  1. Build and test:
flutter build appbundle
# Local testing with bundletool
java -jar bundletool.jar build-apks --bundle=build/app/outputs/bundle/release/app-release.aab --output=app.apks --local-testing
java -jar bundletool.jar install-apks --apks=app.apks

Lazy Initialization Patterns

// Pattern 1: Late initialization
class AppConfig {
  static late final Database _db;

  static Future<void> init() async {
    _db = await Database.open('app.db');
  }
}

// Pattern 2: Lazy singleton
class HeavyService {
  static HeavyService? _instance;
  static HeavyService get instance => _instance ??= HeavyService._();
  HeavyService._();
}

// Pattern 3: Deferred initialization with Future
class ServiceLocator {
  static final Map<Type, Future<dynamic> Function()> _factories = {};
  static final Map<Type, dynamic> _instances = {};

  static void registerLazy<T>(Future<T> Function() factory) {
    _factories[T] = factory;
  }

  static Future<T> get<T>() async {
    if (_instances.containsKey(T)) return _instances[T] as T;
    final instance = await (_factories[T]!() as Future<T>);
    _instances[T] = instance;
    return instance;
  }
}

Minimize main() Work

void main() {
  // MINIMAL work before runApp
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

// Initialize services AFTER first frame
class MyApp extends StatefulWidget {
  const MyApp({super.key});
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    // Defer heavy initialization
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _initializeServices();
    });
  }

  Future<void> _initializeServices() async {
    await Future.wait([
      _initDatabase(),
      _initAnalytics(),
      _initRemoteConfig(),
    ]);
  }

  // ...
}

Use Isolates for Heavy Startup Work

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Parse config in background isolate
  final config = await Isolate.run(() {
    return parseConfig(rawConfigData);
  });

  runApp(MyApp(config: config));
}

Startup Time Measurement

// Built-in API
WidgetsBinding.instance.firstFrameRasterized.then((_) {
  // timeToFirstFrameRasterizedMicros is the key metric
});

12. Font Loading Optimization

Subset Fonts (Reduce Size)

Only include the glyphs you need:

# Use pyftsubset from fonttools
pip install fonttools
pyftsubset MyFont.ttf --text="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" --output-file=MyFont-subset.ttf

Use Variable Fonts

Single variable font file replaces multiple weight/style files:

flutter:
  fonts:
    - family: Inter
      fonts:
        - asset: fonts/Inter-Variable.ttf  # One file for all weights

Precache Fonts

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Pre-load font before first frame
  final fontLoader = FontLoader('CustomFont');
  fontLoader.addFont(rootBundle.load('fonts/CustomFont.ttf'));
  await fontLoader.load();

  runApp(const MyApp());
}

Google Fonts Optimization

import 'package:google_fonts/google_fonts.dart';

// Disable HTTP fetching in production (use bundled fonts)
void main() {
  GoogleFonts.config.allowRuntimeFetching = false;
  runApp(const MyApp());
}

Bundle the fonts locally and declare in pubspec.yaml to avoid network fetch at runtime.

Supported Formats

  • .ttf (TrueType) — universal support
  • .otf (OpenType) — universal support
  • .ttc (TrueType Collection) — universal support
  • .woff / .woff2 — NOT supported on desktop platforms

Avoid Font Simulation

Always provide the actual font file for each weight/style:

flutter:
  fonts:
    - family: Roboto
      fonts:
        - asset: fonts/Roboto-Regular.ttf
          weight: 400
        - asset: fonts/Roboto-Bold.ttf
          weight: 700
        - asset: fonts/Roboto-Italic.ttf
          style: italic

If you use FontWeight.w700 but only provide the Regular file, Flutter simulates bold — which is slower and looks worse.


13. Reducing APK/IPA Size for Better Runtime Performance

Build with —split-debug-info

The single most impactful optimization:

flutter build apk --split-debug-info=build/debug-info
flutter build appbundle --split-debug-info=build/debug-info
flutter build ipa --split-debug-info=build/debug-info

This dramatically reduces code size by extracting debug symbols.

Obfuscate Dart Code

flutter build apk --obfuscate --split-debug-info=build/debug-info
flutter build ipa --obfuscate --split-debug-info=build/debug-info

Analyze App Size

flutter build apk --analyze-size
flutter build appbundle --analyze-size
flutter build ipa --analyze-size

Generates a JSON file loadable in DevTools for treemap visualization down to function level.

Split APK by Architecture

flutter build apk --split-per-abi

Produces three APKs:

  • app-armeabi-v7a-release.apk (ARM 32-bit)
  • app-arm64-v8a-release.apk (ARM 64-bit)
  • app-x86_64-release.apk (x86 64-bit)

Each is significantly smaller than a fat APK.

Use App Bundles

flutter build appbundle

Google Play automatically:

  • Filters assets by device DPI
  • Filters native libraries by CPU architecture
  • Delivers only what the device needs

Remove Unused Dependencies

# Audit dependencies
flutter pub deps

Remove any unused packages from pubspec.yaml. Each dependency adds code.

Compress Assets

  • Optimize PNG with pngcrush or optipng
  • Optimize JPEG with jpegoptim
  • Use WebP format where supported (smaller than PNG/JPEG)
  • Consider SVG for vector graphics (flutter_svg package)

Platform-Specific Code Elimination

Dart compiler removes unreachable platform-specific code:

import 'dart:io' show Platform;

if (Platform.isWindows) {
  // This entire block is removed when building for Android/iOS
}

Shared Object Compression (Android)

By default, Flutter compresses .so files in APK. For smaller on-device size:

<!-- AndroidManifest.xml -->
<application
    android:extractNativeLibs="false">
    <!-- SOs load directly from APK without extraction -->
</application>

Trade-off: Larger APK download, but smaller on-device storage.


14. ProGuard/R8 Optimization for Android

R8 is Enabled by Default

As of recent Flutter versions, R8 is always enabled for release builds and cannot be disabled. R8 replaces ProGuard and provides:

  • Code shrinking (removes unused classes/methods)
  • Obfuscation (renames classes/fields to short names)
  • Optimization (inlines methods, removes dead code)
  • Resource shrinking (removes unused resources)

Custom ProGuard/R8 Rules

Create android/app/proguard-rules.pro:

# Flutter-specific rules
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }

# Keep annotations
-keepattributes *Annotation*

# Keep native methods
-keepclasseswithmembernames class * {
    native <methods>;
}

# Firebase (if used)
-keep class com.google.firebase.** { *; }

# Gson (if used)
-keep class com.google.gson.** { *; }
-keepattributes Signature

Reference in build.gradle:

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

Enabling Resource Shrinking

android {
    buildTypes {
        release {
            shrinkResources true  // Remove unused Android resources
            minifyEnabled true
        }
    }
}

Multidex

For apps exceeding 64K methods (minSdk < 21):

android {
    defaultConfig {
        multiDexEnabled true
    }
}

15. Bitcode and Optimization Levels for iOS

Bitcode Status

As of Xcode 14+, bitcode is deprecated and no longer used. Apple removed bitcode support. Flutter builds do not include bitcode.

Xcode Build Optimization Levels

In Xcode Build Settings:

SettingDebugRelease
Optimization Level-O0 (None)-Os (Size)
Swift Optimization Level-Onone-O
Debug Information FormatDWARFDWARF+dSYM
Strip Debug SymbolsNoYes
Dead Code StrippingNoYes
Link-Time OptimizationNoYes (Thin)

These are set automatically by Flutter’s build system for release builds.

App Thinning

Apple’s App Store automatically performs:

  • Slicing: Creates variant bundles for each device type
  • On-demand resources: Downloads resources only when needed
  • Asset catalog optimization: Delivers only device-appropriate assets

Build and Deploy

# Standard IPA build
flutter build ipa

# With obfuscation
flutter build ipa --obfuscate --split-debug-info=build/debug-info

# Specific export method
flutter build ipa --export-method ad-hoc
flutter build ipa --export-method development

iOS Minimum Deployment Target

Set in Xcode or ios/Podfile:

platform :ios, '13.0'  # Flutter minimum is iOS 13

Higher minimum targets allow the compiler to use more modern APIs and optimizations.


16. Web-Specific: CanvasKit vs Skwasm Performance

Renderer Comparison

AspectCanvasKitSkwasm
Build modeDefault (any build)WebAssembly (--wasm) only
Browser supportAll modern browsersRequires WasmGC support
Download size~1.5MB additional~1.1MB additional
TechnologyFull Skia -> WebAssemblyStreamlined Skia + WASM
Multi-threadingNoYes (via web workers)
Startup speedBaselineFaster
Frame performanceBaselineBetter
FidelityHigh (matches mobile)High (matches mobile)

Build Commands

# Default mode (CanvasKit only)
flutter build web

# WebAssembly mode (Skwasm + CanvasKit fallback)
flutter build web --wasm

At runtime, --wasm builds try Skwasm first and fall back to CanvasKit if the browser lacks WasmGC.

Choose Renderer at Runtime

<body>
  <script>
    {{flutter_js}}
    {{flutter_build_config}}

    const config = {
      renderer: "canvaskit",  // or "skwasm"
    };
    _flutter.loader.load({ config: config });
  </script>
</body>

Web-Specific Performance Optimization

Enable profiling flags:

void main() {
  debugProfileBuildsEnabled = true;
  debugProfileBuildsEnabledUserWidgets = true;
  debugProfileLayoutsEnabled = true;
  debugProfilePaintsEnabled = true;
  runApp(const MyApp());
}
# Run in profile mode for Chrome DevTools profiling
flutter run -d chrome --profile

Tree Shaking:

  • Enabled in profile and release modes for web
  • Removes unused code from final bundle
  • Dart compiler removes unused libraries, classes, and functions

Deferred Loading for Web:

  • Web deferred components produce separate .js files
  • Downloaded on demand when loadLibrary() is called

Font Optimization for Web:

  • Use FontLoader to preload critical fonts
  • Subset fonts to reduce download size
  • Consider system fonts for faster initial render

WebAssembly Compatibility Requirements

To use --wasm builds:

  1. Use dart:js_interop (not deprecated dart:js or package:js)
  2. Use package:web for Web APIs (not deprecated dart:html)
  3. Ensure numeric type compatibility with Dart VM behavior

17. Desktop-Specific Optimizations

General Desktop Performance

Desktop Flutter apps use the same rendering pipeline as mobile but with additional considerations:

  • Impeller on macOS: Available behind experimental flag, improves rendering
  • Skia on Windows/Linux: Default renderer; Impeller support coming

macOS Optimizations

Enable Impeller (experimental):

# For debugging
flutter run --enable-impeller

# For production (Info.plist)
<key>FLTEnableImpeller</key>
<true/>

Entitlements for Performance:

  • Ensure com.apple.security.network.client is set for network access
  • Missing entitlements silently fail, appearing as performance issues

Build:

flutter build macos

Windows Optimizations

Window configuration (main.cpp):

Win32Window::Size size(1280, 720);
if (!window.CreateAndShow(L"myapp", origin, size)) {
    return EXIT_FAILURE;
}

FFI for Windows APIs:

import 'dart:ffi';
import 'package:ffi/ffi.dart';

// Direct Win32 API access
final user32 = DynamicLibrary.open('user32.dll');

Distribution considerations:

  • Include Visual C++ redistributables (msvcp140.dll, vcruntime140.dll, vcruntime140_1.dll)
  • Use MSIX packaging for Store distribution

Linux Optimizations

System dependencies:

sudo apt-get install libgtk-3-0 libblkid1 liblzma5

FFI for Linux system libraries:

final lib = DynamicLibrary.open('libexample.so');

Build:

flutter build linux --release
# Output: build/linux/x64/release/bundle/

Desktop-Wide Best Practices

  1. Keyboard and mouse optimization: Desktop apps receive more events per second than touch; debounce where appropriate
  2. Window resize handling: Use LayoutBuilder and debounce heavy rebuilds during resize
  3. Memory management: Desktop apps tend to run longer; watch for memory leaks
  4. Large viewport rendering: Desktop screens are larger; use RepaintBoundary to limit repaint regions
  5. Multi-window: Not yet natively supported; use packages like desktop_multi_window for workarounds

Appendix A: Impeller Rendering Engine

What is Impeller?

Impeller is Flutter’s modern rendering engine that precompiles all shaders at build time, eliminating shader compilation jank.

Key Benefits

  1. No shader compilation stutter — all shaders compiled offline at build time
  2. Predictable frame times — pre-compiled pipeline state objects
  3. Uses modern GPU APIs — Metal (iOS/macOS), Vulkan (Android)
  4. Multi-threaded rendering — distributes single-frame work across threads

Platform Status (Flutter 3.27+)

PlatformStatus
iOSOnly renderer (Skia removed)
AndroidDefault on API 29+; falls back to OpenGL
macOSExperimental (behind flag)
WindowsNot yet available
LinuxNot yet available
WebNot available (uses CanvasKit/Skwasm)

Configuration

Disable Impeller on Android (for debugging):

flutter run --no-enable-impeller

Disable for production (AndroidManifest.xml):

<meta-data
    android:name="io.flutter.embedding.android.EnableImpeller"
    android:value="false" />

Enable Impeller on macOS:

flutter run --enable-impeller

Appendix B: Common Performance Anti-Patterns

1. Opacity Widget in Animations

// BAD: forces saveLayer every frame
Opacity(opacity: animation.value, child: heavyWidget)

// GOOD: use AnimatedOpacity
AnimatedOpacity(opacity: 0.5, duration: Duration(milliseconds: 300), child: heavyWidget)

// GOOD: for images, use color blending
Image.asset('image.png', color: Color.fromRGBO(255, 255, 255, 0.5), colorBlendMode: BlendMode.modulate)

2. Rebuilding Non-Animated Children

// BAD: Widget2 rebuilds every frame
AnimatedBuilder(
  animation: controller,
  builder: (context, child) {
    return Column(children: [
      Transform.translate(offset: Offset(controller.value * 100, 0), child: Widget1()),
      Widget2(), // Rebuilt every frame unnecessarily
    ]);
  },
)

// GOOD: pass non-animated as child
AnimatedBuilder(
  animation: controller,
  child: Widget2(), // Built once
  builder: (context, child) {
    return Column(children: [
      Transform.translate(offset: Offset(controller.value * 100, 0), child: Widget1()),
      child!, // Reused
    ]);
  },
)

3. Building Large Lists Eagerly

// BAD: creates all 10000 widgets immediately
ListView(children: List.generate(10000, (i) => ListTile(title: Text('$i'))))

// GOOD: lazy builder
ListView.builder(itemCount: 10000, itemBuilder: (ctx, i) => ListTile(title: Text('$i')))

4. Unnecessary saveLayer Calls

Widgets that trigger saveLayer: ShaderMask, ColorFilter, Chip (if disabledColorAlpha != 0xff), Text (with overflowShader)

5. Expensive Clipping

// BAD: antiAliasWithSaveLayer is the most expensive clip mode
ClipRRect(clipBehavior: Clip.antiAliasWithSaveLayer, ...)

// BETTER: use simple clip modes
ClipRRect(clipBehavior: Clip.hardEdge, ...)

// BEST: use decoration instead of clipping
Container(decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)))

6. Missing const Constructors

// BAD: rebuilt every time parent rebuilds
child: Text('Static Text')

// GOOD: skipped during rebuilds
child: const Text('Static Text')

7. Intrinsic Layout Passes

// BAD: intrinsic operations cause O(N^2) layouts
IntrinsicHeight(child: Row(children: manyChildren))

// GOOD: fixed sizes
SizedBox(height: 100, child: Row(children: manyChildren))

Appendix C: Isolates for UI Thread Relief

compute() — Simple Background Work

// Runs in a separate isolate (mobile/desktop) or main thread (web)
final result = await compute(expensiveFunction, inputData);

Isolate.run() — One-shot Isolate

final photos = await Isolate.run<List<Photo>>(() {
  final data = jsonDecode(jsonString) as List<Object?>;
  return data.cast<Map<String, Object?>>().map(Photo.fromJson).toList();
});

Long-lived Isolates

late final Isolate _isolate;
late final ReceivePort _receivePort;
late final SendPort _sendPort;

Future<void> _startWorker() async {
  _receivePort = ReceivePort();
  _isolate = await Isolate.spawn(_workerEntry, _receivePort.sendPort);
  _sendPort = await _receivePort.first as SendPort;
}

static void _workerEntry(SendPort mainSendPort) {
  final workerReceivePort = ReceivePort();
  mainSendPort.send(workerReceivePort.sendPort);

  workerReceivePort.listen((message) {
    // Process messages from main isolate
    final result = heavyComputation(message);
    mainSendPort.send(result);
  });
}

When to Use Isolates

Any operation that could take longer than one frame budget (16ms at 60Hz):

  • JSON parsing of large payloads
  • Image processing (resize, filter, compress)
  • Database queries
  • File I/O and parsing
  • Cryptographic operations
  • Complex list filtering/sorting

Appendix D: Quick Reference — Performance Checklist

Widget Layer

  • Use const constructors everywhere possible
  • Split large widgets into smaller focused widgets
  • Localize setState() to minimal subtrees
  • Use ListView.builder / GridView.builder for long lists
  • Pass non-animated children to AnimatedBuilder.child
  • Use RepaintBoundary around complex static subtrees
  • Avoid Opacity widget; prefer AnimatedOpacity or color blending
  • Use Clip.hardEdge instead of Clip.antiAliasWithSaveLayer
  • Set fixed sizes to avoid intrinsic layout passes

Rendering Layer

  • Enable Impeller (default on iOS/Android 29+)
  • Minimize saveLayer() calls
  • Avoid unnecessary clipping operations
  • Use checkerboardOffscreenLayers to detect saveLayer issues
  • Profile GPU thread for rasterization bottlenecks

Platform Layer

  • Set high refresh rate on Android (flutter_displaymode)
  • Set CADisableMinimumFrameDurationOnPhone on iOS
  • Use FFI instead of MethodChannel for high-frequency native calls
  • Batch platform channel calls
  • Use EventChannel for continuous data streams
  • Run platform channel handlers on background threads (Android)

Build & Size

  • Build with --split-debug-info for release
  • Use --obfuscate for release builds
  • Split APK by architecture (--split-per-abi)
  • Use App Bundles for Play Store
  • Enable R8/ProGuard shrinking (default)
  • Compress/optimize all image assets
  • Remove unused dependencies
  • Subset fonts to required glyphs

Startup

  • Minimize work in main() before runApp()
  • Defer heavy initialization to addPostFrameCallback
  • Use deferred components for optional features
  • Precache critical fonts and images
  • Use isolates for heavy startup computations

Profiling

  • Always profile on physical devices
  • Always use profile mode (flutter run --profile)
  • Monitor p50, p90, p99 frame times, not just average
  • Use DevTools Performance view for frame analysis
  • Use dart:developer Timeline API for custom tracing
  • Use SchedulerBinding.addTimingsCallback for automated monitoring
  • Track firstFrameRasterized for startup metrics

Web

  • Use --wasm build for Skwasm (better performance)
  • Enable deferred loading to reduce initial bundle
  • Subset and preload fonts
  • Profile with Chrome DevTools Performance panel

Desktop

  • Debounce resize handlers
  • Use RepaintBoundary for large viewports
  • Monitor for memory leaks in long-running sessions
  • Test on macOS with Impeller (experimental)