iTranslated by AI
How Spring MVC Implements Suspend Functions
When using Kotlin with Spring MVC, you can add suspend to Controller functions as shown below.
@RestController
class UserController(private val userService: UserService) {
@GetMapping("/users/{id}")
suspend fun getUser(@PathVariable id: Long): User {
return userService.findById(id)
}
}
While using Spring WebFlux is more appropriate to fully leverage non-blocking processing, Spring MVC also supports suspend functions, allowing asynchronous processing to be written more simply.
In this post, we will explore how Spring MVC’s suspend function is implemented under the hood.
Background Recap
Before diving into the internal implementation, let’s briefly recap how Spring MVC works. Understanding Spring MVC’s internal mechanism helps in grasping the implementation of suspend functions in Spring MVC.

Servlet and Spring MVC
A Servlet is a Java component implemented according to the Servlet API, a standard specification for handling HTTP requests and responses. Servlets are executed and managed within a Servlet Container such as Tomcat, Jetty, or Undertow.
When a Servlet Container receives an HTTP request from a client, it passes HttpServletRequest and HttpServletResponse to the corresponding Servlet to process the request and response. Servlets can generate dynamic web pages like HTML or process data like REST APIs. On the other hand, the Servlet API operates at a low level, so directly implementing Servlets often leads to complex code.
Spring MVC is a web framework provided by the Spring Framework. It uses DispatcherServlet (provided by Spring MVC) as the Servlet, while offering high-level mechanisms like Controllers and annotation-based routing on top of it.
When DispatcherServlet receives a request, it follows Spring MVC’s mechanism to call the appropriate Controller and returns the processing result as an HTTP response. Therefore, instead of directly implementing Servlets, developers can concisely build web applications using @RestController and @GetMapping.
// When directly implementing a Servlet
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
String id = req.getParameter("id");
User user = service.getUser(id);
resp.getWriter().write(user.toJson());
...
}
// When using Spring MVC
@GetMapping("/users/{id}")
User getUser(@PathVariable String id) {
return service.getUser(id);
}
In Spring MVC, one worker thread from the Servlet Container is typically assigned to each HTTP request. The assigned thread executes the DispatcherServlet and Controller processing. For example, by default, Spring Boot uses Tomcat as the Servlet Container, so Tomcat manages the thread pool for request processing.
This one-request-per-thread model is easy to understand and implement, but it has the issue that threads are occupied even while waiting for I/O. As the number of concurrent connections increases, thread exhaustion and overhead from context switching among many threads can occur.
Asynchronous Servlet
In the traditional synchronous Servlet/Spring MVC model, a worker thread from the Servlet Container is occupied until a single request processing completes. Therefore, for processes like SSE (Server-Sent Events) or Long Polling that keep the response open for a long time, threads tend to be occupied for extended periods.
To address this issue, the Servlet API introduced asynchronous processing support with Servlet 3.0, adding AsyncContext. AsyncContext is not a Servlet itself but a context for asynchronous processing obtained via ServletRequest.startAsync(). Using AsyncContext, request processing can be suspended to release the worker thread, allowing another thread to continue the asynchronous processing. Once the asynchronous processing completes, AsyncContext.complete() is called to finalize the response.
// Example of asynchronous processing using AsyncContext
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
// Even if doGet() returns, the response is not completed; subsequent processing continues via AsyncContext
// The worker thread is returned to the thread pool after doGet() returns
AsyncContext ctx = req.startAsync();
// Submit a Runnable to AsyncContext
ctx.start(() -> {
// The processing in this block runs asynchronously on another thread managed by the Servlet container
String result = callExternalApi();
try {
resp.getWriter().write(result);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
ctx.complete(); // Complete the response
}
});
}
}
In Spring MVC, DispatcherServlet utilizes this asynchronous processing mechanism of the Servlet API to handle return values such as Callable, DeferredResult, SseEmitter, and ResponseBodyEmitter as asynchronous request processing.
- Example with
DeferredResult
@GetMapping("/async")
public DeferredResult<String> async() {
DeferredResult<String> deferredResult = new DeferredResult<>();
// Execute asynchronous processing in another thread
taskExecutor.execute(() -> {
String result = callExternalApi();
deferredResult.setResult(result);
});
return deferredResult;
}
The flow when returning a DeferredResult is illustrated below. The Controller submits the asynchronous processing to another thread, immediately returns the DeferredResult, and the worker thread is temporarily released. Later, when setResult() is called on the other thread, an async dispatch (second dispatch) occurs, and a re-assigned worker thread writes the response.
This made it possible to release threads during I/O waits, but the callback-based code tended to become complex.
The Rise of Reactive Programming
As systems involving external API integrations, high concurrency, streaming, and event-driven architectures became more common, Reactive Programming gained attention as a declarative way to write asynchronous processing, compared to callbacks.
Reactive Programming is a programming paradigm centered around data streams and the propagation of change.
In the Java ecosystem, Project Reactor is a prominent implementation. Reactor provides the main types Mono and Flux, representing data flow with Publisher.
- Key types of Project Reactor
| Type | Meaning |
|---|---|
Mono<T> |
Publisher that emits 0 or 1 value |
Flux<T> |
Publisher that emits 0 or more values |
// Mono
// Publisher: Define the processing
Mono<String> monoPublisher =
Mono.just("Alice")
.map(String::toUpperCase)
.filter(name -> name.startsWith("A"));
// Subscriber: Receive the value
monoPublisher.subscribe(System.out::println); // ALICE
// Flux
// Publisher: Define the processing
Flux<String> fluxPublisher =
Flux.just("Alice", "Bob", "Charlie")
.filter(name -> name.length() >= 5)
.map(String::toUpperCase);
// Subscriber: Receive the values
fluxPublisher.subscribe(System.out::println); // ALICE, CHARLIE
One characteristic of Mono/Flux is lazy evaluation. Therefore, the Publisher and Operators are not executed until a terminal operation like subscribe() is called.
Both implement the Publisher<T> interface (Reactive Streams specification). Reactive Streams is a specification for data exchange between Publishers and Subscribers. (Incidentally, the Subscriber also includes a backpressure mechanism to request the required amount of data via Subscription.request(n).)
public interface Publisher<T> {
void subscribe(Subscriber<? super T> subscriber);
}
Spring Framework 5.0 added support for handling Reactive Streams-based types as return values in Spring MVC. This allows Controller methods to directly return Mono, Flux, RxJava, and other Reactive API return values. Internally, Mono/Flux are converted to a Publisher, and the subscribed results are passed to the Servlet API’s asynchronous processing mechanism (described later).
// Spring MVC controllers can return Mono/Flux
@RestController
class UserController {
// Get a single resource: return Mono<T>
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable Long id) {
// Can directly return reactive return values
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User.class);
}
// Get multiple resources: return Flux<T>
@GetMapping("/users")
public Flux<User> getUsers() {
// Can directly return multiple reactive return values
return webClient.get()
.uri("/users")
.retrieve()
.bodyToFlux(User.class);
}
}
Also in Spring Framework 5.0, Spring WebFlux was introduced as a new web stack based on Reactive Streams and Project Reactor. This allowed directly handling asynchronous streams while maintaining a coding style similar to traditional Spring MVC controllers.
Note that Spring MVC and Spring WebFlux are both based on the Spring Framework, but their underlying asynchronous processing infrastructure is fundamentally different.
| Aspect | Spring MVC | Spring WebFlux |
|---|---|---|
| Primary Foundation | Servlet API | Reactive Streams |
| Execution Model | Blocking servlet + request-per-thread | Non-blocking + event-loop |
| Main Servers | Tomcat, Jetty, etc. | Netty, Tomcat, Jetty, etc. |
| Controller Return Types |
Object, ResponseEntity, Callable, DeferredResult, CompletableFuture, Mono, Flux, etc. |
Mono, Flux, etc. |
| Database Access | JDBC/JPA common | R2DBC / reactive drivers common |
| Programming Model | Imperative | Reactive |
Addition of Kotlin Coroutine Support
Meanwhile, as Kotlin adoption for server-side development progressed, Spring Framework 5.2 added Kotlin suspend function support to Spring WebFlux, and Spring Framework 5.3 added it to Spring MVC.
With Kotlin Coroutines, asynchronous processing can be written in a synchronous-like coding style. Spring MVC adopts an approach that uses kotlinx-coroutines-reactor to convert suspend functions into Reactor’s Mono and integrates them with the Servlet API’s asynchronous processing mechanism.
Since both Spring MVC and Spring WebFlux allow returning Mono and Flux directly from Controller methods, Kotlin Coroutines can be used with either web stack.
The setup required is very simple, just two steps:
- Add Dependencies
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
}
- Add
suspendto the Controller
@RestController
class UserController(private val userService: UserService) {
@GetMapping("/users/{id}")
// Example of suspend function
suspend fun getUser(@PathVariable id: Long): User {
return userService.findById(id) // suspend function
}
}
Official documentation: https://docs.spring.io/spring-framework/reference/languages/kotlin/coroutines.html
How Spring MVC + Suspend Controller Works
The key is the kotlinx-coroutines-reactor library.
kotlinx-coroutines-reactor acts as a bridge between Kotlin Coroutines and Reactor. By using the mono() function provided by this library, a suspend Controller can be converted into a Reactor Mono.
import kotlinx.coroutines.reactor.mono
// Conversion from suspend function to Mono
val mono: Mono<User> = mono {
suspendController(1) // Call the suspend function Controller
}
// Suspend function Controller
suspend fun suspendController(id: Int): User {
// Processing of the suspend function
...
}
This conversion capability allows Kotlin's suspend functions to integrate with the Servlet API's asynchronous processing mechanism. Here, we will focus on the following two points to trace how Spring MVC internally handles suspend functions:
- Converting a suspend Controller to a Mono
- Spring MVC receives the Publisher and processes it asynchronously
Part 1: Converting a suspend Controller to a Mono
The core processing when Spring calls a suspend function is CoroutinesUtils.invokeSuspendingFunction. This function converts the suspend function into a Mono using MonoKt.mono() from kotlinx-coroutines-reactor, in order to return a reactive Publisher<?>.
Let's examine what this method does step by step.
- Obtain function information via Kotlin reflection
It retrieves Kotlin function metadata (KFunction) from the Java Method object. This allows handling Kotlin-specific information such as parameter type information and whether it is an inline class.
- Convert suspend function to Mono using
MonoKt.mono()
MonoKt.mono() is a function provided by kotlinx-coroutines-reactor. It takes a suspend function type as the second argument (block) and converts its processing into a Mono. Since CoroutinesUtils.java is Java code, a suspend lambda ((scope, continuation) -> { ... }) is passed. If the processing is suspended, it returns COROUTINE_SUSPENDED, and when resumed, Continuation.resumeWith() is called – following the normal Coroutine flow. (For the mechanism of Continuation, refer to this article.)
Inside the block, it does various things such as extracting necessary parameters, but ultimately calls the Controller function via reflection using KCallables.callSuspendBy().
- Branch conversion based on the return type
After creating a Mono, it branches the conversion based on the return type. It checks the declared return type of the suspend function via KFunction#getReturnType(). If the return type is Flow or Publisher-based, it further flattens the created Mono and returns it to the caller. The final return type is Publisher<?>.
| Return Type | Conversion Method |
|---|---|
Flow<T> |
Convert to Flux via flatMapMany()
|
Mono<T> |
Flatten nested Mono via flatMap()
|
Publisher<T> |
Streamify via flatMapMany()
|
| Others | Return as Mono<T> as is |
Part 2: Spring MVC receives the Publisher and processes it asynchronously
The return value converted to a Publisher is processed in the same flow as when Mono/Flux are returned in Spring MVC. Internally, Spring MVC delegates the return values of Mono/Flux to ReactiveTypeHandler. ReactiveTypeHandler subscribes to them using ReactiveAdapterRegistry and passes the result to the Servlet API's asynchronous processing mechanism.
In ReactiveTypeHandler.handleValue(), the processing branches depending on whether the return value is streaming or not.
For a suspend function like suspend fun getUser(id: Long): User that returns a normal value, it is converted to a Mono, so it goes through the non-streaming path. It creates a DeferredResult, subscribes to the Publisher with DeferredResultSubscriber, and then calls WebAsyncManager.startDeferredResultProcessing() to start the Servlet API's asynchronous processing.
Looking at the implementation of DeferredResultSubscriber, it requests Long.MAX_VALUE in onSubscribe() to receive all elements, accumulates values in onNext(), and sets the result to DeferredResult at the timing of onError() / onComplete(). When the result is set to DeferredResult, an async dispatch occurs, and a worker thread is re-assigned to write the response. In other words, the flow is: completion of the suspend function → completion signal of Mono → DeferredResult.setResult() → async dispatch. This shows how coroutines are integrated with the Servlet API's asynchronous processing mechanism.
However, thread behavior requires attention.
By default, Spring uses Dispatchers.Unconfined, so the coroutine runs on the thread that executed the subscribe (Tomcat's worker thread) until the first suspension point. That is, if you write a blocking process that never suspends, it will continue to occupy the worker thread until completion.
If the process suspends, COROUTINE_SUSPENDED is returned at that point, the request transitions to async mode, and the worker thread is released. After that, the processing resumes on the thread used to resume the suspended process. Dispatchers.Unconfined does not specify a thread for resumption, so which thread resumes it depends on the implementation of the called suspend function. For example, if calling WebClient, it resumes on the Reactor Netty event loop thread; if using delay(), it resumes on the DefaultExecutor thread inside kotlinx-coroutines (if withContext(Dispatchers.IO) etc. is explicitly used, it resumes on that Dispatcher's thread).
Even for a suspend Controller that never actually suspends, async dispatch still occurs, but there is no benefit of thread release. In fact, the overhead of using the async mechanism may worsen performance for lightweight processing.
Notes (Points to be aware of)
Avoid using blocking I/O libraries
For example, RestTemplate is a blocking HTTP client. If you call RestTemplate within a suspend function, the thread will remain blocked during the call.
// NG: RestTemplate is blocking
suspend fun callApi(): String {
return restTemplate.getForObject("https://example.com", String::class.java)!!
// ↑ This blocks the thread
}
// OK: Use WebClient's await extension function
suspend fun callApi(): String {
return webClient.get()
.uri("https://example.com")
.awaitBody<String>()
}
Values dependent on ThreadLocal are not guaranteed across suspension points
Servlet-based Spring MVC has many mechanisms that rely on ThreadLocal, such as SecurityContextHolder (Spring Security) and logging MDC. As mentioned earlier, a coroutine may resume on a different thread after a suspension point, so these ThreadLocal values may not be visible in the resumed processing.
suspend fun process() {
// OK: Executed on Tomcat's worker thread, so ThreadLocal values are visible
val before = SecurityContextHolder.getContext().authentication
delay(100) // a suspending operation
// NG: Processing after suspension may resume on a different thread, so ThreadLocal values are not visible
val after = SecurityContextHolder.getContext().authentication
}
@Transactional does not behave as expected in suspend functions (JDBC/JPA environment)
In a standard Spring MVC environment using PlatformTransactionManager (JDBC/JPA based), adding @Transactional to a suspend function does not work as expected.
TransactionInterceptor treats the suspend function as a regular method. After compilation, a suspend function immediately returns COROUTINE_SUSPENDED upon reaching the first suspension point, so the interceptor considers the method to have completed successfully and commits the transaction at that point. In other words, the transaction is not maintained across suspension points. Additionally, PlatformTransactionManager associates the transaction with the thread-local context, so if the coroutine resumes on a different thread, the transaction is not visible to the subsequent processing.
// NG: @Transactional cannot span suspensions in PlatformTransactionManager environments
@Transactional
suspend fun updateUser(id: Long, name: String) {
val externalUser = callSuspendFunctionToGetUser(id) // ← Suspends here, and when COROUTINE_SUSPENDED is returned, the interceptor commits the transaction
val user = userRepository.findById(id) // After resuming, it may be on a different thread, and the ThreadLocal transaction information is not visible
userRepository.save(user.copy(name = name, externalUserName = externalUser.name))
}
As a countermeasure, do not put @Transactional on the suspend function itself; instead, create a non-suspend wrapper like TransactionRunner that explicitly defines the transaction boundary.
@Component
class TransactionRunner {
@Transactional(readOnly = true)
fun <T> readOnly(block: () -> T): T = block()
@Transactional
fun <T> readWrite(block: () -> T): T = block()
}
// Do not put @Transactional on the suspend function itself
suspend fun updateUser(id: Long, name: String) {
val externalUser = callSuspendFunctionToGetUser(id) // Perform suspending operations outside the transaction
withContext(Dispatchers.IO) { // For blocking I/O, execute on Dispatchers.IO which has more threads available
transactionRunner.readWrite {
// Inside this block, execution is synchronous on the same thread, so the transaction works normally
// (Do not call suspend functions inside this block)
val user = userRepository.findById(id)
userRepository.save(user.copy(name = name, externalUserName = externalUser.name))
}
}
}
On the other hand, in a ReactiveTransactionManager (R2DBC) environment, @Transactional on suspend functions is officially supported and works without issues.
Cautions for operations in OncePerRequestFilter
When using OncePerRequestFilter to output access logs, for a suspend function controller, it is only called once at the time of the request.
class AccessLogFilter : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
try {
filterChain.doFilter(request, response)
} finally {
log.info("access log: ${request.method} ${request.requestURI} ${response.status}")
}
}
}
In the above example, ${response.status} is evaluated before the suspend function controller completes (right after async processing starts), so the output will be an indeterminate status at that point (usually 200), not the final status code.
To obtain the status code after the suspend function controller has completed, override shouldNotFilterAsyncDispatch() to also pass the filter on the asynchronous dispatch (second dispatch). Then, output the log at the timing when processing is complete, not the first dispatch when async processing started. You can use isAsyncStarted() for this determination.
class AccessLogFilter : OncePerRequestFilter() {
// Allow the filter to pass even on async dispatch (second dispatch)
override fun shouldNotFilterAsyncDispatch(): Boolean = false
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
try {
filterChain.doFilter(request, response)
} finally {
// Skip immediately after async processing starts (first dispatch),
// and only output the log after async dispatch completion or when a synchronous request completes
if (!isAsyncStarted(request)) {
log.info("access log: ${request.method} ${request.requestURI} ${response.status}")
}
}
}
}
asyncDispatch is required in tests
For example, when using MockMvc in integration tests, the response of a suspend function comes back asynchronously, so you need to use asyncDispatch to receive the response.
// NG: Assertions are executed before the asynchronous processing completes
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk)
// OK: Wait until asynchronous processing completes
val mvcResult = mockMvc.perform(get("/users/1"))
.andExpect(request().asyncStarted())
.andReturn()
mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk)
.andExpect(jsonPath("$.id").value(1))
Exceptions propagate as Mono.error()
When an exception is thrown inside a suspend function, the mono {} block catches it and converts it to Mono.error(e). Since Spring MVC subscribes to this, you can handle it with a normal @ExceptionHandler or @ControllerAdvice.
@RestController
class UserController(private val userService: UserService) {
@GetMapping("/users/{id}")
suspend fun getUser(@PathVariable id: Long): User {
return userService.findById(id) // May throw NotFoundException
}
}
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException::class)
fun handleNotFound(e: NotFoundException): ResponseEntity<ErrorResponse> {
// Exceptions from suspend functions can be caught here as usual
return ResponseEntity.status(404).body(ErrorResponse(e.message))
}
}
However, the handling of coroutine cancellation exceptions (CancellationException) on Mono differs depending on the origin of the cancellation.
- If the coroutine terminates with a cancellation exception (
CancellationException) while the Mono is being subscribed to, it propagates as a Mono error, similar to normal exceptions. - If the subscriber side, i.e., Spring MVC, cancels the subscription and thereby cancels the coroutine,
onErrororonCompleteare not notified. (Because the subscriber side is no longer waiting for the result and considers it unnecessary.)
Since the latter CancellationException is not handled by @ExceptionHandler, you must properly perform necessary cleanup, such as using try-finally, which must be executed even upon cancellation.
Summary
Support for suspend functions in Spring MVC is realized by converting suspend functions into Mono and integrating them with Spring's asynchronous processing mechanism.
If you aim for fully non-blocking processing, WebFlux is more suitable. However, migrating to WebFlux requires large-scale changes, such as changing the execution infrastructure and switching database access drivers to R2DBC. On the other hand, introducing suspend functions into existing Spring MVC applications might be a good first step toward asynchronous processing.
Discussion