Available for work
Ankit Ranjan
12 min read

Diagnosing Jank and When Isolates Actually Help

Part 3 of 3: How to read the DevTools timeline, distinguish UI-thread jank from raster-thread jank, understand when isolates help (and when they're useless), and master Flutter's full arsenal of performance short-circuits.

You’ve traced the frame from VSync to layout (Part 1) and from paint to GPU (Part 2). Now the pipeline breaks. The app janks. What do you do?

The common instinct is “make it async” or “spawn an isolate.” But that only fixes one category of jank. Apply it to the wrong kind and you waste hours. This part teaches you to diagnose which thing broke and apply the correct fix.

Rung 7: Diagnosing Jank — Build vs Layout vs Raster

The Two-Track Mental Model

A frame drops when either the UI thread or the raster thread exceeds the budget. They’re different threads doing different work:

  • UI thread — Runs Dart. Your code, the framework’s build(), layout, paint (recording commands). Produces the layer tree.
  • Raster thread — Runs C++. Takes the layer tree and turns it into GPU commands. No Dart here.

If the UI track is tall, the problem is in Dart. If the raster track is tall, the problem is in compositing/GPU work. The fixes are completely different.

Reading the DevTools Timeline

Open DevTools in profile mode (flutter run --profile). Go to the Performance tab. Record some interaction. You’ll see two horizontal tracks:

DevTools timeline showing UI and Raster tracks

Each bar is a frame. Green bars fit within the budget line. Red bars exceeded it. Look at which track has the tall bars:

Tall UI bar, short Raster bar — The problem is on the UI thread. Your Dart code is doing too much work: expensive build(), synchronous computation, large rebuild scope.

Short UI bar, tall Raster bar — The problem is on the raster thread. The layer tree is expensive to composite: Opacity widget (uses saveLayer), BackdropFilter, complex clips, overdraw from too many stacked widgets.

Both tall — You have both problems. Fix them separately, starting with whichever is worse.

UI Track Jank: Causes and Fixes

When the UI track is red, look for:

Expensive build()

// Bad: O(n²) inside build
Widget build(BuildContext context) {
for (var i = 0; i < items.length; i++) {
for (var j = 0; j < items.length; j++) {
// something expensive
}
}
return Container();
}

Fix: move computation out of build, cache results, use more efficient algorithms.

Synchronous parsing

// Bad: blocks UI thread
Widget build(BuildContext context) {
final data = jsonDecode(hugeJsonString); // 50ms parse
return DataView(data: data);
}

Fix: parse asynchronously before the widget builds, or use Isolate.run().

Large rebuild scope

// Bad: setState at root rebuilds everything
class _AppState extends State<App> {
int counter = 0;
void _increment() {
setState(() => counter++); // Rebuilds entire widget tree
}
}

Fix: push state down to smaller widgets, use ValueListenableBuilder, or add const to static subtrees.

Missing const constructors

// Bad: creates new widget every rebuild
child: Text('Static text'),
// Good: reuses same instance
child: const Text('Static text'),

ListView without itemExtent

// Bad: measures every child
ListView.builder(itemBuilder: ...);
// Better: knows size without measuring
ListView.builder(
itemExtent: 56.0, // Fixed height per item
itemBuilder: ...,
);

Raster Track Jank: Causes and Fixes

When the raster track is red, look for:

Opacity widget

// Bad: creates saveLayer (expensive)
Opacity(
opacity: 0.5,
child: MyWidget(),
)
// Better: animate opacity without saveLayer
FadeTransition(
opacity: animation,
child: MyWidget(),
)
// Best: bake opacity into the paint
Container(
color: Colors.blue.withOpacity(0.5),
)

BackdropFilter

// Very expensive: reads back rendered pixels
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(color: Colors.white.withOpacity(0.3)),
)

BackdropFilter must read pixels that have already been rendered, apply a filter, then composite. It’s inherently expensive. Consider:

  • Caching the blurred content if it doesn’t change often
  • Using a static blurred image instead of live blur
  • Reducing blur radius
  • Limiting the blur area

Complex ClipPath

// Expensive: arbitrary path clipping
ClipPath(
clipper: MyComplexClipper(),
child: ...,
)
// Cheaper: rectangular clip
ClipRect(
child: ...,
)

Overdraw

Every stacked widget draws pixels. If you have 10 Containers with backgrounds stacked on top of each other, the GPU paints 10 layers of pixels even though only the top one is visible.

Use the DevTools “Highlight Repaint Rainbows” to see what’s repainting. Use the “Performance Overlay” to see raster thread load.

UI jank vs Raster jank comparison

