Improving App Performance with Isolates in Flutter

Surya Mouly
9 min readMay 14, 2023

--

Parallel code execution in Flutter has never been effortless.

Isolates in flutter-Tutorial
Isolates In flutter

I learned about isolates a while ago and I tries to use them. And let me tell you, it was horrifying. However, I recently learned how simple it has become. So, there you have it.

You may have heard of isolates but never truly understood what they meant. Or perhaps you have implemented isolates, but the code has always been messy and time-consuming to write. In any case, this blog will walk you through the highs and lows of Isolate’s history as well as its current and improved implementation. After all, it is up to you whether you consider using the most recent way or the oldest method.

Fundamentals

Flutter Documentation describes Isolates as follows:

“An isolated Dart execution context.”

Did you gather anything of value? I, for one, did not 😜. So let’s start with Isolates and then put together our own definition that can be understood easily 😜.

So, To begin with we have 3W’s and 1H to understand isolates

  1. What are Isolates?
  2. Why do we require Isolates?
  3. What is Event Handling?
  4. How should Isolates be implemented?

We’ll start with the broader context of Isolates and see what it actually stands for before digging deeper and putting all the pieces together to see how each aspect works together so that we can grasp what Isolates really do and why we need them. So, let’s begin with our first W.

What are Isolates?

For a understanding of isolates, we need to first understand Concurrency.

“Concurrency comes about when two or more tasks begin, run, and finish at the overlapping period of time. It doesn’t necessarily follow that they’ll ever be running at the same time. Multitasking on a single-core system, for example. Parallelism occurs when tasks run simultaneously, such as on a multicore machine.”

For concurrency, Dart relies on the Isolate paradigm. Isolate is nothing more than a thread wrapper. However, threads, by definition, can share memory, which makes code vulnerable to race situations and locks. Isolates, on the other hand, cannot share memory and must instead communicate via message passing mechanisms.

Dart code can use isolates to accomplish numerous independent processes at the same time, employing additional cores if they are available. Each Isolate has its own memory as well as a single thread that runs an event loop.

Don’t Worry we’ll get to the event loop in a moment.

Why do we require isolates?

Before we can answer the topic of why isolates?, we must first understand how async-await actually works. Let’s try and understand a code snippet below.

The code snipped below demonstrate that — “We want to read some data from a file, decode it as JSON, and then display the length of the JSON keys. We won’t get into implementation details here.”

void main() async {
// Read some data.
final fileData = await _readFileFromJson();
final jsonData = jsonDecode(fileData);

// Use that data to print.
print('JSON keys: ${jsonData.length}');
}

Future<String> _readFileFromJson() async {
final file = File(filename);
final contents = await file.readAsString();
return contents.trim();
}

Let’s say we click this button, and it sends a request to _readFileFromJson(), which is all dart code we wrote. However, the function _readFileFromJson() executes code using the Dart Virtual Machine/OS to conduct the I/O operation, which is a separate thread, the I/O thread. This signifies that the code in the main function executes within the main isolate. When the code reaches the _readFileFromJson() function, the execution is sent to the I/O thread, while the Main Isolate waits until the code is completed or an error occurs. This is what the await keyword accomplishes.

Isolates: why is it necessary?
Above statement as a pictorial representation.

After reading the contents of the files, the control returns to the main isolate, and we begin processing the String data as JSON and printing the number of keys. This is a rather simple task. But imagine the JSON parsing was a very large task due to a very large JSON, and we begin modifying the data to comply to our demands. Then there’s the work on the Main Isolate. At this point, the UI could freeze, causing our users to become annoyed.

What exactly is Event Handling?

As previously stated, Isolate is a thread wrapper, and each Isolate has an event loop that executes events. These events are just what occurs when we make use of the application. These events are added to a queue, which the Event loop subsequently consumes and handles. These events are processed in the order of first-in-first-out.

Let’s go over this code again to better understand event handlers. We already know what’s going on in this block of code.

void main() async {
// Read some data.(Repaint-event)
final fileData = await _readFileFromJson();
final jsonData = jsonDecode(fileData);

// Use that data to print.
print('JSON keys: ${jsonData.length}');
}
//(onTap event)
Future<String> _readFileFromJson() async {
final file = File(filename);
final contents = await file.readAsString();
return contents.trim();
}

Our programmes start, and the UI (Paint Event) is added to the queue. We press the button, and the file handling code begins. As a result, the Tap Event is added to the queue. Let’s say the UI gets modified after it’s finished, so the Paint Event is placed back into the queue.

Event Loop in Flutter
A pictorial representation of above code snippet mapped with apps events.
Event Loop in Flutter
A pictorial representation of above code snippet mapped with apps events.

Because our code for handling the file and JSON was so short, the UI no longer struggles or lags. But imagine for a moment that our file handling code is massive and takes a long time. The event queue and event loop now resemble the image below.

Isolates in Flutter: Tutorial
file handling code is massive and takes a long time

Because the main isolate takes a long time to handle that event, our animation or UI may hang and upset your users, generating massive dropoffs. This is where producing a new isolate or a worker isolate comes in.

How should Isolates be implemented?

In the flutter app, all of our dart code runs in isolate. It is up to you whether it is a main isolate or a worker isolate. The main isolate has already been constructed for you, so you don’t need to do anything else here. On the Main Isolate, the main function begins. Once our main function is up and running, we can begin spawning new isolates.

So, there are two approaches of implementing Isolates.

Let us begin with an existing approach/old approach:

Isolates, unlike threads, do not share memory, as previously discussed. This is done to avoid race conditions and locks. Message passing, however, is used for communication between Isolates. These messages are primitives, and the full list of objects that can be exchanged between isolates can be found here.

