Skip to content
Blog

Optimizing Dart Performance for Smooth 120fps Apps

Master Dart performance: const constructors, lazy initialization, isolates with compute(), scoped setState, RepaintBoundary, and DevTools profiling to hit buttery-smooth 120fps.

Published on August 18, 2026

AI Assistant

A 120Hz display gives you 8.3ms per frame. Miss that budget and the UI janks — dropped frames, stuttering scroll, laggy gestures — and no amount of visual polish saves the experience. The good news: Dart gives you the tools to stay inside the budget. The discipline is knowing which levers to pull and in what order.

In this guide you will learn the techniques that keep Flutter apps smooth at 120fps: canonicalized const constructors, late final lazy initialization, offloading heavy work to isolates with compute(), scoping setState() calls, and isolating repaints with RepaintBoundary. You’ll close with a DevTools workflow that turns “feels janky” into a measured, fixed problem. Key technologies: Dart’s compile-time constants, isolates and the compute() helper, Flutter’s widget/rendering pipeline, and Flutter DevTools.

Prerequisites

  • Flutter SDK 3.x with Dart 3.x
  • A Flutter project you can run in profile mode on a physical device
  • Flutter DevTools (ships with the Flutter SDK)
  • Basic familiarity with StatefulWidget and setState

Core Content

Know your frame budget

At 60Hz you have 16.6ms per frame; at 120Hz that drops to 8.3ms. Flutter spends that budget across two threads: the UI thread (build + layout + paint) and the raster thread (Impeller/engine compositing). The performance overlay shows both — if either graph’s bars cross the 16ms (or 8.3ms) marker, you’re dropping frames.

Before optimizing, capture real numbers. SchedulerBinding.addTimingsCallback gives you per-frame build and raster durations directly:

void watchFrames() {
  SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
    for (final timing in timings) {
      final total = timing.totalSpan.inMicroseconds / 1000;
      if (total > 8.3) {
        debugPrint('Over budget: ${total.toStringAsFixed(1)}ms');
      }
    }
  });
}

Rule one: never guess. Profile first, optimize second.

Canonicalize with const

The single cheapest optimization in Dart is const. A const constructor produces a canonicalized instance — identical constructor calls return the same object, so nothing is allocated at runtime and, crucially, Flutter can short-circuit rebuilds when it encounters the same instance of a widget as the previous frame.

class MetricChip extends StatelessWidget {
  const MetricChip({super.key, required this.label, required this.value});

  final String label;
  final int value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8),
      child: Text('$label: $value'),
    );
  }
}

Note the const on the constructor and on EdgeInsets.all(8). Using these in a list flips the same trick:

const supportedModes = ['profile', 'release', 'debug'];

The list is a compile-time constant — one allocation, ever. Without const, a list declared inside build() is reallocated on every rebuild. Enable the prefer_const_constructors lint (included in flutter_lints) and let the analyzer flag every missed opportunity.

Lazy initialization with late final

Not everything can be constant. For fields that are expensive to produce and rarely used, defer the work with late final. The initializer runs once, on first access, and the result is cached:

class TelemetryService {
  late final List<double> calibrationCurve = _loadCalibration();
  late final int deviceFingerprint = computeFingerprint();
}

late final gives you lazy initialization and immutability — exactly what you want for caches and derived data. Just don’t reach for late as a habit in State fields that depend on the widget; those belong in initState.

Offload heavy work with isolates

Dart is single-threaded per isolate, but isolates run in parallel on separate cores. Heavy CPU work — JSON parsing, image decoding, hashing, matrix math — must not run on the UI isolate. Flutter’s compute() runs a top-level or static function on a background isolate and returns a Future:

Future<ChartData> parseChartData(Uint8List bytes) async {
  return compute(_decodeChart, bytes);
}

