Java Spring
Logging
Trace ID
Async Thread
Multithreading

How do I log trace id in Java Spring Async thread

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In Spring applications, trace IDs are often stored in a thread-local logging context, so asynchronous execution can lose them unless you propagate context explicitly. This becomes a major debugging problem when @Async methods write logs without the request trace ID. A reliable setup copies diagnostic context from the caller thread into worker threads and clears it after execution.

Why Trace Context Is Lost with @Async

@Async runs methods on a thread pool. Logging frameworks usually keep context in MDC, which is thread-local. When work moves to another thread, the context map is empty unless you copy it.

java
1@Slf4j
2@Service
3public class ReportService {
4
5    @Async("appExecutor")
6    public CompletableFuture<Void> buildReport(String reportId) {
7        log.info("building report {}", reportId);
8        // long running work
9        return CompletableFuture.completedFuture(null);
10    }
11}

Without propagation, the log line above may miss traceId, even when the caller thread had one.

Propagate MDC with TaskDecorator

Spring ThreadPoolTaskExecutor supports TaskDecorator, which is a clean way to capture and restore MDC.

java
1@Configuration
2@EnableAsync
3public class AsyncConfig {
4
5    @Bean(name = "appExecutor")
6    public Executor appExecutor() {
7        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
8        executor.setCorePoolSize(8);
9        executor.setMaxPoolSize(16);
10        executor.setQueueCapacity(200);
11        executor.setThreadNamePrefix("app-async-");
12        executor.setTaskDecorator(new MdcTaskDecorator());
13        executor.initialize();
14        return executor;
15    }
16}
java
1public class MdcTaskDecorator implements TaskDecorator {
2    @Override
3    public Runnable decorate(Runnable runnable) {
4        Map<String, String> contextMap = MDC.getCopyOfContextMap();
5        return () -> {
6            if (contextMap != null) {
7                MDC.setContextMap(contextMap);
8            }
9            try {
10                runnable.run();
11            } finally {
12                MDC.clear();
13            }
14        };
15    }
16}

This pattern prevents context leaks between tasks and ensures each async task sees the caller context.

Ensure Incoming Requests Populate Trace ID

You still need to initialize MDC at request entry. A servlet filter is a common place.

java
1@Component
2public class TraceIdFilter extends OncePerRequestFilter {
3
4    @Override
5    protected void doFilterInternal(
6            HttpServletRequest request,
7            HttpServletResponse response,
8            FilterChain filterChain) throws ServletException, IOException {
9
10        String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
11                .filter(s -> !s.isBlank())
12                .orElse(UUID.randomUUID().toString());
13
14        MDC.put("traceId", traceId);
15        response.setHeader("X-Trace-Id", traceId);
16
17        try {
18            filterChain.doFilter(request, response);
19        } finally {
20            MDC.clear();
21        }
22    }
23}

Pair this with a logging pattern that prints %X{traceId} so every log line includes it when present.

If You Use Micrometer Tracing

In newer Spring Boot stacks, Micrometer Tracing can manage trace propagation across supported async boundaries. Even then, custom executors can still need explicit configuration, especially when third-party thread pools are involved. Keep one integration test that verifies trace ID appears in logs from an @Async method, so upgrades do not silently break observability.

Add a Quick Verification Endpoint

A lightweight endpoint helps you validate propagation in local and staging environments. Trigger a request with a known trace header, then inspect both controller and async logs for the same value.

java
1@RestController
2@RequiredArgsConstructor
3public class TraceCheckController {
4    private final ReportService reportService;
5
6    @GetMapping("/trace-check")
7    public ResponseEntity<String> traceCheck(@RequestParam String reportId) {
8        reportService.buildReport(reportId);
9        return ResponseEntity.accepted().body("scheduled");
10    }
11}

This manual check catches configuration drift quickly, especially after executor, logging, or dependency changes.

Common Pitfalls

  • Adding @Async but using the default executor without context propagation.
  • Copying MDC into worker threads and forgetting to clear it, which can leak IDs across unrelated requests.
  • Setting trace ID in business code instead of at request boundaries, causing inconsistent logs.
  • Assuming all async libraries propagate context automatically. Many do not unless explicitly configured.
  • Omitting log pattern fields such as %X{traceId}, making propagation look broken even when context exists.

Summary

  • @Async switches threads, so MDC trace data is not preserved automatically.
  • Use TaskDecorator on your ThreadPoolTaskExecutor to copy and clear MDC safely.
  • Initialize traceId at request entry, then reuse it across logs and response headers.
  • Keep log patterns and tests aligned so trace fields are always visible.
  • Re-check propagation after framework upgrades and executor changes.

Course illustration
Course illustration

All Rights Reserved.