Jank Part 2: A Developer’s Guide to Stabilizing UI Performance

Jank. Unpredictable stutters, lagging scroll performance, or erratic data display — it all points to fundamental issues in UI construction…

Back to all articles

Jank. Unpredictable stutters, lagging scroll performance, or erratic data display — it all points to fundamental issues in UI construction.

Such instability can severely degrade the user experience. This article follows on from our deep dive on RepaintBoundary ➡️ found here and our app stability guide ➡️ found here. It describes a systematic approach to diagnosing and resolving common causes of performance degradation in Flutter, drawing from experience in stabilizing problematic applications.

The focus is on five practical, technical solution areas, followed by guidance on identifying these issues in your own projects.

We will delve into Flutter’s rendering and state management mechanics. These five areas of optimization are instrumental in transforming applications from sources of user frustration to performant and reliable tools.

Minimize Widget Rebuilds

Indiscriminate setState() usage, especially high in the widget tree, forces extensive, unnecessary subtree rebuilds, directly causing jank. The goal is to only trigger rebuilds for the minimal necessary UI.

To achieve this, you must localize state management. First, identify all setState() calls in your codebase. For each call, analyze the scope of its impact using Flutter DevTools’ “Highlight Repaints” feature during the relevant UI interaction. If unrelated UI elements are repainting, the scope is too broad.

Flutter DevTools: Inspector — Highlight repaints
Flutter DevTools: Inspector — Highlight repaints

The corrective action is to break down the StatefulWidget containing the broad setState() call. Encapsulate the specific state variables and the UI elements visually dependent on them into a new, smaller StatefulWidget. Move the setState() call into this new, focused widget. This process of componentization confines the rebuild process — the re-execution of build() methods — to only the necessary parts of your widget tree.

// Solution: Child StatefulWidget manages its own state and rebuild scope.
class MessageSection extends StatefulWidget {
  const MessageSection({super.key});
  @override State createState() => _MessageSectionState();
}

class _MessageSectionState extends State {
  String _message = "Initial Local";

  void _updateMessage(String newMessage) {
    // setState call is localized; only MessageSection's build()
    // is directly triggered by this.
    setState(() { _message = newMessage; });
  }

  // This build method runs when _message changes.
  @override Widget build(BuildContext context) {
    return Column(children: [
      Text(_message),
      ElevatedButton(
        onPressed: () => _updateMessage("Updated"),
        child: const Text("Update")
      )
    ]);
  }
}

Componentization also enables more effective const optimizations (Tip 2). Note that while this tip optimizes the build() phase by controlling setState scope, a widget that is correctly rebuilt might still contain graphically intensive operations. For such cases, Tip 4 addresses how to optimize the subsequent paint and layout phases for those specific complex elements.

TIP 1: Localizing setState() calls by componentizing your UI drastically reduces unnecessary widget rebuilds and unlocks more opportunities for const optimizations.

2. Design for const

Maximizing const usage is fundamental for optimal Flutter build performance, as it allows the framework to completely bypass the build() process for static UI segments. Although the Dart analyzer and its lints (like prefer_const_constructors and prefer_const_literals_to_create_immutables) excel at identifying const opportunities and preventing its misuse, your primary responsibility as an anti-jank developer is to architect widgets that are inherently immutable, thereby enabling widespread const application.

A widget instantiation can be const if its constructor is const and all its constructor arguments are compile-time constants. Compile-time constants include literals (e.g., 42, “text”), other const widget instantiations, or references to const variables. For a custom widget’s constructor to be const, all its fields must be final, and the constructor itself must be marked const.

  • When creating your own widgets, especially StatelessWidgets, always aim to make their constructors const. Ensure fields are final and initialized with parameters that can themselves be, or are resolved from, compile-time constants.
  • After splitting the broader widgets, you will usually find former StatefulWidgets can be made StatelessWidgets, further optimizing screen redraws.
// Solution: Design custom widgets with const constructors.
class StaticInfoCard extends StatelessWidget {
  final String title;
  final IconData icon;

  // This const constructor enables const instantiation of StaticInfoCard.
  // All fields are final and initialized via constructor.
  const StaticInfoCard({super.key, required this.title, required this.icon});

  @override Widget build(BuildContext context) {
    // The internal structure also uses const where possible.
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Row(children: [Icon(icon), const SizedBox(width: 8), Text(title)]),
      ),
    );
  }
}

