5 Advanced Techniques to Preserve Context in Spring Boot Async Calls
In Spring Boot 3.5.0, asynchronous threads lose MDC, security, and request data because ThreadLocal is not shared; the article demonstrates five concrete solutions—including InheritableThreadLocal, thread joining, custom TaskDecorator, Spring Security delegating wrappers, and MDC propagation—each with code samples, pitfalls, and best‑practice warnings.
1. Introduction
In micro‑service and high‑concurrency architectures, Spring Boot applications often use asynchronous threads (e.g., @Async, WebFlux, thread pools) to increase throughput. However, the asynchronous model isolates the child thread from the parent, so MDC logs, security credentials, request‑level metadata, etc., are not automatically propagated. The article enumerates five typical context‑propagation problems and shows how to solve them.
2. Practical Cases
2.1 Thread‑local request context loss
Example Service creates a new Thread and reads a request parameter via RequestContextHolder. When /query is called, the new thread prints id = … but RequestContextHolder returns null because it stores data in a ThreadLocal that is not shared across threads.
Reason: RequestContextHolder uses ThreadLocal; new thread cannot see the parent’s data.
2.2 Using InheritableThreadLocal
InheritableThreadLocalallows child threads to inherit values from the parent. By configuring DispatcherServlet with setThreadContextInheritable(true), the request attributes become inheritable.
@Configuration
public class DispatcherServletConfig {
@Bean(name = "dispatcherServlet")
DispatcherServlet dispatcherServlet(WebMvcProperties webMvcProperties) {
DispatcherServlet dispatcherServlet = new DispatcherServlet();
// ...
// Enable inheritable ThreadContext for LocaleContext and RequestAttributes
dispatcherServlet.setThreadContextInheritable(true);
return dispatcherServlet;
}
}After the change, the same /query call still throws IllegalStateException because the HttpServletRequest is already destroyed when the child thread accesses it.
Solution: make the parent thread wait for the child thread to finish using t.join(). This prevents the request object from being reclaimed, but it blocks the parent thread and reduces concurrency.
public void process() {
Thread t = new Thread(() -> {
// ...
});
t.start();
try {
t.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}2.3 @Async task context propagation
When using @Async, the same loss occurs. The article defines a static ThreadLocal<String> datas, sets it in the controller, and calls an @Async method that prints the value. Without extra handling the child thread prints null.
public static final ThreadLocal<String> datas = ThreadLocal.withInitial(() -> null);
@Async
public void task() {
System.err.printf("Thread context data: %s%n", datas.get());
}
@GetMapping("/query")
public ResponseEntity<?> query(HttpServletRequest request) {
ThreadContextService.datas.set(request.getParameter("id"));
contextService.task();
return ResponseEntity.ok("xxxooo");
}Solution: define a custom ThreadPoolTaskExecutor with a TaskDecorator that copies the ThreadLocal value into the child thread before execution and clears it afterwards.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
ThreadPoolTaskExecutor taskExecutor(TaskDecorator taskDecorator) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(10);
executor.setThreadNamePrefix("async-");
executor.setTaskDecorator(taskDecorator);
return executor;
}
@Bean
TaskDecorator taskDecorator() {
return new TaskDecorator() {
@Override
public Runnable decorate(Runnable runnable) {
String data = ThreadContextService.datas.get();
return () -> {
try {
ThreadContextService.datas.set(data);
runnable.run();
} finally {
ThreadContextService.datas.remove();
}
};
}
};
}
}Running the endpoint prints async-1 - thread context data: 666.
2.4 Spring Security security context propagation
SecurityContext is also stored per thread. In a new thread it can be accessed via DelegatingSecurityContextRunnable, which copies the authentication information.
@GetMapping("/user")
public ResponseEntity<?> user() throws Exception {
Runnable action = () -> {
System.err.printf("%s - name = %s%n",
Thread.currentThread().getName(),
SecurityContextHolder.getContext().getAuthentication().getName());
};
DelegatingSecurityContextRunnable contextRunnable = new DelegatingSecurityContextRunnable(action);
Thread t = new Thread(contextRunnable);
t.start();
return ResponseEntity.ok("success");
}The child thread can correctly read the logged‑in user name.
For thread‑pool scenarios, Spring Security provides DelegatingSecurityContextExecutor:
private final SimpleAsyncTaskExecutor delegateExecutor = new SimpleAsyncTaskExecutor();
private final DelegatingSecurityContextExecutor executor =
new DelegatingSecurityContextExecutor(delegateExecutor);
@GetMapping("/executor")
public ResponseEntity<?> executor() {
executor.execute(() -> {
System.err.printf("%s - name = %s%n",
Thread.currentThread().getName(),
SecurityContextHolder.getContext().getAuthentication().getName());
});
return ResponseEntity.ok("success");
}2.5 MDC (Mapped Diagnostic Context) usage
MDC lets you attach contextual data to log entries. The article shows a Logback configuration that defines a traceXId key and a pattern that prints the trace ID, remote host, request URI, etc.
<configuration scan="true">
<contextName>trace-mdc</contextName>
<property name="TRACEX_PATTERN"
value="%green(%d{HH:mm:ss}) traceId:【%red(%X{traceXId})】%X{req.remoteHost} %X{req.requestURI} %highlight(%-5level) [%yellow(%thread)] %logger Line:%-3L - %msg%n"/>
<appender name="TRACEX" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${TRACEX_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<springProfile name="dev | default">
<root level="INFO">
<appender-ref ref="TRACEX"/>
</root>
</springProfile>
</configuration>A custom servlet filter extracts a x-trace header (or generates a UUID), stores it in MDC, logs the request path, and finally removes the key.
@Component
public class TraceXFilter implements Filter {
public static final String TRACE_KEY = "traceXId";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String traceId = req.getHeader("x-trace");
if (!StringUtils.hasLength(traceId)) {
traceId = UUID.randomUUID().toString().replace("-", "").toUpperCase();
}
MDC.put(TRACE_KEY, traceId);
logger.info("Request: {}", req.getServletPath());
try {
chain.doFilter(request, response);
} finally {
MDC.remove(TRACE_KEY);
}
}
}When using a thread pool, a TaskDecorator can copy the MDC map to the worker thread:
public class PackTraceIdTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap);
} else {
MDC.clear();
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}All five techniques together enable reliable context propagation across asynchronous boundaries in Spring Boot 3.5.0.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
