Huge performance optimization of the Flutter app extracting Dart Server to Isolate

Kerim Amansaryyev
Stackademic
Published in
7 min readMay 8, 2024

--

Disclaimer: This is a story of why I extracted the Dart shelf server running Sharelines application to separate isolate. It’s not a typical use case where you would delegate heavy tasks to get results upon the short-lived isolates using Isolate.run or compute because I needed to run my server continuously managing the lifecycle of the server inside the long-lived isolate.

This article is a part of the cycle dedicated to the development process of my indie application — Sharelines, which enables file transferring across local area networks. Here I share valuable insights derived during the process, you can have a look at the first article in the series here.

Here is the story, once upon a time, I’d been just about to deliver the application to Google Play, however, I bumped into a severe performance issue. The thing is — the application got stuck when I tried to send a file with a large volume. Let’s see what it looks like:

Sharelines running on MacOS

Here is the explanation of what’s going on in the gif:

  1. I opened Sharelines on MacOS (but the issue was present across all the operating systems, later I will explain why).
  2. Clicked on “Configure Server”, so the internal server of the application has been launched.
  3. Pulling the UI bottom sheet up and down to ensure that the app is running smoothly (don’t mind the frame rate, the gif supports 12 FPS, and the app itself runs smoother).
  4. I selected 2 large files to send within the multipart request to the server of Sharelines.
  5. While the files were being received, the frame rate had dropped dramatically making the application almost irresponsive.
Profiling before optimization shows the frame drop to 0.5 FPS

The profiler shows that at the moment of receiving the file, the frame rate had dropped down to 0.5 FPS. That’s insane!

It was terrifying because I wouldn’t release the app that wasn’t able to handle transferring files of large volumes.

With what had I started?

Understanding the nature of the issue

I started tracking the problem down and discovered that the Dart shelf server overloads the event loop of the main isolate while receiving and unpacking the file chunks within the stream.

Inside the multipart request handler of the Dart shelf server:

...
// Whenever the stream is listened, the process of reading the file chunks
// and writing them on the disc will begin
formData.part.listen((chunk){...})
...

Let’s first understand what is an event loop.

Normal UI event loop

The event loop is a runtime construct that executes a queue of events where each of the events is a single unit of operation that takes some time and memory to get executed. For example, when I pull the bottom sheet, the app needs to repaint the screen to show some animation — which leads to a sequence of events added to the event queue.

Therefore, when the server, that runs in the Flutter application, receives chunks, a new parsing event is added to the event loop per chunk. Suppose that the file chunks take some time to be parsed, so the event queue, now, looks like this:

Heavy file parsing on 1 event loop

As you can see, now it’s hard for the application to repaint the screen smoothly since we have “Parse chunk” events that take more time to complete holding the whole queue. This leads to the frame rate dropping.

What is the solution?

Short answer: We need to isolate the “Parse chunk” events from the main event loop extracting them to their own event loop.

Why is there a special stress on the word “isolate”? By this, we are smoothly sliding to the usage of Dart Isolates.

Everybody (even my dog) knows that Dart is single-threaded. What does it mean? It simply means that Dart does not support shared memory, which means that you can not run another thread to access the same runtime environment. Moreover, you can not run another thread, which would add events to the same event loop. This means that 1 event loop runs on only 1 single thread. That is the reason why Dart is single-threaded.

Does this mean that Dart does not support concurrency? Nope, Dart supports concurrency by launching multiple independent Isolates that have their own independent event loop along with the private memory. This, basically, means that Dart Isolates do not know about each other, but they are open for communication upon messages.

All this stuff is pretty confusing because everybody uses every single chance to say that “Dart is single-threaded” but rarely does anybody say that “Dart is also multi-isolated”. So, let me be that person.

Dart is multi-isolated.

That sounds awkward, now I understand why nobody says that.

Let’s now depict, how it looks when we run the server on its own event loop and memory under separate Isolate.

Extracting the server to the separate Isolate

Looks brilliant! Now, there is the use case to support UI to update the download progress of the received file whenever a new chunk is parsed.

As I mentioned before, isolates do not know anything about each other, they are independent and isolated. The isolates can only receive a message and react to them or send messages.

Examples:

  • Main Isolate wants the Server Isolate to shut down and release resources. So it sends a corresponding message, then Server Isolate reacts to that disposing the server variable.
  • Server Isolate parses a chunk of file and wants to notify Main Isolate about that. So it sends a corresponding message including the size of the chunk, then Main Isolate reacts to that updating the UI.

There are a lot of tutorials on implementing continuous isolates, I will release one of them in the nearest future. For now, I will give a useful tip on organizing message classes per isolate.

Create a sealed class that represents a message that is supposed to be fed to a particular isolate only.

Here are messages that are read by Server Isolate:

// Server Isolate can read message classes
// extending this sealed class only
sealed class _ToServerSendPortMessage {
const _ToServerSendPortMessage();
}

// Sometimes, terminating isolate may fail
// which leads to memory leaks of unreleased resources
// this message is read by Server Isolate to ensure that it's still needed
// If Server Isolate does not receive this message after timeout
// it will close server and release resources
final class _ToServerPongMessage extends _ToServerSendPortMessage {
const _ToServerPongMessage();
}

// If Server Isolate gets this message,
// Then it will stop writing a file that corresponds to "fileName"
final class _ToServerStopReceivingFileMessage extends _ToServerSendPortMessage {
final String fileName;

const _ToServerStopReceivingFileMessage({
required this.fileName,
});
}

Here are messages that are read by Main Isolate:

// Main Isolate can read message classes
// extending this sealed class only
sealed class _FromServerReceivePortMessage {
const _FromServerReceivePortMessage();
}

// When Main Isolate reads this message,
// it can respond by sending _ToServerPongMessage
// If it doesn't - this means that Server Isolate is no longer needed
final class _FromServerPingMessage extends _FromServerReceivePortMessage {
const _FromServerPingMessage();
}

// This message is received by Main Isolate when Server Isolate is alive.
final class _FromServerReceivedSendPort extends _FromServerReceivePortMessage {
final SendPort sendPort;

const _FromServerReceivedSendPort({
required this.sendPort,
});
}

// This message is received by Main Isolate when Server Isolate can not launch server
final class _FromServerReceivedException extends _FromServerReceivePortMessage {
final _LaunchServerException launchServerException;

_FromServerReceivedException({
required this.launchServerException,
});
}

// This message is received by Main Isolate when Server Isolate successfully releases resources
final class _FromServerReceivedKilled extends _FromServerReceivePortMessage {
const _FromServerReceivedKilled();
}

// This message is received by Main Isolate when Server Isolate, for example, receives a chunk of a file
final class _FromServerReceivedAppServerMessage
extends _FromServerReceivePortMessage {
final AppServerMessage appServerMessage;

const _FromServerReceivedAppServerMessage({
required this.appServerMessage,
});
}

Let’s look at the final diagram:

Isolate communication

And the final result after extracting the server to separate Isolate:

Sharelines running on MacOS after the optimization
Profiling after the performance optimization

Runs smoothly as butter, with no frame drop at all! I was even able to cancel the first file retrieval because Main Isolate was able to read the tap event.

If you have read the article till this row, you are a legend!

You can read more about my personal project that I have recently launched on Google Play — Sharelines, the application for LAN file transfer that was built using Flutter.

You can launch me over the moons (of Jupiter) by supporting me on Buy me a Coffee.

Stackademic 🎓

Thank you for reading until the end. Before you go:

--

--