DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

Reading two fields out of a 4 MB JSON payload: where an FFI parser beats dart:convert

A measurement of simdjson_dart against dart:convert, and the sizes where each one wins.

The usual answer to "how do I parse JSON fast in Dart" is that you don't. dart:convert ships in the SDK, it is fine. For most payloads that is correct. There is one shape of problem where it is the wrong default, and I could not find anyone who had measured the boundary, so I did.

The problem is partial reads. You get a large JSON document back from a service and you want two fields out of it. A webhook envelope where you only read event.type and event.id. A paginated response where you want total and next_cursor before deciding whether to touch the body. A stored blob where you read one flag. jsonDecode does not care what you want. It walks the entire document, allocates a Map and a List and a String for every node, and hands you the whole tree so you can pull two values off the top and drop the rest.

simdjson_dart is an FFI binding to simdjson. The value is not "SIMD makes parsing faster" in the abstract. The value is lazy access. It parses to an internal tape and lets you navigate to a field without materializing the parts you skipped. The fields you never touch never become Dart objects.

Here is the partial-read case:

import 'package:simdjson_dart/simdjson_dart.dart';

// You have `bytes` (Uint8List) from an HTTP response.
final doc = SimdJsonDocument.parseBytes(bytes);
final type = doc.at('/event/type') as String?;
final id = doc.at('/event/id') as int?;
doc.close();
Enter fullscreen mode Exit fullscreen mode

Against the dart:convert version of the same two reads:

import 'dart:convert';

final root = jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
final event = root['event'] as Map<String, dynamic>;
final type = event['type'] as String;
final id = event['id'] as int;
Enter fullscreen mode Exit fullscreen mode

I benchmarked this exactly, reading the same two fields, across payload sizes. All numbers are on Apple Silicon, Dart 3.11.

The crossover is around 2 KB. Below it, dart:convert wins. At 1 KB it is 2.3x faster than simdjson_dart, because crossing the FFI boundary costs more than decoding the whole small document. Nothing to be done about that. If your payloads are small, this package is a pessimization.

Above the crossover it inverts, and it inverts hard:

Payload size Winner Speedup
1 KB dart:convert 2.3x
4 KB simdjson_dart 5.7x
4 MB simdjson_dart 10.4x

The lazy SimdJsonDocument.at path overtakes jsonDecode at about 2 KB and reaches 10x at 4 MB

The gap widens with size for one reason. dart:convert's cost scales with the whole document because it builds the whole tree. simdjson_dart's cost for two fields is the parse-to-tape plus two lookups, and it never allocates Dart objects for the 4 MB you did not ask for. At 4 MB you pay for two fields instead of a few hundred thousand Map entries. That is the 10.4x.

The rule for partial reads is short. If you are pulling part of a payload larger than a few KB, use it. Below a couple KB, or if you are going to touch most of the fields anyway, don't.

Where it loses

Partial reads are the case it is built for. Full decode, where you want every field as a Dart object, depends on your data.

I measured full decode too, simdjson_dart materializing the entire document against jsonDecode. Two things came out of it.

Number-heavy data above roughly 25 KB goes to simdjson_dart, up to 1.8x. simdjson parses numbers fast and the tape-to-Dart conversion for numbers is cheap, so on payloads that are mostly floats and ints it stays ahead even when you materialize everything.

Object-heavy or string-heavy data goes to dart:convert at every size I tried. This is not close and it is not a tuning problem. dart:convert builds Dart Map and String objects directly as it parses. simdjson_dart parses to its tape first and then walks the tape building the same Dart objects, so for strings and nested objects it does the allocation work dart:convert does plus the tape step in front of it. The two-pass design cannot win that race.

For full decode: keep dart:convert unless your data is both large and number-heavy. Everything else, decode with the SDK.

The cost of entry

This is an FFI package. It links against a C++ library, so you need a C++ toolchain to build it or a prebuilt binary for your target. It does not run on the web and it never will, because there is no simdjson on the web. If you ship to Flutter web from the same codebase you need dart:convert on that path.

You also own a lifetime. SimdJsonDocument holds native memory and you call close() when you are done. The lazy views from at() are only valid while the document is alive. That is the tax for not allocating the tree.

None of that is worth it to shave microseconds off a 1 KB decode. It is worth it when you reach into large payloads for a few fields, and the numbers above are the reason.

Top comments (0)