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 ScenephysicalSize— Current physical size of the rendering rectangledisplay— The Display object containing this viewdevicePixelRatio— Logical-to-physical pixel ratiophysicalConstraints— Sizing constraints in physical pixelsviewInsets— 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
| Feature | Debug | Profile | Release |
|---|---|---|---|
| Assertions | Enabled | Disabled | Disabled |
| Service extensions | Enabled | Partial | Disabled |
| DevTools connection | Yes | Yes (mobile) | No |
| Hot reload | Yes | No | No |
| Compilation | JIT (mobile) | AOT | AOT |
| Optimizations | None | Full | Full |
| Binary size | Large | Medium | Small |
| Performance | Poor | Near-release | Best |
| Emulator/Simulator | Yes | No | No |
| Web compiler | dartdevc | dart2js | dart2js |
| Web minification | No | No | Yes |
| Web tree shaking | No | Yes | Yes |
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:
- Track Widget Builds — Shows
build()method events in timeline with widget names; identifies excessive rebuilds - Track Layouts — Shows render object layout events; detects layout performance issues
- 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
}
Flow Events (Linking Related Operations)
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_millis90th_percentile_frame_build_time_millis99th_percentile_frame_build_time_millisworst_frame_build_time_millisaverage_frame_rasterizer_time_millis90th_percentile_frame_rasterizer_time_millis99th_percentile_frame_rasterizer_time_millisworst_frame_rasterizer_time_millisaverage_cpu_usage/average_gpu_usagerelease_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
- Always benchmark on physical devices, never emulators
- Always use profile or release mode
- Run on the slowest device you support
- Warm up before measuring (first runs include compilation overhead)
- Compare results only across identical environments
- Measure p50, p90, p99, and worst-case, not just average
- Use
benchcommand 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
- Serialization/Deserialization: StandardMessageCodec converts Dart objects to binary and back
- Thread hopping: Messages cross from Dart isolate to platform thread
- Asynchronous nature: Every call involves Future/callback overhead
- 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
@UiThreadannotation); useHandler(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
| Aspect | MethodChannel | EventChannel | dart:ffi |
|---|---|---|---|
| Communication | Request-response | Stream-based | Direct function call |
| Serialization | StandardMessageCodec | StandardMessageCodec | None (direct memory) |
| Threading | Async, main thread | Async, main thread | Synchronous by default |
| Overhead per call | ~0.1-1ms | ~0.05ms (after setup) | ~0.001-0.01ms |
| Use case | One-off calls | Continuous data streams | High-frequency/compute |
| Type safety | Manual / Pigeon | Manual | Generated bindings (ffigen) |
| Platform support | All (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
| Scenario | Best Choice |
|---|---|
| Read battery level | MethodChannel |
| Get device info | MethodChannel |
| Listen to accelerometer | EventChannel |
| Stream GPS updates | EventChannel |
| Image manipulation (pixel ops) | FFI |
| Crypto operations | FFI |
| Audio signal processing | FFI |
| Game physics engine | FFI |
| SQLite database access | FFI |
| Push notification handling | MethodChannel |
| Bluetooth data streaming | EventChannel |
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:
- Add Play Core dependency:
// build.gradle.kts
dependencies {
implementation("com.google.android.play:core:1.8.0")
}
- 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>
- 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
pngcrushoroptipng - Optimize JPEG with
jpegoptim - Use WebP format where supported (smaller than PNG/JPEG)
- Consider SVG for vector graphics (
flutter_svgpackage)
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:
| Setting | Debug | Release |
|---|---|---|
| Optimization Level | -O0 (None) | -Os (Size) |
| Swift Optimization Level | -Onone | -O |
| Debug Information Format | DWARF | DWARF+dSYM |
| Strip Debug Symbols | No | Yes |
| Dead Code Stripping | No | Yes |
| Link-Time Optimization | No | Yes (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
| Aspect | CanvasKit | Skwasm |
|---|---|---|
| Build mode | Default (any build) | WebAssembly (--wasm) only |
| Browser support | All modern browsers | Requires WasmGC support |
| Download size | ~1.5MB additional | ~1.1MB additional |
| Technology | Full Skia -> WebAssembly | Streamlined Skia + WASM |
| Multi-threading | No | Yes (via web workers) |
| Startup speed | Baseline | Faster |
| Frame performance | Baseline | Better |
| Fidelity | High (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
.jsfiles - Downloaded on demand when
loadLibrary()is called
Font Optimization for Web:
- Use
FontLoaderto preload critical fonts - Subset fonts to reduce download size
- Consider system fonts for faster initial render
WebAssembly Compatibility Requirements
To use --wasm builds:
- Use
dart:js_interop(not deprecateddart:jsorpackage:js) - Use
package:webfor Web APIs (not deprecateddart:html) - 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.clientis 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
- Keyboard and mouse optimization: Desktop apps receive more events per second than touch; debounce where appropriate
- Window resize handling: Use
LayoutBuilderand debounce heavy rebuilds during resize - Memory management: Desktop apps tend to run longer; watch for memory leaks
- Large viewport rendering: Desktop screens are larger; use
RepaintBoundaryto limit repaint regions - Multi-window: Not yet natively supported; use packages like
desktop_multi_windowfor 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
- No shader compilation stutter — all shaders compiled offline at build time
- Predictable frame times — pre-compiled pipeline state objects
- Uses modern GPU APIs — Metal (iOS/macOS), Vulkan (Android)
- Multi-threaded rendering — distributes single-frame work across threads
Platform Status (Flutter 3.27+)
| Platform | Status |
|---|---|
| iOS | Only renderer (Skia removed) |
| Android | Default on API 29+; falls back to OpenGL |
| macOS | Experimental (behind flag) |
| Windows | Not yet available |
| Linux | Not yet available |
| Web | Not 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
constconstructors everywhere possible - Split large widgets into smaller focused widgets
- Localize
setState()to minimal subtrees - Use
ListView.builder/GridView.builderfor long lists - Pass non-animated children to
AnimatedBuilder.child - Use
RepaintBoundaryaround complex static subtrees - Avoid
Opacitywidget; preferAnimatedOpacityor color blending - Use
Clip.hardEdgeinstead ofClip.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
checkerboardOffscreenLayersto detect saveLayer issues - Profile GPU thread for rasterization bottlenecks
Platform Layer
- Set high refresh rate on Android (
flutter_displaymode) - Set
CADisableMinimumFrameDurationOnPhoneon 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-infofor release - Use
--obfuscatefor 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()beforerunApp() - 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:developerTimeline API for custom tracing - Use
SchedulerBinding.addTimingsCallbackfor automated monitoring - Track
firstFrameRasterizedfor startup metrics
Web
- Use
--wasmbuild 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
RepaintBoundaryfor large viewports - Monitor for memory leaks in long-running sessions
- Test on macOS with Impeller (experimental)