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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
5 Advanced Techniques to Preserve Context in Spring Boot Async Calls

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

InheritableThreadLocal

allows 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootAsyncInheritableThreadLocalSpring SecurityMDCThreadContextTaskDecorator
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.