Mastering Context Propagation in Spring Boot: From ThreadLocal to Distributed Tracing
Introduction
Context propagation is a crucial concept in microservices architectures. It ensures that contextual information such as user identity, trace IDs, correlation IDs, and security metadata is passed across different layers of an application — whether within the same thread, across asynchronous tasks, or between distributed services.
In this article, we will explore various techniques for context propagation in Spring Boot, covering synchronous execution, multi-threaded processing, reactive applications, distributed tracing, and event-driven communication with Kafka.
Understanding Context Propagation
Context propagation refers to carrying relevant contextual information across different execution flows. It ensures consistency and observability across:
- Synchronous execution within the same thread
- Multi-threaded processing
- Reactive programming
- Distributed services (microservices, cloud-based systems)
- Event-driven architectures (Kafka, RabbitMQ, etc.)
Debugging, tracing, security enforcement, and request consistency can become challenging without proper context propagation.
Context Propagation in Synchronous Applications
In traditional single-threaded applications, it is a very straightforward concept. All information can be stored in some global variables, request objects, or in some Thread local storage.
What is ThreadLocal?
ThreadLocal<T>is a Java utility that provides thread-scoped storage for data, ensuring that each thread accessing the variable has its own isolated copy. This is useful for maintaining request-scoped or thread-specific context data in multi-threaded applications.
public class ThreadLocalExample {
private static final ThreadLocal<String> threadLocalVar = new ThreadLocal<>();
public static void main(String[] args) {
threadLocalVar.set("Thread-1 Data"); // Stores data in current thread's context
System.out.println("ThreadLocal Value: " + threadLocalVar.get()); // Retrieves value
threadLocalVar.remove(); // Always remove to avoid memory leaks
}
}This approach works well if we run multiple threads because every thread gets its own isolated copy
public class MultiThreadExample {
private static final ThreadLocal<String> threadLocalVar = new ThreadLocal<>();
public static void main(String[] args) {
Runnable task = () -> {
threadLocalVar.set(Thread.currentThread().getName() + " Data");
System.out.println(Thread.currentThread().getName() + " → " + threadLocalVar.get());
};
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
t1.start();
t2.start();
}
}but it has its own problems when we are running code in a multi-threaded environment, specifically some thread pools such as ExecutorService.
Context Propagation in Multi-Threaded Applications
In a multi-threaded application, each thread gets its own isolated copy of the ThreadLocal variable.
When using a thread pool (ExecutorService), worker threads do not terminate after execution, meaning they may retain ThreadLocal values from previous tasks. This can cause context leakage.
ExecutorService executor = Executors.newFixedThreadPool(2);
Runnable task = () -> {
threadLocalVar.set(Thread.currentThread().getName() + " Data");
System.out.println(Thread.currentThread().getName() + " → " + threadLocalVar.get());
};
executor.submit(task);
executor.submit(task);
executor.shutdown();Problem: If the thread is reused, it might retain ThreadLocal values from a previous task, leading to unintended behavior.
So, How to solve this problem?
Manually clear ThreadLocal
One simple solution is to clear ThreadLocal data in the finally block of the run method so that when the thread finishes execution all context information is cleared.
Runnable task = () -> {
try {
threadLocalVar.set(Thread.currentThread().getName() + " Data");
System.out.println(Thread.currentThread().getName() + " → " + threadLocalVar.get());
} finally {
threadLocalVar.remove(); // Prevents memory leaks
}
};This approach is good but there is always a chance of some human error such as forgetting to clear ThreadLocal which causes context leakage,
Using a Context Wrapper
A better solution is always to propagate context to all threads whenever a new thread is spawned, For this, a decorator pattern can be used. Below is a sample code snippet for it.
// Create custom class for Request Context as we dont have any inbuilt class in java
// In case of Spring boot, existing implementation can be used
public class RequestContext {
private static final ThreadLocal<String> userContext = new ThreadLocal<>();
public static void setUser(String user) {
userContext.set(user);
}
public static String getUser() {
return userContext.get();
}
public static void clear() {
userContext.remove();
}
}
// use contextpropagator for runnable
public class ContextPropagator implements Runnable {
private final Runnable delegate;
private final Object userContext;
public ContextCopyingDecorator(Runnable delegate, Object userContext) {
this.delegate = delegate;
this.userContext = userContext;
}
@Override
public void run() {
RequestContext.setUser(userContext);
try {
delegate.run();
} finally {
RequestContext.clear();
}
}
}How to Use It?
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(new ContextPropagator(() -> {
System.out.println(Thread.currentThread().getName() + " → " + RequestContext.getUser());
}, "UserB"));
executor.shutdown();in this case developer is not responsible for writing logic for removing thread-local or clearing it.
Context Propagation in @Async Methods in Spring Boot
Let us first understand Async method behaviour in Spring. Spring boot provides Async annotation to execute methods asynchronously. However ThreadLocal Context does not automatically propagate to the asycn method as Spring runs them in separate threads from a thread pool.
To solve this, we have solutions
- Spring allows customizing the thread execution via TaskDecorator
@Component
public class ContextCopyingTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
String userContext = RequestContext.getUser();
return () -> {
try {
RequestContext.setUser(userContext);
runnable.run();
} finally {
RequestContext.clear();
}
};
}
}
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(10);
executor.setThreadNamePrefix("Async-");
executor.setTaskDecorator(new ContextCopyingTaskDecorator());
executor.initialize();
return executor;
}
}This decorator copies the context to the new thread before execution. With the decorator applied, RequestContext.getUser() will work correctly inside @Async methods.
2. Using CompletableFuture For manual Context Passing
Instead of relying on ThreadLocal, another approach is passing context explicitly using completableFuture.
import java.util.concurrent.CompletableFuture;
class UserContext {
private String username;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
public class ContextChainingExample {
public static CompletableFuture<String> asyncStep1(UserContext userContext) {
return CompletableFuture.supplyAsync(() -> "Step 1 for " + userContext.getUsername());
}
public static CompletableFuture<String> asyncStep2(String prevResult) {
return CompletableFuture.supplyAsync(() -> prevResult + " → Step 2 completed");
}
public static void main(String[] args) {
UserContext userContext = new UserContext();
userContext.setUsername("Main User");
asyncStep1(userContext)
.thenCompose(ContextChainingExample::asyncStep2)
.thenAccept(System.out::println);
}
}Context Propagation in Reactive Applications
When using reactive programming with Spring WebFlux and Project Reactor, traditional approaches like ThreadLocal do not work due to non-blocking execution and thread switching. Instead, Reactor’s Context API is used to propagate contextual data across reactive streams.
The reason behind this is In a reactive pipeline, tasks execute asynchronously and may switch threads multiple times, breaking ThreadLocal.
public class RequestContext {
private static final ThreadLocal<String> userContext = new ThreadLocal<>();
public static void setUser(String user) { userContext.set(user); }
public static String getUser() { return userContext.get(); }
}
public Mono<String> process() {
RequestContext.setUser("JohnDoe");
return Mono.fromSupplier(RequestContext::getUser); // Null due to thread switch
}
}Reactor provides a built-in context that is automatically passed across threads. The below code shows how to use it
Mono<String> getUser(Mono<String> userMono) {
return userMono.flatMap(user -> Mono.deferContextual(ctx -> {
return Mono.just("User: " + ctx.getOrDefault("user", "anonymous"));
})).contextWrite(Context.of("user", "john_doe"));
}Context can be used to fetch data that was saved earlier.
public Mono<String> getUserFromContext() {
return Mono.deferContextual(ctx -> Mono.just(ctx.getOrDefault("user", "Guest")));
}
public Mono<String> execute() {
return getUserFromContext().contextWrite(Context.of("user", "Alice"));
}Context Propagation in Distributed Systems
In modern distributed systems, applications are spread across multiple microservices, processes, or even cloud environments. Context propagation ensures that important information — such as trace IDs, correlation IDs, user authentication details, and security tokens — is correctly passed across services, message queues, and external API calls.
Distributed applications involve multiple services communicating with each other over HTTP, messaging queues (Kafka, RabbitMQ), and gRPC. Without proper context propagation, there are some problems such as :
- Tracing requests across services is difficult
- Security tokens and user authentication data may be lost
- Logging correlation becomes impossible
Context propagation in distributed systems comes with its own challenges such as
Thread Execution Switching
In a distributed system, a single request does not always execute within the same thread throughout its lifecycle. Due to factors such as thread pooling, task scheduling, or reactive processing, execution may begin in one thread and later continue in another. For example, in traditional multi-threaded applications, an incoming request may be handled by a servlet thread, but if the request involves asynchronous processing using an ExecutorService or CompletableFuture, it could be transferred to a different worker thread. Similarly, in reactive programming paradigms (such as those using Project Reactor or RxJava), request handling is non-blocking and event-driven, meaning execution hops across multiple threads to optimize performance and resource utilization.
A major challenge that arises from this behavior is that ThreadLocal variables, which are often used to store contextual information (such as user identity, request metadata, or transaction details), do not automatically propagate across threads. Since ThreadLocal is bound to the thread that sets the value, any subsequent thread executing the same request will not have access to the previously stored data. This can lead to unexpected behavior, such as losing user session details, breaking request tracing, or failing to maintain transactional consistency.
To address this issue, various solutions can be employed, such as manually passing context as method parameters, using InheritableThreadLocal (with limitations), leveraging TransmittableThreadLocal for thread pool compatibility, or relying on context propagation frameworks like Spring’s RequestAttributes. Each approach has trade-offs, and the optimal choice depends on factors such as application architecture, performance considerations, and the complexity of the distributed workflow.
Network Boundaries
In a distributed system, context does not automatically propagate across network boundaries, such as HTTP, gRPC, or message queues. When a request travels between microservices over a network, thread-local or in-memory context, such as user identity, correlation ID, or transaction details, is lost unless explicitly passed. Unlike in-process execution, where context can be maintained within a single memory space, network calls do not carry contextual information unless it is manually included in request headers or metadata.
For HTTP-based communication, context must be propagated through headers, such as X-Correlation-ID for request tracing. In gRPC, context is passed using Metadata objects, ensuring relevant information is available in remote calls. Similarly, for message queues like Kafka or RabbitMQ, context must be embedded in message headers to persist across asynchronous communication.
HttpHeaders headers = new HttpHeaders();
headers.add("X-Trace-Id", "12345");
HttpEntity<String> entity = new HttpEntity<>(headers);
restTemplate.exchange("http://service-b/endpoint", HttpMethod.GET, entity, String.class);Without explicit propagation, key aspects like distributed tracing, logging correlation, and request tracking become difficult. To address this, frameworks like OpenTelemetry and Jaeger help automate context injection and extraction across network boundaries, ensuring seamless tracking of distributed requests.
Message Queues (Kafka/RabbitMQ)
In event-driven architectures, messages are inherently decoupled, meaning context does not automatically transfer from the producer to the consumer. Message brokers like Kafka and RabbitMQ do not propagate headers or metadata unless explicitly included in the message. If contextual information, such as correlation IDs or user details, needs to persist across events, it must be manually added to message headers or payloads by the producer and extracted by the consumer. Without this explicit propagation, tracing, debugging, and maintaining context across asynchronous workflows become challenging.
ProducerRecord<String, String> record = new ProducerRecord<>("topic", "key", "value");
record.headers().add("trace-id", "12345".getBytes(StandardCharsets.UTF_8));
kafkaProducer.send(record);@KafkaListener(topics = "topic")
public void consume(ConsumerRecord<String, String> record) {
String traceId = new String(record.headers().lastHeader("trace-id").value(), StandardCharsets.UTF_8);
TraceContextHolder.setTraceId(traceId);
}Security & Authentication
In a distributed system, services do not automatically share authentication details like user identities, JWT tokens, or OAuth credentials. Unlike traditional monolithic applications where session data might be stored in memory and shared across different parts of the application, microservices are stateless, meaning they do not remember a user’s login status between requests.
Because of this, every time one service needs to communicate with another, it must explicitly send authentication credentials. This is usually done by passing a token (like a JWT or OAuth access token) in the request headers. For example, in an HTTP request, authentication is typically handled using the Authorization header
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(jwtToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
restTemplate.exchange("http://service-b/endpoint", HttpMethod.GET, entity, String.class);Each receiving service must then independently validate the token to verify the user’s identity and permissions before processing the request. This ensures secure access control but also means that every service must have a way to authenticate requests on its own.
If the token is not passed correctly, the receiving service will not recognize the request as authenticated, which can result in errors like “Unauthorized” (HTTP 401) or “Forbidden” (HTTP 403). Therefore, properly forwarding authentication tokens between services is crucial for ensuring that users remain authenticated as requests flow through different parts of a distributed system
Observability
In distributed systems, each service logs events independently, making it difficult to track a single request as it moves through multiple services. Unlike monolithic applications where logs are centralized, microservices generate logs in separate locations, often across different servers or containers. This fragmentation makes troubleshooting and debugging complex, as there is no direct way to link logs from different services to a single request.
To solve this, a global trace ID is used. A trace ID is a unique identifier assigned to each request at the entry point of the system. This ID is then passed along with the request as it moves between services, typically in HTTP headers (e.g., X-Trace-Id). Each service includes this trace ID in its logs, allowing all related log entries to be correlated later.
MDC.put("traceId", "12345");
logger.info("Processing Order ID 1001");
MDC.clear();Without a trace ID, debugging distributed systems becomes challenging because logs appear disconnected. Tools like OpenTelemetry, Jaeger, and Zipkin help automate trace ID generation, propagation, and correlation, making it easier to monitor, debug, and analyze system behavior across services.
Let us understand by creating one sample for OpenTelemetry
Add OpenTelemetry Dependencies
<dependencies>
<!-- OpenTelemetry API -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
<version>1.29.0</version>
</dependency>
<!-- OpenTelemetry SDK (for manual instrumentation) -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
<version>1.29.0</version>
</dependency>
<!-- OpenTelemetry Exporters (for sending traces to Jaeger/Zipkin) -->
<dependency>
<groupId>io.opentelemetry.exporter</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
<version>1.29.0</version>
</dependency>
<!-- OpenTelemetry Spring Boot Starter -->
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-spring-boot-starter</artifactId>
<version>1.29.0</version>
</dependency>
</dependencies>Configure OpenTelemetry in application.properties
# Enable OpenTelemetry
otel.traces.exporter=otlp
otel.metrics.exporter=none
otel.logs.exporter=none
# Set OTLP endpoint (if using Jaeger, Zipkin, or OpenTelemetry Collector)
otel.exporter.otlp.endpoint=http://localhost:4317
# Enable automatic context propagation
otel.propagators=b3multi,tracecontextOpenTelemetry in Action: Now, let’s simulate a call from one microservice to another, automatically propagating context.
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import org.springframework.web.client.RestTemplate;
public class PaymentService {
private static final Tracer tracer = GlobalOpenTelemetry.getTracer("PaymentService");
private final RestTemplate restTemplate = new RestTemplate();
public String makePayment(String orderId) {
Span span = tracer.spanBuilder("make-payment").startSpan();
try {
String url = "http://localhost:8081/order?orderId=" + orderId;
span.setAttribute("payment.orderId", orderId);
String response = restTemplate.getForObject(url, String.class);
return response;
} finally {
span.end();
}
}
}Microservice B: Receiving the Request and Continuing the Trace
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
private static final Tracer tracer = GlobalOpenTelemetry.getTracer("OrderService");
@GetMapping("/order")
public String processOrder(@RequestParam String orderId) {
Span span = tracer.spanBuilder("process-order").startSpan();
try {
span.setAttribute("order.id", orderId);
span.setAttribute("service.name", "OrderService");
return "Order " + orderId + " processed!";
} finally {
span.end();
}
}
Tools such as Jaeger/Zipkin can be used to visualize traces. You can find more details about them on the below links.
https://www.jaegertracing.io/docs/1.18/
https://zipkin.io/
In the above article, we tried to cover all parts from simple thread to microservice level use cases. Let me know if you want to add anything more. Happy Coding!
