Spring extends the concept of intercepting requests when it comes to asynchronous processing.
Instead of using HandlerInterceptor, the interface AsyncHandlerInterceptor implementations can be used to intercept request involving async processing.
AsyncHandlerInterceptor defines one method
void afterConcurrentHandlingStarted(HttpServletRequest request,
HttpServletResponse response,
Object handler)
throws Exception
AsyncHandlerInterceptor is a sub-interface of HandlerInterceptor.
Example
Creating the interceptor
package com.logicbig.example;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.AsyncHandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import java.time.LocalTime;
public class MyAsyncHandlerInterceptor
implements AsyncHandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
log("interceptor#preHandle", request);
return true;
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
log("interceptor#postHandle", request);
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
log("interceptor#afterCompletion", request);
}
@Override
public void afterConcurrentHandlingStarted(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
log("interceptor#afterConcurrentHandlingStarted", request);
}
private static void log(String stage,
HttpServletRequest request) {
System.out.printf("%s %s called. "
+ "Thread: %s, "
+ "DispatcherType: %s%n",
LocalTime.now(),
stage,
Thread.currentThread().getName(),
request.getDispatcherType());
}
}
Creating the controller
package com.logicbig.example;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.time.LocalTime;
import java.util.concurrent.Callable;
@Controller
public class MyController {
@RequestMapping("/test")
@ResponseBody
public Callable<String> handleTestRequest() {
log("controller#handler called");
Callable<String> callable = new Callable<String>() {
@Override
public String call() throws Exception {
log("controller-callable#async task started");
Thread.sleep(300);
log("controller-callable#async task finished");
return "async result";
}
};
log("controller#handler finished");
return callable;
}
private static void log(String msg) {
System.out.println(
LocalTime.now() +
" MyController [" +
Thread.currentThread().getName() + "] "
+ msg);
}
}
Config class
package com.logicbig.example;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
@EnableWebMvc
@ComponentScan
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new MyAsyncHandlerInterceptor());
}
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setThreadNamePrefix("mvc-async-");
executor.initialize();
configurer.setTaskExecutor(executor);
}
}
Running the application
To try examples, run embedded Jetty (configured in pom.xml of example project below):
mvn jetty:run
Access the application on the browser, we will have this output:
On server console we have this output related to our request processing:
The main request thread should not be blocked for a long time. It should exit immediately after starting a new thread in the handler method. The new thread will perform some application specific long running task.
The method AsyncHandlerInterceptor#afterConcurrentHandlingStarted is called after a new thread for async process (Callable) has been created. This interceptor call is made in the same request thread.
afterConcurrentHandlingStarted invocation is a chance for developer to perform tasks such as cleaning up thread-bound attributes before releasing the thread to the Servlet container.
Just after completing user defined async processing, new series of calls of preHandler(), postHandler() and afterCompletion() are made in a new thread but on the same instance of the interceptor. Since this interception happens during the final response, the developer can apply application logic like login etc as we saw in the HandlerInterceptor tutorial.
To understand what's going on, here is the flow diagram:
Note that the method afterConcurrentHandlingStarted might get called more or less same time when async processing starts in the new thread. This call might be before or after the async processing starts, that's due to thread scheduling differences, but it shouldn't effect our logic which we are supposed to apply in afterConcurrentHandlingStarted.
Within interceptor, to distinguish between the initial request and the subsequent dispatch after asynchronous handling completes, interceptors can check whether the javax.servlet.DispatcherType returns from ServletRequest#getDispatcherType() call is "REQUEST" or "ASYNC".
Also note that we used Callable approach for long running task in the above example. We can also use DeferredResult approach as well with the same interceptor. Please see examples here
Integration Test
package com.logicbig.example;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitWebConfig(WebConfig.class)
public class ControllerTest {
@Autowired
private WebApplicationContext wac;
private MockMvcTester mockMvcTester;
@BeforeEach
public void setup() {
this.mockMvcTester = MockMvcTester.from(this.wac);
}
@Test
public void testController() {
assertThat(mockMvcTester.get()
.uri("/test"))
.hasStatusOk()
.hasBodyTextEqualTo("async result");
}
}
mvn clean test -Dtest="ControllerTest" Output$ mvn clean test -Dtest="ControllerTest" [INFO] Scanning for projects... [INFO] [INFO] -------< com.logicbig.example:async-handler-interceptor-example >------- [INFO] Building async-handler-interceptor-example 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ war ]--------------------------------- [INFO] [INFO] --- clean:3.2.0:clean (default-clean) @ async-handler-interceptor-example --- [INFO] Deleting D:\example-projects\spring-mvc\async-handler-interceptor-example\target [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ async-handler-interceptor-example --- [WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent! [INFO] skip non existing resourceDirectory D:\example-projects\spring-mvc\async-handler-interceptor-example\src\main\resources [INFO] [INFO] --- compiler:3.15.0:compile (default-compile) @ async-handler-interceptor-example --- [INFO] Recompiling the module because of changed source code. [INFO] Compiling 4 source files with javac [debug target 25] to target\classes [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ async-handler-interceptor-example --- [WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent! [INFO] skip non existing resourceDirectory D:\example-projects\spring-mvc\async-handler-interceptor-example\src\test\resources [INFO] [INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ async-handler-interceptor-example --- [INFO] Recompiling the module because of changed dependency. [INFO] Compiling 2 source files with javac [debug target 25] to target\test-classes [INFO] [INFO] --- surefire:3.2.5:test (default-test) @ async-handler-interceptor-example --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [WARNING] file.encoding cannot be set as system property, use <argLine>-Dfile.encoding=...</argLine> instead [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running com.logicbig.example.ControllerTest 20:11:33.291296600 interceptor#preHandle called. Thread: main, DispatcherType: REQUEST 20:11:33.299311700 MyController [main] controller#handler called 20:11:33.300294400 MyController [main] controller#handler finished 20:11:33.304296200 interceptor#afterConcurrentHandlingStarted called. Thread: main, DispatcherType: REQUEST 20:11:33.304296200 MyController [mvc-async-1] controller-callable#async task started 20:11:33.605741500 MyController [mvc-async-1] controller-callable#async task finished 20:11:33.607669100 interceptor#preHandle called. Thread: main, DispatcherType: ASYNC 20:11:33.616656100 interceptor#postHandle called. Thread: main, DispatcherType: ASYNC 20:11:33.617749300 interceptor#afterCompletion called. Thread: main, DispatcherType: ASYNC [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.194 s -- in com.logicbig.example.ControllerTest [INFO] [INFO] Results: [INFO] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 5.134 s [INFO] Finished at: 2026-08-16T20:11:33+08:00 [INFO] ------------------------------------------------------------------------ INFO: Completed initialization in 1 ms
Note: When testing with MockMvc, the async-completion dispatch is only reported with DispatcherType.ASYNC starting from spring-test 4.2.3.RELEASE. Earlier versions of MockMvcRequestBuilders.asyncDispatch() did not call request.setDispatcherType(DispatcherType.ASYNC), so interceptors and filters would still see DispatcherType.REQUEST on that second dispatch — even though a real servlet container correctly reports ASYNC.
Example ProjectDependencies and Technologies Used: - spring-webmvc 7.0.6 (Spring Web MVC)
Version Compatibility: 3.2.9.RELEASE - 7.0.6 Version compatibilities of spring-webmvc with this example: Versions in green have been tested.
- jakarta.servlet-api 6.1.0 (Jakarta Servlet API documentation)
- spring-test 7.0.6 (Spring TestContext Framework)
- junit-jupiter-engine 6.0.3 (Module "junit-jupiter-engine" of JUnit)
- hamcrest 3.0 (Core API and libraries of hamcrest matcher framework)
- assertj-core 3.26.3 (Rich and fluent assertions for testing in Java)
- JDK 25
- Maven 3.9.11
|