Teach-test: Given a screen that’s smooth on first build but janks during a fade transition, predict which track is tall and name the fix before profiling.

Answer: Raster track is tall. A fade transition likely uses an Opacity widget or FadeTransition. If using Opacity, it creates a saveLayer every frame during the animation. Fix: use FadeTransition with an AnimationController (it uses RenderAnimatedOpacity which avoids saveLayer in many cases), or bake the opacity directly into the child’s paint where possible.


Rung 8: Isolates — Where Concurrency Actually Fits

Now you understand where isolates fit: they help UI-thread compute jank only. They do nothing for raster jank, nothing for rebuild scope issues, nothing for layout costs.

What Isolates Are

An isolate is Dart’s concurrency unit. Each isolate has:

  • Its own heap — Completely separate memory. No pointers cross isolate boundaries.
  • Its own event loop — Processes one task at a time, cooperatively (not preemptively).
  • No shared mutable state — The constraint that makes this safe.
Isolates have separate heaps and communicate via message passing

Because there’s no shared memory, there are no locks and no data races. The trade-off: communication happens by copying messages, not by sharing pointers.

Message Passing

Isolates communicate through SendPort and ReceivePort:

// Main isolate
final receivePort = ReceivePort();
await Isolate.spawn(
workerEntryPoint,
receivePort.sendPort,
);
// Worker sends result back
receivePort.listen((message) {
print('Got result: $message');
});
// Worker isolate
void workerEntryPoint(SendPort sendPort) {
final result = heavyComputation();
sendPort.send(result);
}

Messages are serialised and copied. This has implications:

Can send:

  • Primitives (int, double, bool, String)
  • List, Map (of sendable types)
  • TypedData (efficient for byte arrays)
  • SendPort (to enable two-way communication)
  • Capability

Cannot send:

  • Functions / closures (they capture state from the source heap)
  • Widget, BuildContext, RenderObject (framework objects)
  • Arbitrary class instances (unless they’re simple data)

The Simple API: Isolate.run()

For one-shot computations, use Isolate.run():

Future<void> processImage() async {
final processedBytes = await Isolate.run(() {
// This runs in a fresh isolate
return expensiveImageProcessing(imageBytes);
});
// Back on main isolate, update state
setState(() => _image = processedBytes);
}

Isolate.run() handles spawning, port setup, and cleanup. The closure is copied to the new isolate, so captured variables must be sendable.

When Isolates Help

Isolates help when:

  1. You have CPU-bound computation that takes >5ms on the main isolate
  2. That computation is in your Dart code (not framework code)
  3. The input and output can be efficiently serialised

Examples:

  • JSON parsing of large responses
  • Image manipulation
  • Cryptographic operations
  • Data transformation

When Isolates Don’t Help

Isolates don’t help when:

  1. The problem is raster jank — Isolates don’t touch the raster thread
  2. The problem is rebuild scope — Moving a widget to an isolate is impossible; Widget can’t cross the boundary
  3. The problem is build() overhead — You need smaller widgets, not more threads
  4. The data is too expensive to copy — Serialisation overhead can exceed the compute savings

Teach-test: Explain why you can’t pass a Widget or a BuildContext to an isolate, and what category of work is safe to send.

Answer: Widget and BuildContext are objects that live on the main isolate’s heap. They reference other framework objects (Elements, RenderObjects, State) that also live there. Isolates share no memory, so these pointers would be meaningless on the worker isolate. You can only send data that’s either primitive, a container of sendable types, or explicitly designed for cross-isolate transfer (TypedData, SendPort). Safe work includes: raw data processing (bytes, numbers), parsing/encoding (JSON, protobuf), computation that takes data in and produces data out without needing framework access.


Rung 9: The Synthesis — How Flutter Avoids Redoing Work

We’ve traced the entire pipeline. Now step back and see the pattern: Flutter is optimised to not redo work it doesn’t have to.

Flutter's six short-circuit optimisations

1. const Widgets Skip Rebuilds

// Without const: new instance every rebuild
Text('Hello'),
// With const: same instance, skip comparison
const Text('Hello'),

const widgets are canonicalised at compile time. When the parent rebuilds, Flutter checks if the new child widget is identical to the old one. If it’s the same const instance, the diff short-circuits — no need to even compare fields.

2. Element Reuse Skips Recreation

// New widget with different text
Text(counter.toString())

Even without const, if the widget’s runtimeType and key match the previous widget, the Element is reused. The Element calls update() on itself, which tells the RenderObject to update. No tear-down, no recreation.

This is why widget rebuilds are cheap. The expensive objects persist.

3. Relayout Boundary Stops Layout Propagation

