Appendix: Performance Benchmark
This appendix reports one run of the benchmark in the repository’s benchmark/ directory, which measures Spider Silk against Spring Boot on the same machine, with the same JVM and the same load.
The run took place on the night of 2026-10-12 on the laptop described under Machine, against main at commit 3d85b96, 165 commits after the 1.1.0 release.
Summary
The numbers below are medians, and they hold for this machine and these handlers only.
-
Plain text and a small JSON object each answer about 1.42 times as many requests per second on Spider Silk with Jetty as on Spring Boot. On the same container, Tomcat, the margins are about 1.31 and 1.24 times.
-
A JSON list of 100 records, where the serializer does most of the work, answers 1.72 times as many requests per second on Spider Silk with Jetty as on Spring Boot with Jackson, and 1.37 times on Tomcat.
-
The same list read from a request body, where the parser does most of the work, answers about twice as many requests per second on Spider Silk, on either container.
-
A page rendered with jte, Spider Silk’s default engine, answers about 3.1 times as many requests per second as Spring Boot with Thymeleaf. With Thymeleaf on both sides and the same container, Tomcat, the two are within 2% of each other.
-
A JVM process starts in 295 ms on Spider Silk with Jetty and in 902 ms on Spring Boot, and holds 117 MB against 210 MB of resident memory at that point.
-
As GraalVM CE native images, Spider Silk starts in 24 ms and Spring Boot in 37 ms, and building the image takes 72 s and 85 s respectively. The GraalVM CE images answer fewer requests per second than the JVM in every case, by 20% to 86%.
-
Built with Oracle GraalVM’s profile-guided optimization (PGO), the native images answer 1.18 to 1.98 times as many requests per second as the GraalVM CE images. Spider Silk’s PGO image keeps 79% to 94% of its JVM’s throughput, and Spring Boot’s 27% to 89%.
What was measured
Targets
Nine servers were measured, each in a process of its own.
| Target | Stack | Runtime |
|---|---|---|
|
Spider Silk, embedded Jetty 12.1.13, jte 3.2.4 or Thymeleaf 3.1.5 |
JVM |
|
Spider Silk, |
JVM |
|
Spring Boot 4.1.1 (Spring Framework 7.0.9), Spring Web MVC, embedded Tomcat 11.0.24, Jackson 3.1.5, Thymeleaf 3.1.5 |
JVM |
|
the same application as |
GraalVM CE native image |
|
the same application as |
Oracle GraalVM native image |
|
the same application as |
Oracle GraalVM native image with PGO |
|
the same application as |
GraalVM CE native image |
|
the same application as |
Oracle GraalVM native image |
|
the same application as |
Oracle GraalVM native image with PGO |
Spring Boot runs with its defaults: no property is set, and Tomcat, Jackson, and Thymeleaf are what the starters bring. Spider Silk runs with its defaults too, apart from the server and the template engine its arguments pick. Both servers have a pool of at most 200 platform threads, and neither uses virtual threads.
The native images of Spider Silk were built for Jetty only.
Cases
Each case is one request that gets the same answer on both frameworks.
| Case | Path | Spider Silk | Spring Boot | Body |
|---|---|---|---|---|
|
|
|
|
13 B |
|
|
|
|
27 B |
|
|
|
|
10,490 B |
|
|
|
|
10,490 B in, 3 B out |
|
|
|
none |
1,177 B |
|
|
|
|
1,178 B |
The json-list body is 100 records of five fields each, and the two frameworks send it byte for byte the same.
The json-post case sends that document as the body of a POST /items, and both frameworks read it into records and answer with their number.
The /fortunes page is a table of the 12 fortunes from the TechEmpower benchmark, one of which needs HTML escaping and one of which is Japanese.
The two Thymeleaf pages are the same template file and render to the same bytes.
The jte page differs in its blank lines, and in leaving " and ' unescaped in text, which HTML allows.
Every value a handler answers with is built once at startup, so a request measures the framework, the serializer, and the renderer, and not the data.
Method
benchmark/run.sh drives the whole run.
-
For each application,
native-build.shbuilds an image instrumented for profile-guided optimization with Oracle GraalVM’s--pgo-instrument.pgo-profile.shruns that image under every case the application serves, 10 seconds each, and the image writes the counts it collected to a profile when it stops. Spider Silk gets two profiles, one for jte and one for Thymeleaf, since its argument picks the engine at startup. -
native-build.shthen builds each of the three native images of each application three times from a clean project, alternating between the two applications. The PGO image is built with--pgoon the profiles, which also raises the optimization level from-O2to-O3. -
After a minute’s pause,
startup.shstarts each target ten times with three seconds of quiet before each start. It times the start from before the process launches to the first200from/text, polling every 10 ms, and reads the process’s resident memory at that moment. -
Each case then gets a fresh process: 20 seconds of warm-up, then 30 seconds of measurement, then the process stops.
-
A round runs every case once, case by case, each across every target, and three rounds run in turn. A machine that slows down as it heats up therefore spreads the drift over every target instead of the last one.
Startup is timed apart from the load cases because a process started right after the previous one stopped under full load starts up to half again as slow.
The load is wrk 4.1.0 with 4 threads and 64 keep-alive connections over loopback.
The server is pinned to CPUs 0 to 3, the four performance cores, and wrk to CPUs 4 to 7, the four low-power efficiency cores.
Every JVM and every native image gets -Xms1g -Xmx1g.
The JVM is Temurin 25 with its default collector, G1.
The -native images come from GraalVM CE 25.2.4, whose only collector is the Serial GC.
The -native-oracle and -native-pgo images come from Oracle GraalVM 25.4.4 with its default collector, which is also the Serial GC.
Built with its defaults, Oracle GraalVM infers a profile for each method from the code with a machine-learned model.
The PGO image uses the recorded profile in its place.
The reported throughput and each latency percentile are medians of the three rounds, taken separately.
Across the rounds, the throughput of most cases stayed within 10% of its median.
spider-silk-jetty ran 36% above its median under text in one round, and 13% above it under json-list.
spring-boot ran 11% above its median under json.
Among the native images, a single round ran 10% to 15% above its median in eight cases, and 11% to 12% below it in two: spring-boot-native under json and spider-silk-jetty-native under json-post.
The median of three rounds sets such a round aside.
No request failed in the run.
Machine
Machine |
Lenovo ThinkPad X1 Carbon Gen 13, on AC power |
CPU |
Intel Core Ultra 7 258V (Lunar Lake): 4 performance cores at up to 4.8 GHz and 4 low-power efficiency cores at up to 3.7 GHz, 8 threads, 12 MiB L3 cache |
Memory |
30 GiB (32 GB LPDDR5X) |
OS |
Ubuntu 24.04.5 LTS, Linux 6.17.0-1033-oem |
CPU frequency |
|
JVM |
Eclipse Temurin 25 (25+36-LTS) |
Native image |
GraalVM CE 25.2.4 (native-image 25.0.4), Oracle GraalVM 25.4.4 (native-image 25.0.4.1.1) |
Build |
Gradle 9.5.1 |
Load generator |
wrk 4.1.0 (Ubuntu package |
The desktop session stayed up during the run, with a terminal, Dropbox, and an idle local text-to-speech server open, which use a few percent of one CPU at rest. No web browser window was open.
Results
Throughput and latency
Throughput is requests per second. Latency is the time wrk measured from sending a request to receiving its response, in milliseconds. CPU is the server’s own CPU time during the measurement, where 400% means four cores busy throughout.
| Case | Target | Requests/s | p50 | p90 | p99 | CPU |
|---|---|---|---|---|---|---|
|
|
256,545 |
0.26 |
0.47 |
0.79 |
381% |
|
237,567 |
0.24 |
0.44 |
0.60 |
281% |
|
|
180,984 |
0.33 |
0.49 |
0.80 |
304% |
|
|
191,639 |
0.35 |
0.66 |
1.34 |
371% |
|
|
214,336 |
0.31 |
0.58 |
1.19 |
373% |
|
|
239,214 |
0.28 |
0.52 |
1.12 |
374% |
|
|
117,689 |
0.50 |
0.77 |
2.06 |
320% |
|
|
132,754 |
0.44 |
0.68 |
1.68 |
308% |
|
|
152,600 |
0.38 |
0.63 |
1.56 |
299% |
|
|
|
256,318 |
0.26 |
0.48 |
0.82 |
381% |
|
224,852 |
0.25 |
0.45 |
0.61 |
287% |
|
|
180,642 |
0.33 |
0.50 |
0.79 |
302% |
|
|
204,013 |
0.32 |
0.63 |
1.31 |
370% |
|
|
213,319 |
0.31 |
0.59 |
1.21 |
372% |
|
|
240,873 |
0.27 |
0.52 |
1.09 |
374% |
|
|
124,780 |
0.48 |
0.76 |
1.98 |
312% |
|
|
129,453 |
0.45 |
0.70 |
1.75 |
307% |
|
|
155,736 |
0.37 |
0.61 |
1.59 |
302% |
|
|
|
163,817 |
0.41 |
0.70 |
1.10 |
379% |
|
130,528 |
0.46 |
0.66 |
1.05 |
326% |
|
|
95,200 |
0.67 |
0.89 |
1.25 |
349% |
|
|
98,275 |
0.68 |
1.13 |
1.73 |
377% |
|
|
123,486 |
0.54 |
0.90 |
1.42 |
377% |
|
|
149,915 |
0.44 |
0.75 |
1.20 |
378% |
|
|
13,610 |
3.95 |
10.07 |
32.79 |
388% |
|
|
17,729 |
3.23 |
5.40 |
18.02 |
381% |
|
|
25,394 |
2.28 |
3.93 |
9.77 |
373% |
|
|
|
114,480 |
0.58 |
0.97 |
1.60 |
381% |
|
111,260 |
0.55 |
0.81 |
1.34 |
320% |
|
|
55,860 |
1.13 |
1.42 |
2.19 |
360% |
|
|
83,549 |
0.79 |
1.47 |
2.60 |
369% |
|
|
80,968 |
0.82 |
1.42 |
2.36 |
372% |
|
|
102,891 |
0.64 |
1.12 |
1.93 |
371% |
|
|
7,654 |
8.87 |
18.27 |
28.53 |
330% |
|
|
11,833 |
4.33 |
15.81 |
58.55 |
388% |
|
|
15,050 |
3.63 |
8.32 |
31.50 |
385% |
|
|
|
197,418 |
0.34 |
0.61 |
1.09 |
378% |
|
182,370 |
0.32 |
0.51 |
0.81 |
302% |
|
|
140,651 |
0.47 |
0.88 |
1.66 |
367% |
|
|
160,281 |
0.41 |
0.76 |
1.50 |
369% |
|
|
181,412 |
0.37 |
0.69 |
1.39 |
370% |
|
|
|
70,928 |
0.93 |
1.61 |
2.53 |
377% |
|
62,910 |
0.99 |
1.31 |
2.09 |
357% |
|
|
63,927 |
1.00 |
1.32 |
1.98 |
349% |
|
|
28,340 |
2.33 |
4.13 |
6.78 |
368% |
|
|
45,465 |
1.44 |
2.68 |
4.57 |
365% |
|
|
56,220 |
1.17 |
2.12 |
3.66 |
365% |
|
|
36,143 |
1.69 |
2.60 |
5.09 |
358% |
|
|
40,469 |
1.46 |
2.34 |
3.77 |
362% |
|
|
56,804 |
1.06 |
1.55 |
2.72 |
350% |
The ratios below divide Spider Silk’s throughput by Spring Boot’s, so a value above 1 favors Spider Silk. The last three columns set the two applications' native images from the same GraalVM against each other.
| Case | Jetty vs. Spring Boot | Tomcat vs. Spring Boot | GraalVM CE | Oracle GraalVM | PGO |
|---|---|---|---|---|---|
|
1.42 |
1.31 |
1.63 |
1.61 |
1.57 |
|
1.42 |
1.24 |
1.63 |
1.65 |
1.55 |
|
1.72 |
1.37 |
7.22 |
6.97 |
5.90 |
|
2.05 |
1.99 |
10.92 |
6.84 |
6.84 |
Spider Silk’s jte vs. Spring Boot’s Thymeleaf |
3.09 |
2.85 |
3.89 |
3.96 |
3.19 |
Thymeleaf on both sides |
1.11 |
0.98 |
0.78 |
1.12 |
0.99 |
Native images against the JVM
Each cell below is a native image’s throughput as a share of the same application’s on the JVM, with Spider Silk on Jetty.
| Case | Application | GraalVM CE | Oracle GraalVM | PGO |
|---|---|---|---|---|
|
Spider Silk |
75% |
84% |
93% |
Spring Boot |
65% |
73% |
84% |
|
|
Spider Silk |
80% |
83% |
94% |
Spring Boot |
69% |
72% |
86% |
|
|
Spider Silk |
60% |
75% |
92% |
Spring Boot |
14% |
19% |
27% |
|
|
Spider Silk |
73% |
71% |
90% |
Spring Boot |
14% |
21% |
27% |
|
|
Spider Silk |
71% |
81% |
92% |
|
Spider Silk |
40% |
64% |
79% |
Spring Boot |
57% |
63% |
89% |
Oracle GraalVM’s defaults answer 0.97 to 1.60 times as many requests as GraalVM CE, and the recorded profile adds another 1.12 to 1.43 times on top of them.
The PGO images also answer with lower latency than the GraalVM CE images at every percentile but one.
spring-boot-native-pgo has a p99 of 9.77 ms under json-list against 32.79 ms on GraalVM CE, but under json-post its p99 of 31.50 ms is no better than GraalVM CE’s 28.53 ms, while its p50 falls from 8.87 ms to 3.63 ms.
Startup and memory
Startup is the median of ten starts, from before the process launches to its first 200.
Memory is the process’s resident set size at that moment.
| Target | Startup (median) | Fastest | Slowest | Resident memory |
|---|---|---|---|---|
|
295 ms |
292 ms |
300 ms |
117 MB |
|
344 ms |
342 ms |
348 ms |
117 MB |
|
902 ms |
887 ms |
911 ms |
210 MB |
|
24 ms |
21 ms |
24 ms |
37 MB |
|
24 ms |
23 ms |
41 ms |
40 MB |
|
23 ms |
9 ms |
24 ms |
28 MB |
|
37 ms |
36 ms |
38 ms |
73 MB |
|
37 ms |
37 ms |
38 ms |
75 MB |
|
37 ms |
34 ms |
38 ms |
51 MB |
The polls come about 14 ms apart, curl’s own start included, so the native images' startup times fall on that grid. Spider Silk’s PGO image answered the first poll in four of its ten starts, at 9 to 10 ms, and the second poll in the rest, at 23 to 24 ms. The PGO images hold less memory at startup: 28 MB and 51 MB against 37 MB and 73 MB on GraalVM CE.
Spring Boot’s own log reports Started SpringBootBenchmark in 0.72 seconds on the JVM and in 0.024 seconds as a GraalVM CE native image.
The difference from the table is the JVM’s own startup and the time to the first answer.
Under load, a JVM process held 770 to 870 MB, most of it the 1 GiB heap that -Xms1g reserves.
The native images held 55 to 351 MB, the most under json-post on spring-boot-native.
Native image builds
Each native image was built three times from a clean project, and each instrumented image once.
The build time covers compiling the application, running Spring’s AOT processing where there is one, and running native-image.
The Spider Silk modules the application depends on were already compiled and are not counted.
| Application | Image | Build (median) | native-image alone |
native-image peak memory |
Binary |
|---|---|---|---|---|---|
Spider Silk |
GraalVM CE |
71.5 s |
70 s |
4.2 GiB |
63.6 MB |
Oracle GraalVM |
133.3 s |
131 s |
4.6 GiB |
74.1 MB |
|
Oracle GraalVM, instrumented |
142.8 s |
141 s |
7.0 GiB |
167.6 MB |
|
Oracle GraalVM, PGO |
86.5 s |
85 s |
4.5 GiB |
47.2 MB |
|
Spring Boot |
GraalVM CE |
84.5 s |
81 s |
4.0 GiB |
73.9 MB |
Oracle GraalVM |
152.1 s |
149 s |
4.9 GiB |
84.9 MB |
|
Oracle GraalVM, instrumented |
171.3 s |
168 s |
9.1 GiB |
197.5 MB |
|
Oracle GraalVM, PGO |
104.6 s |
102 s |
4.0 GiB |
55.1 MB |
native-image used all eight CPUs, at a load of 6.5 to 7.1, in every build.
The PGO build takes about a third less time than Oracle GraalVM’s default build, and its binary is about a quarter smaller than GraalVM CE’s.
A PGO image needs the instrumented build and the profiling run first, and the instrumented image is 2.6 times the size of the GraalVM CE image.
The JVM applications' dependencies come to 9.6 MB for Spider Silk and 21 MB for Spring Boot.
The serializer and the parser alone
benchmark/json-bench measures the JSON engine with no HTTP around it, on the documents the HTTP cases send: the 100 records of json-list and the 27-byte object of json.
It also measures 100 places with three numbers each that have a fraction, and 100 orders whose status is an enum, which no HTTP case sends.
The same 100 records come in more forms: with nothing to escape, with Korean strings, with their keys in reverse order, with a description of 200 characters in English and in Korean, spaced out as Python’s json.dumps and a pretty printer write them, with ids of 13 and 18 digits, and with strings of no character or one.
The reversed and spaced keys stand for a document that another program wrote, and the long description for prose such as a product page.
It ran on 2026-10-11 on the same machine, on AC power with the performance platform profile.
It measured silk-json with the changes on top of commit 11b9a13, compiled for Java 25, under JMH 1.37 with two forks of five warm-up and five measured iterations of a second each, pinned to CPUs 0 to 3, with -Xms1g -Xmx1g, on Temurin 25.
Each cell is the average time per operation and the bytes allocated per operation, the latter from JMH’s gc profiler.
| Operation | Through | Time | Allocation |
|---|---|---|---|
Write the 100 records (10,490 B) |
the |
8.0 µs |
24.1 KB |
a hand-written |
5.9 µs |
11.3 KB |
|
the generated codec |
4.7 µs |
10.7 KB |
|
Jackson 3.1 |
11.8 µs |
19.7 KB |
|
fastjson2 2.0.57 |
4.6 µs |
10.7 KB |
|
fastjson2 2.0.57, |
5.7 µs |
10.7 KB |
|
Write the 100 records to a stream, from the buffer |
the generated codec, |
3.8 µs |
184 B |
fastjson2 2.0.57, |
3.6 µs |
184 B |
|
Write the 100 records with nothing to escape (10,090 B) |
the generated codec |
4.2 µs |
10.3 KB |
fastjson2 2.0.57 |
3.6 µs |
10.3 KB |
|
Write the 100 records in Korean (9,590 B) |
the generated codec |
4.0 µs |
9.8 KB |
fastjson2 2.0.57 |
3.6 µs |
9.8 KB |
|
Write the 100 records with a 200-character description (27,498 B) |
the generated codec |
6.8 µs |
28.4 KB |
fastjson2 2.0.57 |
6.3 µs |
27.7 KB |
|
Write the 100 records with a 200-character description in Korean (54,516 B) |
the generated codec |
23.0 µs |
55.4 KB |
fastjson2 2.0.57 |
37.0 µs |
54.7 KB |
|
Write the 100 orders (5,985 B) |
the generated codec |
2.4 µs |
6.2 KB |
fastjson2 2.0.57 |
2.7 µs |
6.2 KB |
|
Read the 100 records |
a hand-written |
14.2 µs |
37.8 KB |
the generated codec |
5.6 µs |
17.6 KB |
|
Jackson 3.1 |
23.7 µs |
52.8 KB |
|
fastjson2 2.0.57 |
6.1 µs |
24.8 KB |
|
fastjson2 2.0.57, |
24.5 µs |
111.2 KB |
|
Read the 100 records in Korean |
the generated codec |
5.7 µs |
23.1 KB |
fastjson2 2.0.57 |
7.9 µs |
34.1 KB |
|
Read the 100 records with their keys reversed |
the generated codec |
7.3 µs |
17.6 KB |
fastjson2 2.0.57 |
6.3 µs |
24.8 KB |
|
Read the 100 records as |
the generated codec |
6.7 µs |
17.6 KB |
fastjson2 2.0.57 |
7.0 µs |
24.8 KB |
|
Read the 100 records indented (14,091 B) |
the generated codec |
9.0 µs |
17.6 KB |
fastjson2 2.0.57 |
8.1 µs |
24.8 KB |
|
Read the 100 records with a 200-character description |
the generated codec |
6.6 µs |
34.3 KB |
fastjson2 2.0.57 |
5.4 µs |
34.4 KB |
|
Read the 100 records with 13-digit ids |
the generated codec |
5.9 µs |
17.6 KB |
fastjson2 2.0.57 |
6.9 µs |
24.8 KB |
|
Read the 100 records with 18-digit ids |
the generated codec |
6.0 µs |
17.6 KB |
fastjson2 2.0.57 |
7.3 µs |
24.8 KB |
|
Read the 100 records with strings of no character or one (6,956 B) |
the generated codec |
2.8 µs |
5.5 KB |
fastjson2 2.0.57 |
3.9 µs |
14.8 KB |
|
Read the 100 orders |
the generated codec |
3.1 µs |
9.5 KB |
fastjson2 2.0.57 |
3.2 µs |
9.6 KB |
|
Write the 27-byte object |
the generated codec |
25 ns |
232 B |
Jackson 3.1 |
135 ns |
496 B |
|
fastjson2 2.0.57 |
27 ns |
216 B |
|
Write the 100 places (8,263 B) |
the generated codec |
9.4 µs |
8.5 KB |
Jackson 3.1 |
20.1 µs |
50.1 KB |
|
fastjson2 2.0.57 |
9.5 µs |
17.6 KB |
|
Read the 100 places |
the generated codec |
5.1 µs |
11.1 KB |
Jackson 3.1 |
26.6 µs |
59.7 KB |
|
fastjson2 2.0.57 |
5.3 µs |
11.2 KB |
The generated codec writes the list in 40% of Jackson’s time and reads it in 24%, with between a third and a half of the allocation, and writes the small object in under a fifth of Jackson’s time.
The hand-written writer lands between the tree and the codec: it finds each key among the keys the document has already written, where the codec copies the key from a constant.
The codec reads the 10 KB document in 92% of fastjson2’s time, and writes the small object in 92% of it.
fastjson2 stays ahead writing the 10 KB document, by 3%.
Its lead is in writing the strings, and it is wider where no string needs an escape: 1.17 times on the plain records and 1.11 times on the Korean ones.
fastjson2 reads a String’s own bytes through `sun.misc.Unsafe, which the JDK is retiring and core does not use.
The codec copies a string’s characters out with getChars and checks them one at a time.
Copying a string’s bytes with no check at all, a floor no correct writer can stand on, writes the plain records in 2.7 µs, ahead of fastjson2’s 3.7 µs.
Checking that copy eight bytes at a time takes it to 4.0 µs, so the lead is the string’s representation, all of it.
The codec escapes a quote or a backslash in the same loop that writes the plain characters, and compares each character instead of looking it up in a table.
Before it did, the first quote sent the rest of the string to a slower loop, and the same write took 5.3 µs.
The JIT compiler of JDK 25 does not vectorize a loop that narrows a char to a byte, so a pass that checks the whole string before writing it measured slower, not faster.
The codec writes a member’s key and value with one call, put(JsonKey, …), which checks the writer’s state once.
It writes a number below 1000 with one store, and a three-byte character, such as a Korean syllable, with one four-byte store.
Measured back to back with the build before them, these took the plain records from 4.4 µs to 4.2 µs and the Korean ones from 4.4 µs to 4.0 µs.
On Temurin 21, which the release targeted before it required Java 25, the ratios were the same: fastjson2 writes the plain records 1.15 times as fast and the Korean ones 1.13 times, and the codec reads the list in 88% of fastjson2’s time.
A string of 64 characters or more takes another path.
The JDK’s US_ASCII encoder narrows it into the buffer through an intrinsic of the JIT compiler.
The string’s own indexOf, another intrinsic, finds its quotes and backslashes, and the narrowed bytes before them are checked eight at a time for a control character only.
An escape moves the narrowed bytes after it up, rather than narrowing them again.
The encoder stops at the first character past ASCII, and the rest of the string goes to the loop that writes one character at a time.
Measured back to back with the builds before them, these took the records with a long description from 15.0 µs to 8.6 µs, and then to 6.9 µs.
fastjson2 still writes them 1.08 times as fast.
In Korean, the codec writes the long descriptions in 62% of fastjson2’s time.
Java 25 has MemorySegment.setString, the one public call that copies a string’s Latin-1 bytes with no widening to char.
Copying through it and checking the bytes eight at a time wrote the 10 KB list in 5.4 µs against 4.9 µs and the long descriptions in 7.1 µs against 6.8 µs, so the codec does not use it.
The codec allocates 0.7 KB more for them, the encoder and the two buffers around it, made once per document.
A plain string pays on this path from about 16 characters, on Temurin 21, on Temurin 25, and under GraalVM’s JIT compiler alike.
A string with escapes pays only from 48 to 96 characters, depending on the compiler, and the threshold of 64 sits between the two.
The 2% to 2.5% that this path first cost the 10 KB list no longer shows: without the path, the same build writes the list in 4.68 µs against 4.72 µs.
An enum constant is written and read through its name encoded once as a JsonKey.
The name is copied out as a key is, and compared in place on the way in, with no string made.
Measured back to back with the build before, this took the orders from 2.9 µs to 2.4 µs out and from 4.6 µs to 3.0 µs in, now 91% and 96% of fastjson2’s time.
A generated codec does this for an enum wherever it stands: a property, an Optional, an element of a collection, or a value of a map.
The codec reads the Korean records in 72% of fastjson2’s time.
It checks a string that has bytes past ASCII and decodes it into characters in the same pass, and widens the ASCII before them without a check.
Before it did, it checked the bytes and then had the JDK decode them again, and the same read took 11.0 µs.
fastjson2 does not check them: an overlong sequence, or a surrogate written as UTF-8, becomes U+FFFD in the value, where the codec rejects the document.
A generated read tries the declared keys in order first.
A key out of that order is found by a switch on the four bytes where the next member stands, which JsonInput.keyHead() reads.
Measured back to back with the build before, this took the list from 6.0 µs to 5.5 µs and the reversed one from 9.5 µs to 8.0 µs.
Once a document has left the declared order, each key after it goes straight to that switch, which took the reversed records to 7.2 µs.
fastjson2 still reads them 1.17 times as fast.
The read also passes over whitespace before a key and after its comma, and checks a comma and one space first.
Measured back to back with the build before, this took the records as json.dumps spaces them from 10.0 µs to 6.6 µs, 95% of fastjson2’s time, and the indented ones from 11.6 µs to 8.6 µs.
In this run the indented ones took 1.11 times fastjson2’s time.
An integer of 8 to 16 digits is read two words at a time, which took the 18-digit ids from 6.5 µs to 5.9 µs.
The codec reads the 13-digit and the 18-digit ids in 86% and 83% of fastjson2’s time.
An empty or one-character string comes from a table with no allocation, which took the records of such strings from 3.4 µs and 13.9 KB to 2.8 µs and 5.5 KB, 73% of fastjson2’s time.
fastjson2 also reads the long descriptions 1.21 times as fast.
It finds the end of a string and makes the String through JDK internal methods that it binds by way of sun.misc.Unsafe, and the public API has no equivalent.
Each place has a latitude with four decimals, a longitude with six, and a rating with one.
The codec reads them in 96% of fastjson2’s time with the same allocation, and in a fifth of Jackson’s.
readDouble converts a number from its digits, with one multiplication or division for numbers like these.
Before it did, it made a string of each number for Double.parseDouble, and the same read took 12.6 µs and 43.9 KB.
Jackson 3 parses a double with its fast parser by default, so turning USE_FAST_DOUBLE_PARSER on changed nothing.
The codec writes the places in 98% of fastjson2’s time with half its allocation, little more than the document itself, and in under half of Jackson’s time.
JsonOutput lays out a double’s shortest decimal straight in its buffer, by the algorithm Double.toString uses since JDK 19, and writes the same text.
Before it did, it made a string of each number with Double.toString and copied it, and the same write took 16.5 µs and 35.6 KB.
Written to a stream straight from the thread’s buffer, as WebResponse.json sends a body, the list takes 3.8 µs and 184 bytes.
That is 1.0 µs and 10.5 KB less than the array toBytes() copies the document into.
fastjson2’s JSON.writeTo saves as much, so the saving is the framework’s rather than the library’s.
In the HTTP json-list case, the same change took the allocation per request from 14.4 KB to 3.8 KB on Jetty and from 13.2 KB to 2.6 KB on Tomcat, counted from the GC log over 30 seconds of load.
fastjson2’s speed comes with terms that the table does not show.
Under --sun-misc-unsafe-memory-access=deny, fastjson2 2.0.57 does not initialize: JDKUtils.<clinit> fails at arrayBaseOffset, and every later call throws NoClassDefFoundError.
-Dfastjson2.creator=reflect fails the same way, and fastjson2 has no mode without sun.misc.Unsafe.
On JDK 24 and later, its first call prints a warning that a method of sun.misc.Unsafe will be removed in a future release.
JEP 471 and JEP 498 set the order: a warning, then denial by default, then removal.
The default in JDK 26 and JDK 27 is still the warning, and no JEP yet makes denial the default or removes the methods.
fastjson2’s 2.1.x branch, which moves to VarHandle, has had no change since February 2026.
With -Dfastjson2.creator=reflect on the JVM, fastjson2 builds no classes at run time, and reads the list in 24.5 µs and 111.2 KB, four times its usual time, and writes it in 5.7 µs.
In a GraalVM native image, fastjson2 turns off its String fast path and its generated classes, and binds through reflection, which needs reachability metadata for every type it binds.
fastjson2 2.0.57 does not build into a native image with the settings its jar carries, under GraalVM CE 25.2.4.
Those settings initialize JSONFactory and a few other classes at build time, and the objects those classes hold reach classes the settings leave to run time, which native-image refuses.
Built with the whole package initialized at build time, the image misreads the records: "available":false fails with syntax error : 102, while true reads.
fastjson2 reads false through sun.misc.Unsafe, at an offset into the byte array that JDKUtils computed in the JVM that built the image.
That offset is 16, and the image’s own is 12.
JDKUtils is one of the classes fastjson2’s own settings initialize at build time, so the offset is wrong in any image of it that builds.
silk-json reads through a VarHandle view of the array, which carries no offset.
json-bench/native-bench.sh builds six of the cases into a native image with GraalVM CE 25.2.4’s defaults, and NativeBench measures them with no JMH around them: a warm-up of three seconds, then the median of ten rounds of one second.
The same harness on Temurin 25 gives the JVM column.
| Operation, generated codec | Native image | JVM |
|---|---|---|
Write the 100 records |
12.2 µs |
4.6 µs |
Write the 100 records in Korean |
9.7 µs |
4.5 µs |
Write the 100 places |
12.9 µs |
9.6 µs |
Read the 100 records |
10.5 µs |
5.6 µs |
Read the 100 records in Korean |
10.8 µs |
6.5 µs |
Read the 100 places |
9.6 µs |
5.1 µs |
The native image takes 1.3 to 2.6 times the JVM’s time, as the native images of the HTTP cases answer fewer requests than their JVMs.
Built with -O3, the image took 0% to 8% less time in each case, and with -march=native from 6% less to 11% more, so neither option closes the gap to the JVM.
fastjson2 is left out of the table, since its image misread the documents.
In the HTTP cases the serializer and the parser are a share of each request, and Reading the results sets them against the throughput.
Reading the results
The text and json cases measure what a framework adds to a request, since the body is tiny.
On the same container, Tomcat, Spider Silk answers 31% more requests per second than Spring Boot under text and 24% more under json.
The two differ in the work done per request.
Spider Silk’s path from the servlet to the handler is two stack frames, while Spring Web MVC resolves a handler mapping, argument resolvers, return-value handlers, and message converters on every request.
Jetty adds another 8% to 14% on top.
It is also the only server that kept four cores busy at these 64 connections, while both Tomcat targets stayed at about 300%.
The json-list case widens the gap to 1.72 times on Jetty and 1.37 times on Tomcat, because a 10 KB document is the serializer’s work rather than the framework’s.
The generated codec writes the document into the thread’s buffer, and WebResponse.json sends the body from that buffer with no copy.
The serializer and the parser alone measures the codec at 40% of Jackson’s time on the same list.
In the run of 2026-10-09, JsonWriter.list built the whole document as a tree before writing any of it, and the gap was 2% to 5%.
An application that sends large documents has two other paths: the streaming writer in Large JSON and NDJSON, and a binding library such as Jackson through External JSON Binding.
Neither was measured here.
The json-post case measures the parser, and Spider Silk answers about twice as many requests per second as Spring Boot on either container.
The generated codec reads the body’s bytes straight into records, with no tree and no reflection.
The serializer and the parser alone measures it at 24% of Jackson’s time on the same list.
Jetty and Tomcat land within 3% of each other here, since the parse outweighs the container.
The template cases show the engine outweighing the framework. jte runs a template as compiled Java, and renders this page about 2.8 times as fast as Thymeleaf, which interprets the template and reads the model through an expression language. With Thymeleaf on both sides, the framework’s share is small enough that the two land within 2% of each other on Tomcat. Jetty adds 13% to Spider Silk’s Thymeleaf page.
The native images give up throughput for startup and memory.
They run code compiled ahead of time, on the Serial GC instead of G1.
The allocation-heavy cases suffer most on GraalVM CE: spring-boot-native answers 14% of what the same application does on the JVM under json-list and under json-post, with a p99 of 33 ms and 29 ms.
Spider Silk’s GraalVM CE image keeps 60% and 73% of its JVM’s throughput under the same cases, in line with the codec’s times in The serializer and the parser alone.
On GraalVM CE, Thymeleaf is the one case where Spider Silk’s native image is slower than Spring’s.
The two evaluate Thymeleaf’s expressions differently: Spider Silk’s module through OGNL, which reads Fortune by reflection, and Spring’s integration through SpEL.
Profile-guided optimization recovers most of the throughput that GraalVM CE gives up, for both applications.
A JIT compiler compiles each method with the branches and types the running program has taken, and a GraalVM CE image is compiled with none of that.
The PGO image is compiled with the counts its instrumented twin recorded under the same cases.
Spider Silk’s PGO image keeps 79% to 94% of its JVM’s throughput, and Spring Boot’s 27% to 89%.
Part of the gain is Oracle GraalVM’s own: its default build, with a profile inferred from the code instead of recorded, already answers up to 1.60 times as many requests as GraalVM CE.
The largest gains are where GraalVM CE lost the most: Thymeleaf on Spider Silk, and json-list and json-post on Spring Boot, each close to twice GraalVM CE’s throughput.
With PGO, the two Thymeleaf pages land within 1% of each other.
The profile was recorded under the same requests that were then measured, which is the most favorable profile there is, as Limits notes.
The G1 collector, which Oracle GraalVM also offers, was not measured.
Limits
-
Client and server share one laptop and talk over loopback, so there is no network latency and no network bottleneck.
-
The two halves of the CPU differ: the server ran on performance cores and wrk on efficiency cores. wrk kept up with the fastest case, so the server, not wrk, set the throughput.
-
wrk sends the next request on a connection only after the previous response arrives, so its percentiles omit the time a request would have waited behind a slow one (coordinated omission). The percentiles compare the targets with each other, not with a service-level objective.
-
The CPU ran under the
powersavegovernor and theperformanceprofile, so its clock still rose and fell with load and temperature. The interleaved rounds spread that drift over every target. -
The handlers do no I/O, so the numbers measure the framework, the serializer, and the template engine only. An application that waits on a database spends most of a request there, and the differences above shrink accordingly.
-
The PGO images were profiled under the very cases they were measured on. An application’s profile covers only the code its profiling load reaches, so its gain depends on how well that load matches production traffic.
-
A response from a Jetty target is 26 bytes longer than one from a Tomcat target, mostly for its
Server: Jetty(12.1.13)header.
Reproducing the run
The benchmark/ directory holds both applications and the scripts, and its README.md lists the settings.
It is a Gradle build of its own that includes the root build, so the Spider Silk side runs the modules of the working tree it sits in.
sudo apt install wrk # or: brew install wrk
sdk install java 25.2.4-graalce # for the native images
sdk install java 25.4.4+1-graal # for the Oracle GraalVM and PGO images
GRAALVM_HOME=~/.sdkman/candidates/java/25.2.4-graalce ORACLE_GRAALVM_HOME=~/.sdkman/candidates/java/25.4.4+1-graal benchmark/run.sh
The run writes benchmark/results/<timestamp>/ with the raw CSV files, the wrk output, each server’s log, a sample response of every case, and the description of the machine.
benchmark/summarize.sh reduces that directory to the tables above.
The microbenchmark runs on its own, from the benchmark/ directory:
../gradlew -p json-bench installDist
taskset -c 0-3 json-bench/build/install/json-bench/bin/json-bench -jvmArgs "-Xms1g -Xmx1g" -prof gc
Without GRAALVM_HOME, the run measures the JVM targets only.
Without ORACLE_GRAALVM_HOME, it leaves out the Oracle GraalVM and PGO targets.
The full run took 2 hours 51 minutes on this machine, and takes about 80 minutes without the Oracle GraalVM targets.
ROUNDS=1 BUILD_ROUNDS=1 STARTS=3 WARMUP=5s DURATION=10s gives a rough picture in about 15 minutes without them.