Icon

Charles Crete

Flutter JSON parsing: What is the fastest?

May 17, 2020

Flutter, Google portable UI framework, uses Dart for application code. My question today: What is the fastest way to parse JSON in Flutter?

Flutter is single-threaded, meaning all code runs on the UI thread by default. It uses an event loop but supports isolates (thread-like workers who don't share memory). Overall, this is very similar to JavaScript's event loop (and Web Workers in browsers or Worker Threads in Node).

Since JSON parsing runs by default on the UI thread, this can cause performance issues since it can block the thread while parsing large messages. This sounds like a good use case for isolates! However, isolates have a static performance impact (it takes time to start one). Let's compare the pros and cons.

Parsing on the main thread:

  • ✓ No startup time, starts instantly
  • ✓ No need to copy message
  • ✗ Can block UI
  • ✗ Can only run 1 at the time (single-thread)

Parsing in isolate:

  • ✓ Doesn't block the main thread
  • ✓ Can be run in parallel with multiple isolates
  • ✗ Startup delay
  • ✗ Need to copy message to isolate's memory

Today we'll be testing a few methods:

  • Main thread parsing only
  • Isolate parsing with new isolate for each parsing (naive)
  • Isolate parsing with reusing isolate
  • Isolate parsing with multiple reused isolates

To view a TLDR, scroll down to "Conclusion" for results and final thoughts.

Main thread parsing

Let's start with the simplest to explain our testing methodology.

We'll be using the following code to measure our code:

class JsonTest extends StatefulWidget {
  
  _JsonTestState createState() => _JsonTestState();
}

class _JsonTestState extends State<JsonTest> {
  var parsesPerSecond = 0;

  
  void initState() {
    super.initState();
    // Start test on next tick
    Future.microtask(_run);
  }

  Future<void> _run() async {
    var lastFrame = DateTime.now();

    var parses = 0;
    while (true) {
      parseJson();

      // Allow UI to update every 1s & set counter
      final time = DateTime.now().subtract(Duration(seconds: 1));
      if (lastFrame.isBefore(time)) {
        setState(() {
          parsesPerSecond = parses;
        });
        parses = 0;
        await Future.microtask(() {});
      }
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(parsesPerSecond.toString()),
      ),
    );
  }
}

const jsonToParse = "";

void parseJson() {
  // Implement parsing here
}

This will update the UI with the count of parses in the last second, every second. We'll be implementing our parsing in the parseJson function.

We'll be using 3 length of JSON (take/modified from JSON examples):

We'll also be testing on a couple of devices using different modes:

  • OnePlus 8 Pro using a 865 (2020), in release mode
  • Pixel 3XL using a 855 (2018), in release mode
  • Local emulator running on a 9700K (3 dedicated CPUs), in debug mode (evaluated), because I'm curious

Unfortunately, it would not be worth the time investment to test on iOS. If there's demand, I could set it up.

The versions used are:

  • Flutter: 1.19.0-1.0.pre
  • Dart: 2.9.0-7.0.dev

Our initial implementation will look like such:

void parseJson() {
  json.decode(jsonToParse);
}

All results in parses/second

Device Short Medium Long
OnePlus 8 Pro (865) ~499 000 ~25 200 ~6 125
Pixel 3XL (855) ~330 000 ~17 000 ~3 285
Desktop (9700K) ~1 040 000 ~59 900 ~10 400

One notable thing was the fast drop-off on real mobile devices. After only 30 seconds of running, some results were 0-30% lower. This didn't happen on desktop, which has longer boost times and better cooling.

I gave all mobile devices about 3-5 minutes between runs (swapping between them), and took measurements after about 10 seconds of running.

Isolate parsing

Next, we'll be using compute:

Future<void> parseJson() async {
  await compute(_parseJson, jsonToParse);
}

_parseJson(String text){
  return json.decode(text);
}

A separate top-level function is required for compute. I've also added await in the loop.

Device Short Medium Long
OnePlus 8 Pro (865) ~175 ~160 ~155
Pixel 3XL (855) ~90 ~80 ~70
Desktop (9700K) ~13 ~13 ~12

Where we see the high cost of using isolates, where the majority of the time is in the startup, and not the parsing. This makes the result much lower, and less dependent on the size of the JSON.

We also see a massive performance hit inside the emulator, likely related to creating new threads inside QEMU. This made the size a non-factor.

We have to remember that this is all off of the main thread, so although slow, will not affect the performance of your UI or other code on the main thread.

Reused isolate parsing

Here, we'll start an isolate and reuse it to avoid the high startup cost. The only cost is the copying of the message.

Here's our implementation, which is a bit more complicated, but will support our next experiment too:

SendPort sendPort;

void isolateCallback(SendPort sendPort) {
  final receivePort = ReceivePort();
  sendPort.send(receivePort.sendPort);

  receivePort.listen((dynamic message) {
    final incomingMessage = message as JsonIsolateMessage;
    incomingMessage.sender.send(json.decode(incomingMessage.text));
  });
}

Future<void> initParsing() async {
  final receivePort = ReceivePort();

  await Isolate.spawn(
    isolateCallback,
    receivePort.sendPort,
  );

  sendPort = await receivePort.first;
}

