Propagate MDC safely through Java thread pools
Capture MDC when submitting work, install it before execution, and restore the prior context to prevent leakage across reused threads.
With SLF4J and Logback, MDC is thread-local. A thread pool does not reliably carry the submitting thread's context into a task. Capture a snapshot at submission, install that snapshot before execution, and restore the execution thread's previous context in finally. Apply the wrapper at every relevant submission boundary.
Why context disappears or leaks
// This changes only the submitting thread's MDC.
MDC.put("requestId", "req-123");
executor.submit(() -> {
log.info("Processing task"); // The worker may not have this requestId.
});
The worker may have no MDC, or it may still contain fields left by an earlier task. Neither is the caller's context. Reusing a worker without cleanup can attach another request's identifiers to its logs.
Wrap each task
import java.util.Map;
import org.slf4j.MDC;
public class MdcTaskWrapper {
public static Runnable wrap(Runnable task) {
Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
try {
if (context == null) MDC.clear();
else MDC.setContextMap(context);
task.run();
} finally {
if (previous == null) MDC.clear();
else MDC.setContextMap(previous);
}
};
}
}
Use it with the application's executor and logger:
executor.submit(MdcTaskWrapper.wrap(() -> {
log.info("This event includes the submitting request's context");
}));
All three steps matter: capture, replace, and restore. A null snapshot must clear the executing thread's current MDC. Restoring rather than unconditionally clearing also protects the caller when CallerRunsPolicy runs the task inline.
Use the same try/finally pattern for Callable. The submitting request must clean up the fields it owns when it finishes. Test two different request IDs through a single worker and verify that neither leaks into the other task. Copying MDC transfers logging fields; it does not create spans or propagate a tracing context by itself.
Use framework integration where available
- TransmittableThreadLocal: propagates values managed by TTL. Wrapping an executor or installing its Agent does not automatically convert an arbitrary logging backend's MDC into a TTL variable. Configure and test the appropriate bridge.
- Spring: register a
TaskDecoratoron theThreadPoolTaskExecutorbefore it is initialized:
executor.setTaskDecorator(MdcTaskWrapper::wrap);
Here executor is Spring's ThreadPoolTaskExecutor, not a plain ExecutorService.
Frequently asked questions
Why do later logs on the same thread retain an MDC value?
Thread-local values remain until changed or removed. Pair fields you introduce with cleanup in finally, using remove, clear, or restoration as appropriate to ownership and nesting.
Does MDC also disappear in a CompletableFuture chain?
It can. Every asynchronous stage is another possible thread boundary. Supply an executor that captures and restores MDC, and use it consistently at those stages. A TTL executor is sufficient only when your MDC integration actually participates in TTL propagation. Switching stages to synchronous execution solely to preserve log fields changes scheduling behavior and is not a general fix.
Which fields are useful?
Start with request and trace IDs, then add tenant or user identifiers and route information when they are appropriate for diagnosis and privacy requirements. Keep fields small. A Logback pattern such as %X{requestId} renders that MDC value.
References and further reading
- Logback MDC
- SLF4J MDC API
- TransmittableThreadLocal
- Update package.json dependencies
- Use the selected time range in a query
The example requires the application's executor and logging backend. Verify propagation against that configuration; no live application was exercised for this article.
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan