Mobile Development
FlutterMobile DevelopmentPerformanceProfilingDevToolsOptimization

Flutter Performance Optimization: How We Keep Apps at 60fps (Profiling + Fixes)

AO
Adrijan Omičević
·14 min read

# What You’ll Learn#

This guide teaches a repeatable Flutter performance optimization workflow we use in production to keep common interactions at 60fps on mid-range devices.

You’ll learn how to profile jank with Flutter DevTools, identify whether the bottleneck is UI thread work or raster thread work, and apply fixes in four high-impact areas: rebuild reduction, rendering, images, and isolates.

We’ll also define practical performance budgets for common screens and include before and after checklists you can re-run on every feature.

# Why 60fps Matters and What “Jank” Actually Is#

On a 60Hz device, you have about 16.67 milliseconds to build and render each frame. If your UI work or rasterization exceeds that budget, frames miss their deadline and users see stutter.

On 120Hz devices, the budget is about 8.33 milliseconds, so an app that “feels fine” at 60Hz can still feel laggy on modern phones.

ℹ️ Note: Many “performance problems” are actually consistency problems. A screen that averages 10ms but spikes to 40ms every few seconds still feels bad because humans notice irregular motion more than raw averages.

# Performance Budgets We Use for Common Screens#

Budgets help you avoid optimizing randomly. You pick a target device class, then decide what “good enough” means per interaction.

Screen or InteractionTarget FPSUI thread budgetRaster budgetNotes
App start to first interactive frame60less than 16.67ms per frameless than 16.67msMeasure separately from cold start time
Home list scroll with images60less than 10msless than 12msLeave headroom for GC and input
Product feed with animations60less than 8msless than 10msHeavy animations raise risk
Search typing with live suggestions60less than 6msless than 10msUI must stay responsive
Map screen panning60less than 8msless than 8msRaster often dominates
Modal open and close60less than 8msless than 8msJank is very noticeable here

If you target 120Hz devices, halve these budgets. In practice, we aim for headroom so the app stays stable when the OS schedules background work.

🎯 Key Takeaway: If you can’t state a budget for the interaction you’re optimizing, you’ll likely waste time on low-impact changes.

# Prerequisites#

RequirementVersionNotes
Flutter3.19+Stable recommended
DartBundledMatch Flutter SDK
Flutter DevToolsLatestUse DevTools bundled with Flutter
DevicePhysical Android and iOSMid-range devices reveal real bottlenecks
Build modesProfile and ReleaseDebug is not suitable for timing

You’ll also benefit from a clean baseline architecture. If your UI is tightly coupled to data fetching and business logic, rebuild control becomes harder. If you need structure help, see Flutter app architecture: Clean Architecture vs Feature-first and our guidance on scalable state patterns in Flutter state management in 2026.

# The Repeatable Workflow: Diagnose, Prove, Fix, Verify#

This is the workflow we use to avoid “cargo cult” optimizations.

Step 1: Reproduce and Record a Minimal Scenario#

Pick a single interaction that feels janky: fast scroll, opening a modal, switching tabs, typing in search, expanding a list item.

Define a reproducible script:

  1. 1
    Open screen
  2. 2
    Perform interaction for 10 to 15 seconds
  3. 3
    Stop
  4. 4
    Repeat twice to confirm consistency

Use real devices. Emulators hide GPU and thermal behavior, and iOS simulators are not representative.

Step 2: Turn On the Right Observability#

Enable these tools first, before changing code.

  1. 1
    Flutter performance overlay
  2. 2
    DevTools Performance timeline
  3. 3
    DevTools CPU profiler when needed
  4. 4
    Rebuild tracking when you suspect widget churn
Dart
// main.dart
import 'package:flutter/material.dart';
 
void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}
 
// Enable during profiling only.
class MyApp extends StatelessWidget {
  const MyApp({super.key});
 
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      showPerformanceOverlay: false, // set true during local profiling
      home: const HomeScreen(),
    );
  }
}

💡 Tip: Keep a dedicated “profiling flavor” with logging and overlays enabled. Don’t mix it into production builds, and don’t rely on debug-only flags for performance conclusions.

Step 3: Capture a Trace in DevTools Performance#

Run in profile mode:

Bash
flutter run --profile

Open DevTools, go to Performance, hit Record, perform the interaction, then stop.

Now classify the jank:

  • UI jank: the UI thread is missing frame deadlines because it is building layouts, running heavy Dart code, or causing frequent garbage collection.
  • Raster jank: the GPU thread can’t keep up due to expensive shaders, clipping, blurs, large images, or too many layers.

Step 4: Identify the Hotspot and Its Category#

Use these signals:

  • Flutter Frames chart: shows which frames missed.
  • Timeline events: look for long “Build”, “Layout”, “Paint”, “Rasterize”.
  • CPU profiler: check Dart hot paths when UI thread is slow.
  • Rebuild stats: find widgets rebuilding too often.

Your goal is to answer one question: what is the most expensive work happening during the missed frames, and why is it happening?

Step 5: Apply One Fix at a Time, Then Re-measure#

Make one change, re-run the same script, capture another trace, and compare.

If your traces don’t improve, revert. Performance work is iterative and evidence-driven.

# Diagnosing Jank with DevTools: What to Look For#

Interpreting the Performance Overlay#

The overlay shows two graphs:

  • Top graph: UI thread work
  • Bottom graph: Raster thread work

If the top graph spikes, suspect rebuilds, layout, or synchronous compute on the main isolate. If the bottom graph spikes, suspect rendering complexity and images.

Reading a Timeline Trace Like a Checklist#

In the recorded timeline:

  1. 1
    Find a janky frame (over budget).
  2. 2
    Zoom in.
  3. 3
    Check which phase dominates:
    • Build and Layout means widget churn or complex layouts.
    • Paint means heavy custom painting or effects.
    • Raster means shader effects, clips, image scaling, or too many layers.
  4. 4
    Identify repeated patterns across multiple janky frames.

When to Use the CPU Profiler#

If the UI thread is slow but the timeline doesn’t clearly show why, start the CPU profiler and repeat the interaction. Look for:

  • Heavy JSON decoding
  • Expensive string operations
  • Sorting large lists
  • Diffing and mapping large collections on every frame
  • Synchronous file IO or large preference reads

# Fix Category 1: Rebuild Reduction (The Highest ROI)#

Excessive rebuilds are one of the most common causes of jank in Flutter apps because they increase both UI work and downstream layout and paint.

Symptom Patterns#

  • Scrolling a list triggers rebuilds in unrelated widgets like app bars and footers.
  • Typing in a search box causes the entire page to rebuild.
  • Changing a single item in a list rebuilds all list tiles.

Fix 1: Reduce Rebuild Scope by Splitting Widgets#

If you keep everything in one large build method, Flutter can’t isolate what should rebuild.

Dart
// Bad: big build method reacts to small state changes.
@override
Widget build(BuildContext context) {
  final state = context.watch<HomeState>();
  return Column(
    children: [
      Header(user: state.user),
      SearchBar(query: state.query),
      Expanded(child: ResultsList(items: state.items)),
      Footer(version: state.version),
    ],
  );
}

Split and select only what each sub-widget needs.

Dart
// Better: smaller rebuild surfaces via selectors.
class HeaderSection extends StatelessWidget {
  const HeaderSection({super.key});
 
  @override
  Widget build(BuildContext context) {
    final userName = context.select<HomeState, String>((s) => s.user.name);
    return Header(userName: userName);
  }
}

This approach works with multiple state solutions, but the principle is the same: subscribe to the smallest slice.

Fix 2: Prefer Const and Stable Widget Subtrees#

Const widgets reduce rebuild cost and object churn. Stable subtrees can also avoid relayout.

Dart
return const Padding(
  padding: EdgeInsets.all(16),
  child: Text('Settings'),
);

