Spring MVC vs WebFlux Benchmarks
2025-Jul-12
For several years of web development, I never had a chance to use Spring. This time I decided to try it out and compare the performance of two different approaches: Spring MVC and Spring WebFlux. At first, I thought that the difference would be significant, but it turned out that the performance of both approaches is quite similar. Let's take a look at the results of the benchmarks.
1. Spring MVC
Spring MVC is a traditional web framework that uses the Servlet API to handle requests and responses.
It is a synchronous framework, meaning that each request is processed in a separate thread, blocking the thread until the response is ready.
But it doesn't mean CPU holds the waiting thread, CPU is free to handle other threads while the waiting thread is blocked.
So the Spring MVC makes a thread pool to handle requests. The default size of the thread pool is 200 threads, but it can be configured to a different size.
In this benchmark, I configured the thread pool size from 50 to 200 threads and measured the performance of the application under different loads.
2. Spring WebFlux
Spring WebFlux is a reactive web framework that uses the Reactor library to handle requests and responses.
It is an asynchronous framework, meaning that each request is processed in a non-blocking way, allowing the application to handle more requests with fewer threads.
As the event loop is well known, it uses a single thread to handle all requests.
The Spring WebFlux's default worker threads are four which can be configured to a different size.
In this benchmark, I configured the worker threads from one to four threads and measured the performance of the application under different loads.
3. Testing Environment
I used wrk to benchmark the performance of both frameworks.
Docker Compose
To get reliable results, I ran each frameworks with the same resources in a docker-compose like below:
services:
mvc:
build: ./mvc
container_name: spring_mvc
ports:
- "8081:8080"
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/benchmarkdb
- SPRING_DATASOURCE_USERNAME=benchmark
- SPRING_DATASOURCE_PASSWORD=benchmark
deploy:
resources:
limits:
cpus: "2.0"
memory: 1024M
webflux:
build: ./webflux
container_name: spring_webflux
ports:
- "8082:8080"
environment:
- SPRING_R2DBC_URL=r2dbc:postgresql://postgres:5432/benchmarkdb
- SPRING_R2DBC_USERNAME=benchmark
- SPRING_R2DBC_PASSWORD=benchmark
deploy:
resources:
limits:
cpus: "2.0"
memory: 1024M
postgres:
image: postgres:17
container_name: benchmark_postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: benchmarkdb
POSTGRES_USER: benchmark
POSTGRES_PASSWORD: benchmark
command: postgres -c max_connections=500
volumes:
- pgdata:/var/lib/postgresql/data
deploy:
resources:
limits:
cpus: "2.0"
memory: 1024M
volumes:
pgdata:
Constants
The wrk's loads were maintained by 10 seconds.
Variables
Main variables used in the benchmarks are:
- CPUs: Number of CPUs allocated to the service.
- Memory: Amount of memory allocated to the service.
- MVC Thread Pool Size: Number of threads in the Spring MVC thread pool.
- WebFlux Worker Threads: Number of worker threads in the Spring WebFlux event loop.
- Concurrency: Number of concurrent requests sent to the service by wrk.
Endpoints
There are three endpoints in the two frameworks used in the benchmarks:
- /static: Returns a static response with a fixed string.
- /dbtest: Returns a response from the database using a simple query.
- /dbtest/slow: Returns a response from the database using a slow query that takes at least 100ms to complete.
Spring WebFlux
@SpringBootApplication
@RestController
@RequestMapping("/api")
public class WebfluxApplication {
public static void main(String[] args) {
SpringApplication.run(WebfluxApplication.class, args);
}
@GetMapping("/static")
public Mono<String> staticResponse() {
return Mono.just("{\"message\":\"Hello from WebFlux\"}");
}
@Autowired
private DatabaseClient databaseClient;
public record NowResult(String now) {}
@GetMapping("/dbtest")
public Mono<String> dbTest() {
return databaseClient.sql("SELECT now() AS now")
.fetch()
.first()
.map(row -> {
return "{\"dbTime\":\"" + String.valueOf(row.get("now")) + "\"}";
});
}
@GetMapping("/dbtest/slow")
public Mono<String> dbTestSlow() {
return databaseClient.sql("SELECT now() AS now, pg_sleep(0.1)")
.fetch()
.first()
.map(row -> {
return "{\"dbTime\":\"" + String.valueOf(row.get("now")) + "\"}";
})
.onErrorReturn("{\"error\":\"Database error\"}");
}
}
Spring MVC
@SpringBootApplication
@RestController
@RequestMapping("/api")
public class MvcApplication {
public static void main(String[] args) {
SpringApplication.run(MvcApplication.class, args);
}
@GetMapping("/static")
public String staticResponse() {
return "{\"message\":\"Hello from MVC\"}";
}
@Autowired
private JdbcTemplate jdbcTemplate;
@GetMapping("/dbtest")
public String dbTest() {
String time = jdbcTemplate.queryForObject("SELECT now()", String.class);
return "{\"dbTime\":\"" + time + "\"}";
}
@GetMapping("/dbtest/slow")
public String dbTestWithLatency() throws InterruptedException {
String time = jdbcTemplate.queryForObject("SELECT now() FROM pg_sleep(0.1)", String.class);
return "{\"dbTimeWithLatency\":\"" + time + "\"}";
}
}
4. Results
I charted the results focusing on four metrics:
- throughput: Requests per second.
- transfer: Amount of data transferred per second.
- average latency: Average time taken to process a request.
- max latency: Maximum time taken to process a request.
Throughput
Below chart shows the throughput(requests/s) of both frameworks under different loads on 1CPU 512M. X-axis represents the number of concurrent requests sent to the service, and Y-axis represents the number of requests per second.
Transfer
Below chart shows the transfer(MB/s) of both frameworks under different loads on 1CPU 512M. X-axis represents the number of concurrent requests sent to the service, and Y-axis represents the amount of data transferred per second.
Average Latency
Below chart shows the average latency(ms) of both frameworks under different loads on 1CPU 512M. X-axis represents the number of concurrent requests sent to the service, and Y-axis represents the average time taken to process a request.
Max Latency
Below chart shows the maximum latency(ms) of both frameworks under different loads on 1CPU 512M. X-axis represents the number of concurrent requests sent to the service, and Y-axis represents the maximum time taken to process a request.
Insights
What I found from the results is that both frameworks have similar performance under different loads. The throughput, transfer, average latency, and max latency are quite similar for both frameworks. The main difference is that Spring MVC has a higher max latency than Spring WebFlux.
For better UX for every request, I would like to use Spring WebFlux. Because it guarantees that every request will be processed within a certain time, while Spring MVC may have some requests that take longer to process.
In the resource view, Spring WebFlux is more efficient than Spring MVC. Because it uses fewer threads(2 against 100) to handle the same number of requests, it consumes less memory.
Total
Below charts show the total throughput, transfer, average latency, and max latency of both frameworks in order under different loads on different computing resources.



Conclusion
The results of the benchmarks show what I never expected. The primary reason I thought Spring WebFlux would outperform Spring MVC was that it's a reactive framework that uses non-blocking I/O. In the process of the benchmarks, I realized that the Spring MVC is using a thread pool which allows it to handle heavy concurrent requests with a limitation.
At the last, I benchmarked Django and Next.JS with the same endpoints and resources. And the results were shocking.
Spring frameworks outperformed Django and Next.JS in every metric with a significant margin.
Given more time, I would like to benchmark Spring and Go frameworks in the same way.