ChartData _decodeChart(Uint8List bytes) {
  final json = jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
  final points = <Offset>[];
  for (final row in json['points'] as List) {
    final p = row as Map<String, dynamic>;
    points.add(Offset(
      (p['x'] as num).toDouble(),
      (p['y'] as num).toDouble(),
    ));
  }
  return ChartData(points);
}

compute() requires the function be top-level or static because isolates can’t close over the surrounding scope. For repeated work, spawn a long-lived isolate with Isolate.spawn and communicate over a SendPort instead of paying isolate startup cost per call. Parse JSON in the background this way and the UI thread stays free to hit its 8.3ms budget.

Scope setState()

When you call setState() on a State object, every descendant rebuilds. Call it high up the tree and you rebuild widgets that didn’t change. Localize the call to the smallest subtree whose UI actually changes:

class _KnobSlider extends StatefulWidget {
  const _KnobSlider();
  @override
  State<_KnobSlider> createState() => _KnobSliderState();
}

class _KnobSliderState extends State<_KnobSlider> {
  double _value = 0.5;

  void _onChanged(double next) {
    setState(() => _value = next);
  }

  @override
  Widget build(BuildContext context) {
    return Slider(
      value: _value,
      onChanged: _onChanged,
    );
  }
}

The expensive chart beside the slider never rebuilds because its setState lives in its own State. Also prefer a StatelessWidget over a helper function returning widgets — widgets get const canonicalization and identity short-circuiting; plain functions rebuild their whole subtree every time.

Cut repaints with RepaintBoundary

setState scoping reduces build cost; RepaintBoundary reduces paint cost. It caches the layer subtree as a raster, so repaints inside the boundary don’t force the rest of the scene to repaint. Wrap rarely-changing, expensive-to-paint subtrees:

RepaintBoundary(
  child: CustomPaint(painter: RadarPainter()),
)

Place it deliberately — every boundary adds memory and can add GPU work, so don’t wrap a widget that repaints every frame anyway. Scrollable lists, maps, and heavy CustomPaint widgets are the classic winners.

Similarly, avoid repaint-heavy operations: animating Opacity, Clip, or ColorFilter forces offscreen buffers via saveLayer(), which is notoriously expensive — prefer animating opacity on a FadeTransition (or AnimatedOpacity) which can be handled more cheaply, and skip saveLayer()-heavy effects during scroll.

Profile with DevTools

Optimization without measurement is cargo culting. Flutter DevTools is the measuring stick:

flutter run --profile

Run in profile mode — debug mode’s JIT and asserts skew results, and emulators don’t reflect real hardware. On a physical device:

  1. Open the Performance view, start recording, and reproduce the jank.
  2. Read the frame timeline: red/orange frames exceed the budget. Click one to see which phase blew it — Build, Layout, Raster, or Paint.
  3. Open the CPU Profiler and sample the same interaction. Look for self-time hotspots on the UI thread: string concatenation in loops, list allocations, JSON parsing.
  4. Toggle the Performance Overlay to see both threads live.
  5. Also check checkerboardOffscreenLayers in the Performance view to spot unexpected saveLayer() calls.

Measure before and after each change, one change at a time. Numbers, not vibes.

Putting It All Together

A smooth 120fps screen is a stack of small wins. Start from the bottom: measure with the overlay and frame timings. Then make every widget const, use late final for lazy caches, push parsing off the UI thread with compute(), keep setState() scoped to the widget that changes, wrap heavy paints in RepaintBoundary, and re-measure. Each technique is small; combined, they keep you inside the 8.3ms budget on a 120Hz panel.

Conclusion & Next Steps

Dart and Flutter are fast by default — jank is almost always something we add: allocations in build(), work on the wrong thread, rebuilds and repaints that outlive their usefulness. Master the basics above and most frame-budget problems disappear before you need more exotic tooling.

Next, dig into the official docs: Dart’s guide to writing performant code, Flutter’s performance best practices, and the DevTools performance view for deeper profiling. Then benchmark real frames with flutter drive and keep that 8.3ms budget sacred.