// Child size doesn't affect parent
SizedBox(
width: 100,
height: 100,
child: ..., // Child can relayout without parent knowing
)

When a child has tight constraints (size is fixed) or sizedByParent: true, it becomes a relayout boundary. Changes inside can’t propagate up.

4. Repaint Boundary Stops Paint Propagation

RepaintBoundary(
child: AnimatedWidget(), // Repaints without affecting siblings
)

A RepaintBoundary gives its subtree its own layer. When only that subtree needs repainting, the engine re-records just that Picture. Siblings don’t repaint.

5. Dirty-List Coalescing Batches Changes

// All three setState calls in one sync block
setState(() => a++);
setState(() => b++);
setState(() => c++);
// Result: one frame, one rebuild

scheduleFrame() is idempotent. Calling it multiple times before VSync schedules one frame.

6. Frame Scheduling Gates Rendering

If nothing is dirty, Flutter doesn’t request a VSync callback. If the widget is off-screen (via TickerMode), its Tickers are muted. No wasted work.

The Interview Synthesis

“Flutter is fast because it aggressively avoids redundant work: const skips rebuilds, element diffing reuses render objects, relayout and repaint boundaries stop propagation, and dirty-marking plus VSync scheduling means only what changed gets recomputed, once per frame.”


Code Examples: Before and After

const Preventing Rebuild

// Before: Static content rebuilds every time parent does
class ParentWidget extends StatefulWidget {
@override
State<ParentWidget> createState() => _ParentWidgetState();
}
class _ParentWidgetState extends State<ParentWidget> {
int counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Counter: $counter'),
StaticHeader(), // Rebuilds on every setState!
StaticFooter(), // Also rebuilds!
],
);
}
}
// After: const widgets don't rebuild
class _ParentWidgetState extends State<ParentWidget> {
int counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Counter: $counter'),
const StaticHeader(), // Skipped
const StaticFooter(), // Skipped
],
);
}
}

RepaintBoundary Isolating Animation

// Before: Spinning icon causes entire page to repaint
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
ExpensiveList(),
SpinningLoadingIcon(), // Repaints trigger list repaint
],
),
);
}
}
// After: Animation isolated
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
ExpensiveList(),
RepaintBoundary(
child: SpinningLoadingIcon(), // Own layer, isolated
),
],
),
);
}
}

Isolate for Heavy Computation

// Before: JSON parsing blocks UI thread
Future<void> loadData() async {
final response = await http.get(Uri.parse(url));
final data = jsonDecode(response.body); // Blocks!
setState(() => _data = data);
}
// After: Parsing runs in isolate
Future<void> loadData() async {
final response = await http.get(Uri.parse(url));
final data = await Isolate.run(() {
return jsonDecode(response.body); // Runs in worker isolate
});
setState(() => _data = data);
}

Opacity Without saveLayer

// Before: Opacity uses saveLayer (expensive)
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _controller.value,
child: child,
);
},
child: const MyWidget(),
)
// After: FadeTransition avoids saveLayer
FadeTransition(
opacity: _controller,
child: const MyWidget(),
)

Key Takeaways

  1. UI track tall = Dart problem. Fix with const, smaller rebuild scope, Isolate.run().

  2. Raster track tall = compositing problem. Fix with FadeTransition instead of Opacity, avoid BackdropFilter, reduce overdraw.

  3. Isolates help UI-thread compute jank only. They cannot fix raster jank, layout costs, or rebuild scope issues.

  4. Isolate communication copies data. You can only send primitives, containers, and TypedData. Functions and framework objects cannot cross.

  5. Flutter’s six short-circuits: const, element reuse, relayout boundary, repaint boundary, dirty coalescing, VSync gating. Know them, use them, teach them.


The Full Interview Answer

“A frame must clear both the UI thread (Dart: build, layout, paint) and the raster thread (C++: compositing, GPU commands) within 16.6ms. I profile in release mode using DevTools. If the UI track is tall, the problem is expensive Dart code — I use const, shrink the rebuild scope, or move computation to an isolate. If the raster track is tall, the problem is compositing — I replace Opacity with FadeTransition, avoid BackdropFilter, reduce overdraw. Isolates only help the UI side; they’re useless for raster jank.”

That answer shows you understand the pipeline, can diagnose correctly, and won’t waste time on the wrong fix.


You’ve completed the depth ladder. From the hardware VSync pulse through Dart’s dirty-marking, through layout and paint, through the thread boundary to Impeller’s Metal/Vulkan commands, through double buffering to pixels on the display — and back to the DevTools timeline to diagnose when it breaks.

Now you can teach it.

Share:

Search

Loading search...