Skip to main content

Performance8 min read

Flutter Isolates: Heavy Work Without Jank

Move heavy work off the main thread in Flutter with Isolates: learn Isolate.run, compute and Isolate.spawn with working code that keeps your UI at 60 fps.

  • #flutter
  • #isolates
  • #dart
  • #performance
  • #async
Flutter Isolates: Heavy Work Without Jank
Advertisement

Flutter apps feel fast until they do not. A scroll stutters, a button tap waits half a second, an animation drops frames — and the cause is rarely the widget tree. More often, the app is doing heavy work on the same thread that draws the UI. Dart runs your code on a single thread, called the main isolate, and while that thread is busy parsing JSON or crunching numbers, it cannot build frames. The fix is to move the heavy work somewhere else: a second isolate. An isolate is Dart's version of a thread, with one important difference: isolates do not share memory. Each one has its own heap and its own event loop, so they communicate only by sending messages. That sounds restrictive, but it is exactly what makes them safe. There are no data races, no locks, and no shared mutable state to corrupt. You send a value in, you get a value back, and the UI thread keeps drawing frames in the meantime.

How much work is too much for the main isolate

Flutter targets 60 frames per second, which gives the main isolate about 16 milliseconds per frame. Any single task that takes longer than that risks a missed frame. Parsing a large JSON response, decoding a big image, sorting thousands of records, or running a compression routine can easily take 50 to 500 milliseconds. Users notice that as a freeze. A useful rule: if the work takes more than a few milliseconds and you cannot break it into small chunks, move it to another isolate. If the work takes less than a millisecond, the cost of spawning an isolate and copying the data across is higher than just doing it inline. Measure first — the DevTools timeline shows long tasks on the UI thread clearly — then isolate the real offenders.

Isolate.run: the simplest option

Dart 2.19 introduced Isolate.run, the easiest way to run a function on a background isolate. You pass it a function, it runs that function on a new isolate, and it returns a Future with the result:

import 'dart:isolate';

Future<List<Product>> loadProducts(String rawJson) async {
  // Parsing happens on a background isolate.
  // The UI thread stays free to draw frames.
  return Isolate.run(() => parseProducts(rawJson));
}

List<Product> parseProducts(String rawJson) {
  final decoded = jsonDecode(rawJson) as List<dynamic>;
  return decoded
      .map((item) => Product.fromJson(item as Map<String, dynamic>))
      .toList();
}

A few details matter here. Isolate.run creates a new isolate, runs the function, sends the result back, and shuts the isolate down. That is perfect for one-off jobs such as parsing a network response or decoding a file. The function you pass must be a top-level function, a static method, or a closure that captures only values that can be sent between isolates. A closure that captures a BuildContext, a controller, or any object holding native resources will fail at runtime, so keep the function self-contained and pass everything it needs as arguments. The result must also be sendable. Plain data — numbers, strings, lists, maps, and objects made only of those — crosses the boundary fine. Objects that hold sockets, file handles, or platform channels do not, and the isolate will throw when you try to send them.

compute: the Flutter shortcut

Flutter ships a convenience wrapper called compute in package:flutter/foundation.dart. It does the same job as Isolate.run for a single function call, and it is the form you will see most often in Flutter code:

Advertisement
import 'package:flutter/foundation.dart';

Future<List<Product>> loadProducts(String rawJson) async {
  return compute(parseProducts, rawJson);
}

// Must be a top-level or static function.
List<Product> parseProducts(String rawJson) {
  final decoded = jsonDecode(rawJson) as List<dynamic>;
  return decoded
      .map((item) => Product.fromJson(item as Map<String, dynamic>))
      .toList();
}

compute takes the function and a single argument. If your function needs several inputs, wrap them in a record or a small data class and pass that as the one argument. Under the hood, compute spawns an isolate, runs the function, returns the result, and tears the isolate down — the same lifecycle as Isolate.run, with a Flutter-friendly API. For one-off background work in a Flutter app, compute is usually the right default.

Isolate.spawn: when the work never stops

Isolate.run and compute are built for single jobs: spawn, run, return, shut down. Some work does not fit that shape. A background worker that processes a stream of images, a worker that keeps a connection open, or a long-running computation that reports progress all need an isolate that stays alive. That is what Isolate.spawn is for. With Isolate.spawn, you create the isolate yourself and talk to it through ports. A SendPort sends messages; a ReceivePort listens for them:

import 'dart:isolate';

Future<void> main() async {
  final receivePort = ReceivePort();
  await Isolate.spawn(_workerEntry, receivePort.sendPort);

  // The worker sends its own SendPort back first.
  final sendPort = await receivePort.first as SendPort;

  // Ask the worker to do something.
  sendPort.send('process this image');
}

void _workerEntry(SendPort mainSendPort) {
  final workerPort = ReceivePort();
  mainSendPort.send(workerPort.sendPort);

  workerPort.listen((message) {
    // Heavy work happens here, on the worker isolate.
    final result = doHeavyWork(message as String);
    mainSendPort.send(result);
  });
}

