Reducing Android scope-sync overhead in Sentry Flutter
Our SDK adds work to the app that installs it. It records recent app activity as breadcrumbs and keeps user information and other diagnostic data up to date. Changes to that information are called scope updates.
On Android, we send those updates to a worker isolate, which passes them to the Sentry Android SDK. We found that the calling isolate and the worker both normalized the same data. The encoding step also created a JSON string and an extra byte buffer that we could avoid.
Together, removing that duplicate work and encoding directly into bytes reduced processing time by 27–30% for the scope payloads we benchmarked.
Moving work off the UI isolate
Flutter’s main isolate handles app logic and builds frames. Synchronous SDK work on that isolate competes for the same execution time.
We introduced a dedicated worker isolate for Sentry Android SDK operations, including handing over event envelopes and synchronizing breadcrumbs, users, and context data. For scope updates, the division looks like this:
Calling isolate
Normalize the scope data
Send a request to the worker
↓
Worker isolate
Encode the data as JSON bytes
Create the Java byte array
Call the Sentry Android SDK through JNIWhen the worker is available, encoding and native-call work happen off the calling isolate. If the worker hasn’t started, the SDK falls back to performing those operations on the calling isolate.
The calling isolate still has to prepare and send each update. We looked at that preparation and the worker’s processing together to check for repeated work.
Normalizing twice
Before data reaches the Sentry Android SDK, we normalize it: go through its values, convert them into types Sentry’s data format supports, and remove anything unsupported.
The code we changed normalized each object on the isolate making the update, then sent it to the worker. The worker’s Java byte-array helper normalized it again before encoding it. We were processing the same values twice on their way to the Sentry Android SDK.
The fix was to normalize the object once, before sending it to the worker, and remove the second normalization from the helper.
That roughly halved the time spent on normalization alone: 7.57 µs down to 3.78 µs for a 1.2 KB breadcrumb, and 2.71 ms down to 1.35 ms for 238 KB of context data.
Neither function looked wrong on its own. We had to follow the object into the worker to see that both were doing the same work.
We needed bytes all along
The Android scope-sync methods expect JSON bytes, which we pass through JByteArray.from. Our helper built a JSON string, encoded it as UTF-8, then copied the bytes into another Uint8List.
The last copy was unnecessary: utf8.encode already returns a Uint8List. We could also use JsonUtf8Encoder to produce UTF-8 bytes directly, without first building a JSON string.
For data that can be encoded as JSON, the change looks like this:
// Before: JSON string, UTF-8 encoding, then an extra buffer copy.
final bytes = Uint8List.fromList(utf8.encode(jsonEncode(payload)));
final byteArray = JByteArray.from(bytes);// After: encode directly into UTF-8 bytes.
final encoder = JsonUtf8Encoder(); // In production, reuse an encoder across calls.
final byteArray = JByteArray.from(encoder.convert(payload));Removing a whole buffer copy sounds like it should save a lot. On its own, it reduced the encoding benchmark time by about 2%. Switching to direct UTF-8 encoding as well brought the reduction to 16% for both the 1.2 KB and 238 KB inputs.
The extra copy was easy to spot, but it accounted for only a small part of the encoding time.
Measuring the combined change
We then measured normalization, JSON encoding, and Java byte-array creation together. This comparison includes both the normalization and encoding changes.
The measurements come from a physical Google Pixel 4 in release mode and are medians of seven runs after warmup. We used three payload sizes to examine how the cost changes with the amount of data:
| Input | Before | After | Time reduction |
|---|---|---|---|
| 1.2 KB breadcrumb | 41.0 µs | 29.9 µs | 27% |
| 23 KB of context data | 824 µs | 574 µs | 30% |
| 238 KB of context data | 8.24 ms | 5.95 ms | 28% |
For the small breadcrumb, that’s about 11 µs saved per update. For the largest input, the saving was 2.29 ms. These sizes describe our benchmark inputs; they don’t establish how large scope updates typically are in customer apps.
The combined reductions are smaller than the halving we saw for normalization alone, because normalization is only one part of the work.
These measurements cover scope-data processing. They don’t measure isolate round-trip latency, network delivery, or the responsiveness benefit of introducing the worker. What an app actually saves depends on the size and frequency of its scope updates.
Neither change needed a new algorithm. Both came from reading the full path from the calling isolate to the Sentry Android SDK. If your code sends data between isolates, check what each side does to that data before you optimize either side.
The Android core worker shipped in sentry_flutter 9.21.0, and the normalization and encoding improvements shipped in 9.26.0. Both changes are available in sentry_flutter 9.26.0 and later. For the latest fixes, upgrade to the latest version. If you see unexpected behavior after you upgrade, open an issue on GitHub.