Dart gives us with Ports to send messages. SendPort and ReceivePort are two ports.
Because we’re talking about the old way of spawning Isolates, we should know that isolate methods must be top-level or static functions.

Let’s look at the piece of code below:

Future<String> downloadUsingSpawningIsolateMethod() async {
const String downloadLink = 'some link';
// create the port to receive data from
final resultPort = ReceivePort();
// spawn a new isolate and pass down a function that will be used in a new isolate
// and pass down the result port that will send back the result.
// you can send any number of arguments.
await Isolate.spawn(
_readAndParseJson,
[resultPort.sendPort, downloadLink],
);
return await (resultPort.first) as String;
}

Let’s understand the above code snippet :-

1. What this code does is construct a RecievePort instance to receive data. Keep in mind that this is the old way for producing Isolates. It may be a little lengthy, but it is vital to understand the specifics.

2. Using Isolate, we create a Worker Isolate on top of the Main Isolate.spawn and pass a top-level function that executes the blocking code. We also give down a list of arguments, the first of which is the SendPort, which will be used to communicate data from the worker Isolate, and the second of which is the download link. We wait for the new Isolate to be spawned.

3. We then wait for the outcome, which is some kind of String, and use it as we see fit. This data could be any of the objects on this list.

4. ResultPort.first hides behind the screen a stream subscription and waits for data from the worker isolate to be sent onto it. We return the result as soon as the first item arrives.

The _readAndParseJson function takes the argument and executes the worker isolate code. This is a bogus function that simply waits the control for Two seconds before exiting. The exit function synchronously terminates the current isolate. Before returning the data to the caller isolate, certain checks are performed, and the data is returned over the SendPort.

// we create a top-level function that specifically uses the args
// which contain the send port. This send port will actually be used to
// communicate the result back to the main isolate

// This function should have been isolate-agnostic
Future<void> _readAndParseJson(List<dynamic> args) async {
SendPort resultPort = args[0];
String fileLink = args[1];

String newImageData = fileLink;

await Future.delayed(const Duration(seconds: 2));

Isolate.exit(resultPort, newImageData);
}

Error Handling for Isolates

// Error Handling
Future<String> downloadUsingSpawningIsolateMethodWithErrorHandling() async {
const String downloadLink = 'some link';
// create the port to receive data from
final resultPort = ReceivePort();
// Adding errorsAreFatal makes sure that the main isolates receives a message
// that something has gone wrong
try {
await Isolate.spawn(
_readAndParseJson,
[resultPort.sendPort, imageDownloadLink],
errorsAreFatal: true,
onExit: resultPort.sendPort,
onError: resultPort.sendPort,
);
} on Object {
// check if sending the entrypoint to the new isolate failed.
// If it did, the result port won’t get any message, and needs to be closed
resultPort.close();
}

final response = await resultPort.first;

if (response == null) {
// this means the isolate exited without sending any results
// TODO throw error
return 'No message';
} else if (response is List) {
// if the response is a list, this means an uncaught error occurred
final errorAsString = response[0];
final stackTraceAsString = response[1];
// TODO throw error
return 'Uncaught Error';
} else {
return response as String;
}
}

Everything is pretty same here, we just have added error handling here.

This code performs the following:
1. While spawning a new isolate, we set errorsAreFatal to true to ensure that the Main Isolate is aware of any errors. We assign the SendPort to the onExit and onError handlers to ensure that any errors that occur when departing or spawning are reported.

2. While spawning a new isolation, we additionally include a try-catch block to ensure that any errors are caught and the operation is terminated.

3. If the spawning is successful and some data is received from the worker isolate, we must determine whether it is an error or not.

4. If the message returned is null, that means the isolation departed without sending any messages and an error occurred. If the answer is a list, this indicates that the worker Isolate returned an error and a stacktrace. Otherwise, this transaction is a success.

If we only intended to send one message, this appears to be overkill. Close the Isolate after one message. You’d have to write the same code every time you wanted to spawn a new isolation. Because the Isolate logic is rather customised. It would be quite time consuming to pass in different arguments every time. This is why a new approach for one-time transactions was developed.

The new approach is called “Isolate.run”.

// Isolates with run function()
Future<String> startDownloadUsingRun() async {
final imageData = await Isolate.run(_readAndParseJsonWithoutIsolateLogic);
return imageData;
}

Future<String> _readAndParseJsonWithoutIsolateLogic() async {
await Future.delayed(const Duration(seconds: 2));
return 'this is downloaded data';
}

This is all there is to say about the new method.
We use the run() method to create a new Isolate, which abstracts off all of the complicated information and error handling, saving you a lot of time. These few lines of code aid in launching, error handling, message passing, and terminating the Isolate.

When should you use the new Run method and when should you utilise the old spawn technique?

These examples show message forwarding that occurs only once. As a result, the run method should be utilised. It significantly decreases the number of code lines and test cases.
However, if you need several messages to be delivered between the Isolates, you must use the old Isolate.spawn() method. As an example, suppose you start downloading a file on a worker isolate and want to display the download progress on the UI. This means that the progress counter must be passed again.
This requires us to construct the entire SendPort and ReceivePort for message forwarding, as well as the custom logic for receiving parameters and sending progress back to the main Isolate.

Hope you liked the understanding of Isolates. If you have any doubts, please comment.

--

--

Surya Mouly

Decently fat guy. Wears glasses. Has a beard. Bear hug expert. thinks he’s amusing. Enjoys talking about himself as a third individual. Furthermore loves food.