// Usage: The linter would typically prompt for these 'const' keywords.
// Your design of StaticInfoCard made this possible.
Column(children: [
  // DynamicWidget(), // Assumed non-const widget
  const StaticInfoCard(title: "Help Center", icon: Icons.help_outline),
  const StaticInfoCard(title: "Settings", icon: Icons.settings),
]);

Tip 2: Immutable StatelessWidgets with const constructors proactively empower the linter to enforce widespread const usage for optimal build performance.

3. Lazy Loading with .builder Constructors

Building all items in long lists or grids at once (e.g., a Column in SingleChildScrollView) cripples performance, especially with large datasets.

To implement lazy loading, identify all scrollable views in your application that display collections of items. If they are currently built by manually mapping data to widgets within a Column or Row (often nested in a SingleChildScrollView), refactor them.

Replace this direct-construction approach with Flutter’s .builder constructors, such as ListView.builder, GridView.builder, or SliverList.builder (if using CustomScrollView). These constructors require an itemCount and an itemBuilder function, which is called only for items that are, or are about to become, visible.

// Solution: ListView.builder builds items on demand.
class MyEfficientDataList extends StatelessWidget {
  final List dataItems;
  const MyEfficientDataList({super.key, required this.dataItems});

  @override Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: dataItems.length,
      itemBuilder: (BuildContext context, int index) {
        // This ListTile is only built when it's about to be visible.
        return ListTile(title: Text(dataItems[index]));
      },
    );
  }
}

TIP 3: Employ .builder constructors for lists and grids to ensure efficient, “just-in-time” rendering of items, crucial for performance with large datasets.

4. Isolating Complex Painting with RepaintBoundary

Complex CustomPaint widgets or frequently updating animations can trigger repaints in unrelated UI parts if not isolated. While RepaintBoundary is key to addressing this by creating a separate paint layer for its child, its effectiveness can be undermined if the boundary itself changes size.

If the widget inside the RepaintBoundary changes its dimensions (e.g., an animating text string of varying length, an expanding graphic), the RepaintBoundary will resize accordingly. This size change forces the parent widget to perform a new layout pass, which can cascade up the widget tree. Consequently, other UI elements, even those distant from the animation, may be forced to rebuild and repaint due to these layout shifts, negating the paint isolation benefits.

Therefore, to correctly use `RepaintBoundary` for true isolation of both painting and layout:

  1. Identify candidates using DevTools “Highlight Repaints”: Look for frequently updating graphical widgets causing repaints beyond their bounds, or where changes to their content size trigger repaints in unrelated areas.
  2. Wrap the specific widget in a RepaintBoundary.
  3. Wrap the RepaintBoundary or its child with a SizedBox set to the maximum anticipated dimensions, use ConstrainedBox, or programmatically measure content (e.g., with TextPainter) to define fixed bounds. This prevents the boundary’s size changes from dirtying the layout of the broader UI.
// Solution: RepaintBoundary with stabilized size.
class AnimatingTextSection extends StatelessWidget {
  const AnimatingTextSection({super.key});

  static const double MAX_TEXT_WIDTH = 200.0;
  static const double FIXED_TEXT_HEIGHT = 25.0;

  @override Widget build(BuildContext context) {
    return Column(children: [
      const Text("Dynamic Content Area"),
      SizedBox( // Ensures stable dimensions for the animating area.
        width: MAX_TEXT_WIDTH,
        height: FIXED_TEXT_HEIGHT,
        child: RepaintBoundary( // Isolates painting of MyAnimatingTextWidget.
          child: MyAnimatingTextWidget(),
        ),
      ),
    ]);
  }
}
// MyAnimatingTextWidget is assumed to be a complex, self-repainting widget
// whose content might change, potentially affecting its intrinsic size.

Tip 4: Use RepaintBoundary with a fixed size to isolate both painting and layout for complex, dynamic graphical elements, preventing wider UI disruptions.


Saropa Contacts Case Study — An animated welcome caused the entire screen to rebuild

Illustration from article
Before and after RepaintBoundary + Sizedbox

Before and after RepaintBoundary + Sizedbox

How we fixed it:

// Displays animated welcome messages, optimized to prevent UI repaints.
return SizedBox(
  // Fixes the animation area`s size (200x35) to prevent layout shifts
  // when text content changes.
  // This is crucial for `RepaintBoundary` to effectively isolate painting.
  width: 200,
  height: 35,
  child: RepaintBoundary(
    // Isolates the repainting of `TextListFadeBetween` to this fixed area.
    // Prevents the animation from causing other UI parts to repaint.
    child: TextListFadeBetween(
      items: welcomeWords,                    // Texts to animate.
      fontSizeCommon: CommonFontSize.Larger,  // Text size.
      suffixTextBold: username,               // e.g., "Welcome, ..."
      repeatCount: 60,                        // Animation repetitions.
    ),
  ),
);