Also avoid creating new objects in build when they don’t need to change, especially TextStyle, BorderRadius, EdgeInsets, and Duration.

Fix 3: Avoid Rebuilding Lists When One Item Changes#

For lists, update the item, not the list. Common techniques:

  • Use keyed list items so Flutter can preserve element state.
  • Use granular state per row when possible.
  • Avoid setState at the page level for per-row changes.
Dart
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    final item = items[index];
    return KeyedSubtree(
      key: ValueKey(item.id),
      child: ProductTile(item: item),
    );
  },
);

Fix 4: Cache Derived Data, Don’t Recompute on Every Frame#

If you compute filtered lists, grouped sections, or formatted strings in build, you’re paying that cost for every rebuild.

Move it to:

  • Memoized selectors
  • View-model computed fields
  • A one-time pre-processing step when data changes

This is where architecture matters. A predictable state layer makes it easier to compute once and reuse. If your state layer is messy, revisit Flutter state management in 2026.

⚠️ Warning: Don’t “optimize” by adding global caches everywhere. Most performance regressions come from unbounded caching, stale data, and complexity that prevents future fixes.

# Fix Category 2: Rendering and Layout (When Raster or Paint Spikes)#

If the raster thread spikes, rebuild optimization alone won’t help. You need to reduce the cost of painting.

Common Rendering Cost Drivers#

Cost driverWhy it’s expensiveTypical fix
Backdrop blurRequires offscreen renderingRemove blur or limit blur area
Excessive clippingPrevents certain GPU optimizationsClip only where necessary
Large shadowsExtra blur passesUse lighter shadows, smaller spread
Nested opacityForces offscreen layersAvoid opacity on large subtrees
OverdrawPainting pixels multiple timesSimplify backgrounds and layers

Fix 1: Use RepaintBoundary Where It Actually Helps#

RepaintBoundary isolates repaint regions. Use it when a small animated part causes a large static area to repaint.

Good candidates:

  • Animated counters inside a static card
  • Small progress indicators in a big list item
  • Video thumbnails with overlay animations

Bad candidates:

  • Wrapping everything, which increases layer count and memory usage
Dart
RepaintBoundary(
  child: AnimatedBuilder(
    animation: animation,
    builder: (context, _) => Transform.scale(
      scale: animation.value,
      child: const Icon(Icons.favorite),
    ),
  ),
);

Fix 2: Avoid Layout Thrash From Intrinsic Measurements#

Widgets that rely on intrinsic sizing can cause extra layout passes. If you see repeated layout in traces, review usage of intrinsic measurement patterns and replace with explicit constraints.

Prefer SizedBox, ConstrainedBox, and well-defined layouts.

Fix 3: Keep Scrollable Hierarchies Simple#

Nested scrollables and complex slivers can be correct, but they are easy to make expensive.

Practical guidance:

  • Prefer one primary scrollable per screen.
  • Use ListView.builder or slivers with lazy building.
  • Avoid shrinkWrap: true in large lists unless you must, because it can force full layout.

# Fix Category 3: Images (The Silent Performance Killer)#

Images can cause both UI and raster jank due to decoding, resizing, and overdraw.

Image Budgets That Prevent Most Problems#

ScenarioRecommended max display sizeRecommended file sizeNotes
List thumbnail64 to 120 logical pixels10 to 30KBUse WebP or AVIF when possible
Card hero image300 to 500 logical pixels wide50 to 150KBCache and prefetch
Full-screen imageMatch device width150 to 400KBProgressive loading helps

If you decode a 4000 by 3000 JPEG for a 100 by 100 thumbnail, you will pay for it in memory and time.

Fix 1: Decode to Target Size#

Use cacheWidth and cacheHeight so Flutter decodes close to the display size.

Dart
Image.network(
  url,
  width: 96,
  height: 96,
  fit: BoxFit.cover,
  cacheWidth: 192,  // roughly 2x for high density screens
  cacheHeight: 192,
);