Future<void> parseJson() async {
  if (sendPort == null) {
    await initParsing();
  }

  final receivePort = ReceivePort();

  sendPort.send(JsonIsolateMessage(receivePort.sendPort, jsonToParse));

  await receivePort.first;
}

class JsonIsolateMessage {
  final SendPort sender;
  final String text;

  JsonIsolateMessage(this.sender, this.text);
}
Device Short Medium Long
OnePlus 8 Pro (865) ~14 500 ~5 100 ~1 220
Pixel 3XL (855) ~5 100 ~2 700 ~640
Desktop (9700K) ~18 800 ~8 700 ~8 300

Here, we see much better performance. Not as fast as without copying, but better than without reusing.

Here, the real devices had a very sharp drop-off in some tests, with the OnePlus going from ~14k to ~5k after 20s in one example (but sustaining ~5.1k & ~1.2k in others). This shouldn't happen in real-world usage since parsing wouldn't be continuous, usually come from the network, which would be our bottleneck.

Due to this model, we only consider the short (~5s) maximum burst for our rough averages. The burst time was much lower on the Pixel (which would drop off after ~3).

Reused multi-isolate parsing

Next, we'll be initializing 8 isolates and the next available one. We will be using 8 because it is the number of cores on the 865 and 855 (in a 1-3-4 formation).

The implementation (again more complex):

List<SendPort> availableSendPorts;
final sendPortStream = StreamController<void>.broadcast();

Future<SendPort> intialize() async {
  final receivePort = ReceivePort();

  await Isolate.spawn(
    isolateCallback,
    receivePort.sendPort,
  );

  return await receivePort.first;
}

void isolateCallback(SendPort sendPort) {
  final receivePort = ReceivePort();
  sendPort.send(receivePort.sendPort);

  receivePort.listen((dynamic message) {
    final incomingMessage = message as JsonIsolateMessage;
    incomingMessage.sender.send(json.decode(incomingMessage.text));
  });
}

Future<void> initParsing() async {
  availableSendPorts = await Future.wait([
    intialize(),
    intialize(),
    intialize(),
    intialize(),
    intialize(),
    intialize(),
    intialize(),
    intialize(),
  ]);
}

Future<void> parseJson() async {
  if (availableSendPorts == null) {
    await initParsing();
  }

  // Wait until one is available
  if (availableSendPorts.isEmpty) {
    await sendPortStream.stream.first;
  }

  final sendPort = availableSendPorts.removeAt(0);

  final receivePort = ReceivePort();

  sendPort.send(JsonIsolateMessage(receivePort.sendPort, jsonToParse));

  await receivePort.first;

  availableSendPorts.add(sendPort);
  sendPortStream.add(null);
}

class JsonIsolateMessage {
  final SendPort sender;
  final String text;

  JsonIsolateMessage(this.sender, this.text);
}
Device Short Medium Long
OnePlus 8 Pro (865) ~13 500 ~4 600 ~1 180
Pixel 3XL (855) ~6 800 ~2 700 ~690
Desktop (9700K) ~13 500 ~5 900 ~1 500

Here, we see a drop-off for all devices except the Pixel. This is an odd result, possibly due to the implementation, or differences in CPU design.

Conclusion

Here is the result once again:

Main thread parsing

Device Short Medium Long
OnePlus 8 Pro (865) ~499 000 ~25 200 ~6 125
Pixel 3XL (855) ~330 000 ~17 000 ~3 285
Desktop (9700K) ~1 040 000 ~59 900 ~10 400

Isolate parsing

Device Short Medium Long
OnePlus 8 Pro (865) ~175 ~160 ~155
Pixel 3XL (855) ~90 ~80 ~70
Desktop (9700K) ~13 ~13 ~12

Reused isolate parsing

Device Short Medium Long
OnePlus 8 Pro (865) ~14 500 ~5 100 ~1 220
Pixel 3XL (855) ~5 100 ~2 700 ~640
Desktop (9700K) ~18 800 ~8 700 ~8 300

Reused multi-isolate parsing

Device Short Medium Long
OnePlus 8 Pro (865) ~13 500 ~4 600 ~1 180
Pixel 3XL (855) ~6 800 ~2 700 ~690
Desktop (9700K) ~13 500 ~5 900 ~1 500

Final thoughts

Here are my recommendation:

  • If you are parsing small JSON messages, for example consuming an API, use main thread parsing.

    • It has the lowest overhead and the most performant, and even with medium-long messages, it won't affect the performance of the UI.
  • If you are continuously parsing medium-long JSON messages, consider reusing a single isolate.

    • Will not affect the UI thread, but will parse slower.
  • If you need to parse few very long JSON messages, use isolate parsing (with optional reusing).

    • This won't affect the UI thread, and the isolate startup cost will be minimal.
    • This also has the lowest complexity of the isolate-based solutions.

I would not recommend reusing multiple isolates to do JSON parsing, as this is the slowest method.

I hope this was educative and helpful for your Flutter JSON-parsing activities!

Update 2020-05-18:

User mraleph commented on Reddit about chunked decoding, which is another alternative to the solution of not blocking the main thread. This is great for parsing large JSON messages and taking breaks to let the UI update.

About me

I am Charles Crete, author of the redux_persist, flutter_linkify, and flutter_super_state (and more!) libraries for Flutter/Dart.

Follow me on Twitter!