evitaDB - Fast e-commerce database
logo
page-background

Choosing HTTP server for evitaDB

We plan to provide several ways to communicate with evitaDB clients. For that we needed some universal HTTP server that would serve at least most of the requests.
The main goal right now is to provide GraphQL, REST, gRPC and potentially WebSockets or SSE APIs for some specific use-cases. However, there is currently no foundation for HTTP communication as evitaDB comes only with Java API. Because of this, there was a need to find some HTTP server, library or framework that would serve as a common foundation for all of those mentioned APIs.
The article updated as of June 2024 (added Armeria server)
All web server versions have been updated to up-to-date versions as of June 2024. We also added the Armeria built on top of Netty server server to the listing.
Server, library or frameworkJMH Score (ops/s)Min JMH Score (ops/s)Max JMH Score (ops/s)
microhttp164781552717429
Netty161091501017208
Vert.x151891459715781
Undertow147331402115445
Armeria142561393514576
Javalin141141126916960
Quarkus135291342013639
Micronaut135071233714677
Spring Boot WebFlux131291270313555
Spring Boot MVC106751053310817
NanoHTTPD774475617927
Throughput results (ops/s - higher is better).
Server, library or frameworkJMH Score (us/op)Min JMH Score (us/op)Max JMH Score (us/op)
Microhttp367.523341.455393.591
Netty381.255355.639406.871
Vert.x383.202365.798400.606
Undertow392.591373.907411.275
Armeria452.791425.552480.030
Quarkus455.963428.281483.645
Micronaut459.101430.433487.769
Spring Boot WebFlux460.533433.276487.790
Javalin465.533407.634523.432
Spring Boot MVC558.134540.083576.185
NanoHTTPD800.347764.524836.170
Average time results (us/op - smaller is better).
The article updated as of June 2023
Thanks to Francesco Nigro's from RedHat comments in Issue #1 (thanks!) we updated:
  • updated the versions of all tested web servers to the latest versions,
  • fixed the problem in the Netty performance test implementation that erroneously closed an HTTP connection in every iteration,
  • enforced the HTTP protocol version to 1.1, so that the servers that allow upgrading to HTTP/2 version don't have an advantage,
  • changed the performance test behavior to use a separate HTTP client for each JMH thread

... and re-measured all tests with only a single web server running in parallel.

Server, library or frameworkJMH Score (ops/s)Min JMH Score (ops/s)Max JMH Score (ops/s)
Netty323103137233248
microhttp313443059732092
Vert.x304632891532010
Javalin266492450228796
Undertow252142244427985
Micronaut221691962624712
Quarkus212691965022887
Spring Boot WebFlux200161867721355
Spring Boot MVC155501505916041
Quarkus (in native mode)154331451616351
NanoHTTPD940090689733
Throughput results (ops/s - higher is better).
Server, library or frameworkJMH Score (us/op)Min JMH Score (us/op)Max JMH Score (us/op)
Microhttp193184202
Vert.x198194201
Netty198182215
Javalin229225233
Undertow253232274
Micronaut283262304
Quarkus287264309
Spring Boot WebFlux306281331
Spring Boot MVC385378392
Quarkus (in native mode)391369412
NanoHTTPD646635657
Average time results (us/op - smaller is better).
We're still using the Undertow server, even though it's no longer one of the fastest. We hope it will change its internal implementation to a Netty server as promised for version 3.x.

Criteria and requirements

As the main requirement, we wanted the lowest possible latency and the highest possible throughput of HTTP request processing and also a simple codebase of chosen server, library or framework so that there would be a smaller probability of “magic” behavior and unexpected surprises. Another important feature was the ability to embed the HTTP foundation into the existing evitaDB codebase without needing to adjust the whole running and building processes of evitaDB to the chosen solution because in future there, may be other ways to communicate with users. An advantage to all of these requirements would be a simple and straightforward API for handling HTTP requests and errors. This means that there wouldn't be any unnecessary low level HTTP communication work involved, if not explicitly needed by some specific use-cases. Last but not least, it would be nice to have at least partial built-in support for handling WebSockets for future specific use-cases. Finally, a server or library that is publicly known to work or being tested with GraalVM or having direct GraalVM support is a plus.

Servers, libraries and frameworks

We decided to spend up to 8 hours on ecosystem exploration and chose the following 10 Java HTTP servers and frameworks to test:

This list contains low level servers, small libraries and even big and well known frameworks for building web applications. We decided to include those big frameworks to have some performance comparison with lower level servers so that we know whether the possible sacrifice of missing high level abstractions is really worth it. Our servers, libraries and frameworks were selected upon several recommendation articles and mainly by GitHub repository popularity, i.e. star count, issues, dates of last commits and so on.

Testing environment

We tested the servers, libraries and frameworks on a simple “echo” GraphQL API because we wanted to test the baseline latency of HTTP request processing for GraphQL API. The GraphQL API was chosen because it is the first API evitaDB is going to support, and we believe that this approach will give us the accurate measurement even for other future APIs. This simplified test API was then implemented on top of each chosen server, library and framework.

The “echo” API contains single query that takes single string argument:

which it then returns in response message:

No additional business logic is implemented there.

For actual testing and result interpretation, we used the Java Microbenchmark Harness (JMH). A testing workflow consists of two parts: running servers with implemented APIs and running JMH tests. Firstly, an application with tested HTTP servers is started, which starts each server in a separate thread with a custom port. Then, the JMH application is started and each test, for each server, builds a Java HTTP Client which then continuously generates calls to the appropriate server for one minute from several threads.

Implementation of servers

Implementation of individual servers involved basically looking up the official library or server examples and transforming them into the “echo” GraphQL API with help of the GraphQL Java library (which is de-facto the industry standard for writing GraphQL APIs on the Java platform). Besides the fact that this approach was rather time efficient, when dealing with so many servers, libraries and frameworks, it also showed how easy it is to work with each individual server or library and what surprises there are when building even a simple server based on an official example. Another advantage of this approach is that it shows base performance of a particular solution without need for complex low level configuration. If there is a need for complex configuration even at the beginning, we fear that in future this could be rather difficult to maintain.
microhttp, Javalin, Vert.x and Undertow were pretty straightforward to handle. We needed to write just a few lines of code to implement simple HTTP request handlers. The implementation of the remaining servers and libraries was a little bit more complicated.
NanoHTTPD comes with a rather simple API, but it was not designed to handle JSON requests, only basic HTTP POST bodies. Therefore, it involved reimplementation of body parsing. Also, it seems that it is no longer maintained, although some discussion is still happening in issues of its GitHub repository.
Netty on the other hand required to manually configure several worker threads and other communication options. In the case of their HTTP codec, there is quite a large and really not straightforward abstraction for HTTP request and response classes. There are several questionably named classes (at least for new users of this server) that are ultimately composed together and passed to handlers. This was a problem when trying to find a way to read a POST request body. We had to register Netty’s body aggregator and use a specific HTTP request class that has access to that body in the end.
Spring’s MVC and WebFlux were not difficult to implement but were difficult to set up as they require a custom Spring Boot Maven plugin to compile which ultimately discards the idea of embedding it in other applications. Fortunately, the solution was rather simple, build separate jar files for each Spring server.
Implementing the service in the Quarkus framework was much more difficult. If we omit difficulties with GraalVM itself, such as custom building, there were also difficulties in the implementation of the actual controllers. The most notable obstacle was the inability to split an implementation into multiple modules, although it should be possible. Therefore, unlike other implementations, this one had to have all of its controllers in one place together with the main application class.
In the case of the Micronaut, the implementation itself was rather simple, probably the simplest one among other servers and libraries (with the help of a built-in controller for GraphQL). The problem was in the Maven module setup. Micronaut uses a custom Maven parent POM and there are some hidden dependencies, and it wasn’t possible to run Micronaut without it as part of a larger multi-module Maven project.

Benchmarking servers

The final tests were run with 1 warm-up iteration, 5 measurement iterations and 2 forks on a laptop with Ubuntu 21.10 and an 8-core Intel Core i7-8550U CPU with 16 GB of RAM. The tests were run in two modes: throughput (operations per second) and average time (microseconds per operation).

