How I Made Apache Fory JSON 4.5× Faster
Recently, I have been comparing Java serialization frameworks, and for binary payloads, I have become a big fan of Apache Fory which promises to be "blazingly-fast" on its web site, and it really is. In the comparisons, I have differentiated between JSON and binary payloads. Jackson was the king of JSON serialization (also for Kotlin), while Apache Fory dominated for binary payloads.
In the recent 1.7.0 release of Apache Fory, JSON support was added as the main new feature. So naturally, I was very curious and added Fory JSON to my serialization demo. When I saw the numbers for my payload, I couldn't believe them: Fory was actually 2.8 times slower than Jackson. Something was wrong here, and I decided to get to the bottom of it.
The Investigation
The payload size already hinted at a possible cause: Fory wrote a payload of 20,999 bytes vs. 9,814 bytes for Jackson. Our payload includes thumbnails as byte arrays, and Fory decided to serialize them like this:
"thumbnail":[-77,-1,99,-104,...]
This is extremely inefficient. Each byte becomes up to five characters, more than three and a half on average for random data. Writing needs a sign test and a decimal conversion while reading needs to scan digits and rebuild numbers. It is also at odds with RFC 7493, the protobuf JSON mapping, Jackson, Gson, Moshi, kotlinx.serialization and other serialization frameworks. JSON has no binary type, and a base64 string is the standard way of handling it: For each three bytes, four characters are written.
For the same bytes, that looks like this:
"thumbnail":"s/9jmA==..."
In the previous comparison, base64 handling was the point where the JSON engines differed most, and Fory's new module had kind of fallen into the same trap. But is the inefficient encoding the entire cause of the slowdown? Only one way to know, by profiling. The data is unambiguous, decoding the byte array dominates the call tree.


Without the thumbnails, Fory JSON did the same round trip in 3.5 µs. The new module was generally fine. It was just the default codec for byte arrays that was the whole problem.
As it turns out, Fory already has a Base64ByteArrayCodec, but it needed the @JsonBase64 annotation to be used.
My first move was to
flip the default codec for byte[]. But even with that change, the numbers were still terrible with only a 16% improvement.
The next profiling run showed why the gain was so modest. Writing and reading had moved in opposite directions. The base64 encoder is table-driven, writes directly into the output buffer and is about 6 times faster than writing decimal digits. The base64 decoder, however, was even slower than the decimal parsing. It did a branch-heavy validation scan over the whole string and then a second pass that re-read every character with per-character escape handling and a four-way branch per digit. With about 7 KB of base64 data per serialized order, the decoder alone took 85% of the entire run.


Fixing this was a bit more involved. The validation now runs as a single scan with a 128-entry table that maps each ASCII character to its 6-bit value or to -1 for anything invalid. The decoding pass consumes four characters per iteration through the same table and emits three bytes, without any branches. JSON strings can legally contain Unicode escape sequences, so a base64 body with a backslash still falls back to the old validating path. Since the encoder does not escape base64 characters, the fast path always runs in practice.
Finally, the numbers for Fory JSON came way down, from 75.9 µs to 16.8 µs per round trip:
| Byte array handling | Time per round trip | Payload size |
|---|---|---|
| Decimal number arrays (Fory 1.7.0) | 75.9 µs | 20,999 bytes |
| Base64 default, original decoder | 64.0 µs | 9,742 bytes |
| Base64 default, table-driven decoder | 16.8 µs | 9,742 bytes |
The first step brought a factor of 1.16, the second a factor of 3.8, together a factor of 4.5. Byte array handling was about 90% of the run time in the first two states and is still 66% after the fix, but of a much smaller total.


Fory JSON is now also faster than Jackson for this payload, and not 2.8 times slower as before:
| Setup | Time per round trip |
|---|---|
| Fory JSON, after the fix | 16.8 µs |
| Jackson 3.2.2 | 25.7 µs |
| Jackson 2.22.1 | 27.0 µs |
Against Jackson 2, Fory JSON is 1.6 times faster, against Jackson 3 it is 1.5 times faster.
So unexpectedly, the base64 default delivered correctness, interop and a 2.2 times smaller payload, but not much better speed. It required an improved base64 decoder which made all the difference.
Delivering the Fix
The heroes of this story are the Fory maintainers who in a matter of hours reviewed my
PR, improved the API with a @JsonByteArray format
annotation that keeps the old decimal format available as an opt-in,
and finally merged it into the main branch the next day.
Fory seems to be developed at the same speed as it serializes data. Kudos! And the moral of the story? New modules in fast libraries can have unoptimized corners. A profiler finds them in a matter of minutes.
How This Was Measured
The measuring setup was the same as in the previous posts: the JProfiler Gradle plugin recorded one snapshot per setup in offline mode, with CPU sampling or allocation recording in separate runs. A coding agent drove the JProfiler MCP server for the hot spot analysis. The full project is on GitHub.
To look at the snapshots yourself, download JProfiler and open the
.jps files from build/snapshots-cpu.