5. Ensuring FutureBuilder and StreamBuilder Stability

UI instability (flickering loaders, data reloads) often traces to incorrect Future/Stream handling in their respective builders, primarily from re-creating the async operation on every build.

To ensure stability, audit your FutureBuilder and StreamBuilder usages. Examine where the Future or Stream object passed to the future or stream property is created. If it’s generated by a method call directly within the build method, this is the issue.

Refactor by moving the creation of the Future or Stream into the State class’s initState() method, assigning it to an instance variable. The build method must then reference this stable, stored instance. If the Future or Stream needs to change based on widget properties, handle this in didUpdateWidget by conditionally creating a new async operation and calling setState to update the stored reference.

// Solution: Future is initialized in initState and reused.
class DataWidgetSolution extends StatefulWidget {
  const DataWidgetSolution({super.key});
  @override State createState() => _DataWidgetSolutionState();
}

class _DataWidgetSolutionState extends State {
  late Future _dataLoadingFuture;
  @override
  void initState() {
    super.initState();
    // Future created ONCE and stored.
    _dataLoadingFuture = _loadDataFromServer();
  }
  Future _loadDataFromServer() async { /* actual data fetching */
    await Future.delayed(const Duration(seconds: 1)); return "Fetched Data";
  }
  @override Widget build(BuildContext context) {
    return FutureBuilder(
      // Uses the STABLE, stored Future instance.
      future: _dataLoadingFuture,
      builder: (context, snapshot) { /* build UI based on snapshot */
        if (snapshot.connectionState == ConnectionState.waiting) return const CircularProgressIndicator();
        return Text(snapshot.data ?? "No data");
      },
    );
  }
}

Tip 5: Stabilizing your Future or Stream instances in initState() eradicates UI flicker and prevents wasteful, repeated asynchronous operations.


In Summary: Diagnosing Jank Causing Anti Patterns

  • If simple actions trigger repaints across large, unrelated UI sections, scrutinize your setState() calls. Look for state being managed too high in the widget tree, forcing distant descendants to rebuild. This points to the need for componentization.
  • Architect immutable StatelessWidgets with const constructors; this proactive design empowers the linter to enforce widespread `const` usage for optimal build performance.
  • If screens with lists or grids load slowly, exhibit jerky scrolling or cause high memory usage, search for manual construction of all list items at once (e.g., Column(children: list.map(…))) and refactor using the .builder constructors.
  • When jank specifically occurs around active graphical elements like charts or custom animations, use RepaintBoundar to constrain to the graphic’s logical bounds
  • If unrelated widgets also repaint when the size of the animated content changes (e.g., text in an animation changes length), the RepaintBoundary likely lacks a SizedBox or ConstrainedBox wrapper.
  • UI flickering (especially loading indicators appearing and vanishing), unexpected data re-fetching, or multiple identical network requests for a single view are strong indicators of Future or Stream objects being (repeated) created within a build method, rather than once in initState.

Proactive Performance Management in Flutter

Stabilizing a Flutter application requires a methodical approach rooted in understanding the framework’s core principles. The five optimization areas detailed — localizing setState (which facilitates const usage), employing lazy loading, isolating repaints, and ensuring stable asynchronous operations — address critical bottlenecks.

Moving beyond reactive fixes to proactively incorporate these practices is essential for high-performance Flutter applications, resulting in a more reliable, fluid, and professional user experience.

Dog Food!

Here is a python script that we use at Saropa to scan our projects for code smells: ➡️

gist.github.com — Flutter Stability Rules Checker v.1.4
gist.github.com — Flutter Stability Rules Checker v.1.4

Stabilizing a Flutter application requires a methodical approach rooted in understanding the framework’s core principles. Flutter DevTools, especially the Performance and Inspector tabs, is invaluable for identifying jank symptoms.


References:


Final Word 🪅

Illustration from article
saropa.com
Share this article

Your feedback is essential to us, and we genuinely value your support. When we learn of a mistake, we acknowledge it with a correction. If you spot an error, please let us know at blog@saropa.com and learn more at saropa.com.

Originally published by Saropa on Medium on May 20, 2025. Copyright © 2025