Server, library or frameworkJMH Score (ops/s)Min JMH Score (ops/s)Max JMH Score (ops/s)
microhttp30,19930,03430,401
Netty28,68928,61728,748
Undertow25,76025,74525,793
Javalin23,65023,39923,995
Vert.x22,85022,47723,070
Micronaut19,57219,39419,841
Spring Boot WebFlux18,15817,99118,234
Spring Boot MVC17,67417,60317,786
Quarkus (in native mode)11,50911,38311,642
NanoHTTPD6,1716,0516,254
Throughput results (ops/s - higher is better).
Server, library or frameworkJMH Score (us/op)Min JMH Score (us/op)Max JMH Score (us/op)
microhttp131129133
Netty145142146
Undertow156156156
Javalin172168175
Vert.x173172174
Micronaut202201203
Spring Boot WebFlux224223225
Spring Boot MVC224222233
Quarkus (in native mode)348345353
NanoHTTPD642625649
Average time results (us/op - smaller is better).
A gist with the raw results can be found here and charts for visualization here.
From the above results, there are 3 main adepts for the winner: microhttp, Netty and Undertow. Quite interesting and surprising are the results of Javalin, which is, in fact, a framework built upon the Jetty server and not a barebone HTTP server.
The results of the popular Netty server, which is a low level server with the most difficult API of all of them, are also very good. Also, we expected the Quarkus server, which was run in native mode using GraalVM to end up in higher positions. In contrast to that, large frameworks such as Spring and Vert.x were much more performant than we ever expected due to their complex abstraction.

Choosing the final solution

Final decision - which server, library or framework to pick was narrowed to the 3 solutions: microhttp, Javalin and Undertow. Because they performed very similarly, the decision was made based upon their advantages and disadvantages relevant to evitaDB.

In initial performance tests, we made a mistake that led to the low performance of the Netty server compared to other solutions. This bug was fixed months later after a comment by Francesco Nigro. Due to the initially insufficient number of servers and the complex API, we excluded Netty from the list of web servers we selected for use in evitaDB.

At first, the microhttp server seemed like the one to go with, mainly because of its exceptionally small codebase (around 500 LoC) and a simple and straightforward API. Overall, this server ticked almost all the requirement boxes except the one for support for WebSockets. But the unsure future of this fairly new project made it quite a deal-breaker. Some other possible disadvantages may be the lack of support for SSL and other advanced features. With that in mind, we focused on choosing between Javalin and Undertow. Javalin is a lightweight framework built upon the Jetty server and Undertow is an actual HTTP server, yet they performed almost the same. Both tick all the requirement boxes. Both are performant, easily embeddable, small enough to limit the possibilities of “magic” surprises, have simple and straightforward APIs and even support WebSockets. Both are popular and are updated regularly. Both support non-blocking request processing and both should probably run on GraalVM in the future if needed. Javalin comes with shorthand API configuration methods for setting up endpoints, built-in JSON-to-classes conversion using Jackson, request validation and a simple way to handle errors. On the other hand, Undertow is in some ways leaner, but it lets you configure a lot of low level stuff. Similarly to Javalin, Undertow also comes with some built-in features like routing or different HTTP handlers, but actual HTTP request handling is not as simple as in Javalin because of a missing built-in JSON-to-classes conversion.
Generally, in both cases, implemented servers are fairly simple, and it took only a few lines to get a working GraphQL API with basic routing. For a web application, this would be a pretty easy win for Javalin because of all of those shorthands. But for evitaDB, which is a specialized database and not web application where HTTP APIs are not the main thing, we think that those shorthands could come short in future expansions of evitaDB or wouldn’t be even used. Another point against the Javalin in case of evitaDB is the lack of low level HTTP communication configuration. We currently don’t have any particular use for that, but we think that losing the ability could be unnecessarily limiting in the future. Thus, we chose the Undertow server.

Conclusion

Every single server, library or framework was successfully used to build a server with the example “echo” API. That means that, functionally, all of these solutions would be sufficient. But when we incorporated requirements for performance and embedding possibilities, possible servers, libraries and frameworks narrowed. For example, Spring Boot solutions required its own build Maven plugins and workflow, the Quarkus solution also required its own workflow and we couldn’t even make it work across multiple Maven modules, although it should be possible. Micronaut was probably the worst when it came to setting it up, because not only did it require its own code structure and custom Maven plugin, but also required its own Maven parent POM which is quite a problem in multi-module projects like evitaDB. This shortcoming could probably be overcome by investing more time to deeply understand the required dependencies, but we fear that if simple setup is that difficult, there may be other surprises that could arise in the future. Other solutions were embedded fairly easily. One exception was the Netty server. Implementation of a server on Netty required a lot of low level HTTP configuring (which for new users seems almost like gibberish). Another difficulty was the enormous amount of different HTTP request and response classes that are somehow combined to the final ones. But this is probably due to the universality of the whole Netty ecosystem.

All source codes and the whole test suite can be found in our Github repository. We appreciate any constructive feedback and also recommend you to experiment with the sources and draw appropriate conclusions yourself.