The handshake at the start is the pattern to remember: the spawned isolate creates its own ReceivePort, sends that port's SendPort back to the main isolate, and from then on the two sides exchange messages. The worker isolate stays alive until you call isolate.kill() or it exits on its own, so remember to shut it down when the screen that owns it is disposed, or you will leak an isolate for the life of the app. Isolate.spawn is more code than compute, and that is the point: you only reach for it when a single-shot call is not enough. If you find yourself spawning an isolate for every tap, you are paying the spawn cost repeatedly for no benefit — use compute or keep one worker isolate alive instead.

What can cross the isolate boundary

Messages between isolates are copied, not shared. That has two consequences worth knowing. First, sending a large object costs time: a 10 MB list is serialized, copied, and deserialized on the other side. For very large payloads, that copy can eat the benefit of the background work, so prefer sending compact data — a file path instead of the file bytes, or an ID instead of a full object — and let the worker read what it needs itself.

Advertisement

Second, only certain values can be sent: null, numbers, booleans, strings, lists and maps of sendable values, SendPort instances, and objects whose fields are all sendable. Anything holding a native resource — a database connection, an open file, a platform channel, a BuildContext — cannot cross. If you get an error about an un-sendable object, the fix is almost always to move the creation of that resource inside the worker, or to pass a plain description of the work instead of the resource itself.

Common mistakes

Three mistakes show up again and again. The first is isolating work that is too small: spawning an isolate for a task that takes one millisecond costs more than it saves, and the app gets slower, not faster. The second is capturing UI objects in the background function — a closure that quietly holds a controller or a context will fail when the message is sent, often far from the line that caused it. The third is forgetting that isolates cannot touch the UI at all: there is no setState from a background isolate, no platform channel call, and no access to plugins that rely on the main isolate. The worker computes; the main isolate updates the screen with the result.

A complete example: parsing without the freeze

Putting it together, here is the pattern most apps end up with: fetch on the main isolate, parse on a background isolate, render when the result arrives.

import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;

class ProductListScreen extends StatefulWidget {
  const ProductListScreen({super.key});

  @override
  State<ProductListScreen> createState() => _ProductListScreenState();
}

class _ProductListScreenState extends State<ProductListScreen> {
  late final Future<List<Product>> _products;

  @override
  void initState() {
    super.initState();
    _products = _fetchProducts();
  }

  Future<List<Product>> _fetchProducts() async {
    final response = await http.get(Uri.parse('https://api.example.com/products'));
    // The expensive parse runs off the main isolate.
    return compute(parseProducts, response.body);
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Product>>(
      future: _products,
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        final products = snapshot.data!;
        return ListView.builder(
          itemCount: products.length,
          itemBuilder: (context, index) {
            return ListTile(title: Text(products[index].name));
          },
        );
      },
    );
  }
}

Nothing about the UI changed. The fetch, the parse, and the list are the same as before — the only difference is that compute moves the parse to a background isolate, so the loading spinner animates smoothly while a large response is being decoded. That is the whole point of isolates: the user should never be able to tell that heavy work is happening at all. If you want to go deeper into how Dart handles work that waits — network calls, timers, and streams — read Dart Streams vs Futures: When to Use Each. And if the heavy work in your app comes from an on-device AI model, the same off-thread thinking applies; see Flutter On-Device AI With TensorFlow Lite. Isolates are not a performance trick. They are the way Dart is designed to handle heavy work: keep the main isolate free to draw frames, send plain data to a worker, and bring the result back. Start with compute for one-off jobs, reach for Isolate.spawn when the worker needs to stay alive, and measure before and after — the frame timeline in DevTools will show the difference immediately. Get in touch and let's talk about your project.

Frequently asked questions

When should I use an Isolate in Flutter?
Use one when a single task takes more than a few milliseconds on the main isolate, such as parsing a large JSON response, decoding an image, or sorting thousands of records. Work under a millisecond is cheaper to run inline, because spawning an isolate and copying data across has its own cost.
What is the difference between compute and Isolate.run?
Both run a single function on a background isolate and return its result as a Future. compute is Flutter's convenience wrapper in package:flutter/foundation.dart, while Isolate.run is the Dart SDK equivalent; for one-off jobs in a Flutter app, compute is usually the right default.
Can an isolate update the UI directly?
No. Isolates do not share memory with the main isolate, so they cannot call setState or touch widgets. The worker sends its result back through a port or a Future, and the main isolate updates the screen.
What can I send to an isolate?
Only sendable values: null, numbers, booleans, strings, lists and maps of sendable values, SendPort instances, and objects made only of those. Objects holding sockets, file handles, platform channels, or a BuildContext cannot cross the boundary.

Written by Muhammad Uzair

Flutter Developer & Mobile Engineering Team Lead.

Work with me
Advertisement
All articles
  • Performance

    8 min read

    Flutter Geofencing Without Battery Drain

    Most geofence features die from battery drain, not bugs. Five rules for battery-friendly geofencing in Flutter: radius, dwell, permissions, transitions.

  • Mobile Development

    8 min read

    Color-Blind Friendly Games in Flutter

    Build a color-blind friendly game in Flutter: pair every color with a symbol, add aiming aids players can read, and ship settings players control.

  • Mobile Development

    7 min read

    Build a Pomodoro Focus Timer in Flutter

    Build a drift-free Pomodoro timer in Flutter with local notifications for session transitions, persistent session history, and calm deep-work analytics.