Fix 2: Precache Strategically for Scroll#

If your list scrolls into images that decode at the same time, you get stutter. Precache the next few images when you have idle time.

Dart
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  for (final url in urls.take(5)) {
    precacheImage(NetworkImage(url), context);
  }
}

Fix 3: Reduce Overdraw in Image-heavy Cards#

Avoid stacking multiple translucent gradients, blurs, and shadows on top of large images. If the design requires it, constrain the effect area.

# Fix Category 4: Isolates and Background Work (When CPU Spikes)#

If your timeline shows long Dart tasks, you’re likely blocking the main isolate.

Use isolates for:

  • Parsing large JSON payloads
  • Data transformation on large lists
  • Encryption and hashing
  • Generating PDFs or thumbnails

Practical Rule of Thumb#

If a synchronous task takes more than about 4 to 8 milliseconds on your target mid-range device, treat it as a candidate for isolate offloading.

Example: Parse JSON in an Isolate Using compute#

Dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
 
List<Map<String, dynamic>> parseItems(String body) {
  final decoded = jsonDecode(body) as List<dynamic>;
  return decoded.cast<Map<String, dynamic>>();
}
 
Future<List<Map<String, dynamic>>> parseItemsAsync(String body) {
  return compute(parseItems, body);
}

This keeps the UI isolate available for rendering. Pair it with caching so you don’t re-parse the same payload repeatedly.

Don’t Move Everything Off the Main Isolate#

Isolates add overhead and complexity. They also require message passing and copying, which can be expensive for large objects.

Use isolates for heavy work, not as a default.

# Before and After: A Checklist You Can Re-run on Every Feature#

This is the difference between “we optimized once” and “we keep the app fast”.

Before Profiling Checklist#

CheckTargetHow to verify
Profile mode on deviceYesflutter run --profile
Repro script definedYesSame steps each run
Budgets definedYesUse table in this post
Logging minimizedYesAvoid spam in hot paths
Images realisticYesTest with production-like media

After Fixes Checklist#

CheckTargetHow to verify
Missed frames reducedYesDevTools Performance frames chart
UI and raster headroomAt least 20 percentOverlay graphs stay under budget
Rebuild counts downYesRebuild stats and widget inspector
No new memory spikesYesDevTools Memory and GC frequency
Verified in releaseYesflutter run --release on device

💡 Tip: Keep screenshots of “before” and “after” traces in your PR description. It makes performance work reviewable and prevents regressions from being reintroduced later.

# How Performance Work Affects Cost and Delivery#

Performance optimization is not free, but it’s predictable when you apply budgets early. Fixing performance after features ship tends to cost more because UI, state, and data layers become harder to refactor.

If you’re planning a new app, budgeting time for profiling and performance gates early can reduce rework and store review risk. For a cost breakdown mindset, see Flutter app development cost.

# Key Takeaways#

  • Profile in profile mode on real devices, capture DevTools traces, and classify jank as UI-thread or raster-thread before touching code.
  • Reduce jank fastest by shrinking rebuild scope using widget splitting, selectors, stable subtrees, and const constructors.
  • When raster spikes, optimize rendering complexity by limiting blur, clipping, opacity layers, and using RepaintBoundary only for targeted isolation.
  • Treat images as a pipeline: decode to target size with cacheWidth and cacheHeight, prefetch strategically, and avoid expensive overlays.
  • Offload heavy CPU tasks to isolates when synchronous work exceeds about 4 to 8 milliseconds on your target device, then verify with before and after traces.

# Conclusion#

Flutter performance optimization is easiest when it’s a workflow, not a one-off sprint: set budgets, reproduce jank, capture traces, apply one fix, and verify in release builds.

If you want us to audit your app’s slowest screens, define performance budgets for your product, and implement fixes without destabilizing architecture, talk to Samioda. We build Flutter apps that stay smooth as they scale, and we back changes with measurable before and after traces.

FAQ

Share
A
Adrijan OmičevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.