Spring Boot Observability: Propagating Trace Context Across Async Threads
- Get link
- X
- Other Apps
Spring Boot @Async: Fixing Distributed Tracing Context Propagation Across Threads
Have you ever noticed that distributed traces suddenly disappear when your Spring Boot application executes an asynchronous task?
A request enters your application, calls a service, and then schedules background work using @Async. The initial request appears correctly in your tracing dashboard, but the background execution is missing—or worse, it appears as an entirely unrelated trace.
The problem may not be your tracing backend. It may be that the request's observation context was not propagated to the worker thread.
Distributed tracing relies on context: identifiers and other state that connect operations across services and execution boundaries. OpenTelemetry provides mechanisms to propagate this context between services, usually without requiring application code to copy identifiers manually.
However, there is another boundary within the same application that is easy to overlook: switching threads.
When Spring executes an @Async method on a different thread, the original thread's context does not automatically follow it. Without proper context propagation, the asynchronous operation loses its relationship with the original request.
Let's look at why this happens and how to fix it.
1. Understanding Two Different Context Propagation Boundaries
There are two common scenarios in distributed tracing, and they require different solutions.
Service-to-service communication
When one service makes an HTTP request to another service, tracing instrumentation typically injects the current trace context into HTTP headers.
OpenTelemetry's default propagator uses the W3C Trace Context format.
The traceparent header carries three important pieces of information:
Trace ID: Identifies the overall distributed trace.
Parent span ID: Identifies the operation that initiated the next operation.
Trace flags: Carry tracing-related flags, such as the sampling decision.
The receiving service extracts this information and continues the existing trace.
Asynchronous execution within the same application
The situation is different when Spring Boot switches execution from the request thread to a worker thread.
Java applications commonly use ThreadLocal to store contextual information associated with the current thread.
However, a worker thread executing an @Async method does not automatically inherit the request thread's ThreadLocalvalues.
This means the trace context can be lost even though the asynchronous task is part of the same application and was triggered by the original request.
Spring Boot provides context propagation support to address this problem.
Its observability documentation specifically covers context propagation across threads and reactive pipelines, including an opt-in configuration for @Async.
2. Enable Context Propagation for @Async
If your asynchronous methods use Spring Boot's auto-configured AsyncTaskExecutor, you can enable context propagation through application configuration.
Add the following property to application.yml:
spring:
task:
execution:
propagate-context: trueThis tells Spring Boot to propagate the current observation context when executing tasks through its auto-configured executor.
With this configuration, tracing context can follow asynchronous tasks instead of being lost at the thread boundary.
What if You Use a Custom Executor?
Many applications define their own AsyncTaskExecutor using ThreadPoolTaskExecutor to control thread pool size, queue capacity, naming, and other execution settings.
In that case, enabling the auto-configured executor property is not sufficient.
You need to configure a ContextPropagatingTaskDecorator for your custom executor.
For example, first register the decorator as a Spring bean:
@Bean
ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
return new ContextPropagatingTaskDecorator();
}Then apply it to your custom executor:
@Bean
ThreadPoolTaskExecutor taskExecutor(
ContextPropagatingTaskDecorator decorator) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setTaskDecorator(decorator);
return executor;
}The important part is:
executor.setTaskDecorator(decorator);This ensures that the task decorator captures and restores the relevant context when the task moves between threads.
Choose the configuration that matches your executor. The auto-configured executor property does not automatically configure a separately constructed executor.
3. Context Propagation Does Not Automatically Create a Span
There is another important distinction.
Propagating context and creating a span are two different things.
Context propagation allows an instrumented operation to continue the existing trace. However, it does not guarantee that every method annotated with @Async automatically creates a new, visible span.
If you want a specific background operation to appear as a named span, you need to instrument that operation.
Spring Boot uses Micrometer Observation as the foundation for its metrics and tracing integration.
For example:
Observation.createNotStarted(
"report.generate",
observationRegistry
).observe(() -> generateReport());This creates an observation around the report-generation operation.
When tracing is enabled and properly configured, this observation can produce a span associated with the current trace context.
Be Careful with Metric Tags
Micrometer Observation supports two types of key-values:
Low-cardinality values: Added to both metrics and traces.
High-cardinality values: Added to traces only.
This distinction matters when designing observability for production applications.
For example, avoid using unique identifiers such as user IDs, request IDs, or order IDs as metric dimensions.
Doing so can create excessive metric cardinality and significantly increase monitoring costs.
Keep metric tags bounded and use trace attributes when you need detailed, per-request information.
4. A Practical Troubleshooting Checklist
If your asynchronous operations are missing from distributed traces, work through the following checks.
Step 1: Identify the last visible span
Find the last visible span in your tracing dashboard.
Note its trace ID and parent span ID. This gives you a reference point for investigating where the trace context disappears.
Step 2: Check which executor is being used
Determine whether your @Async method uses:
Spring Boot's auto-configured
AsyncTaskExecutor.A custom
ThreadPoolTaskExecutor.Another custom executor.
Make sure you configure context propagation on the executor that actually executes the task.
Step 3: Enable context propagation
For the auto-configured executor, use:
spring:
task:
execution:
propagate-context: trueFor a custom executor, register and apply ContextPropagatingTaskDecorator.
Step 4: Verify that the background operation is instrumented
Context propagation alone does not guarantee a visible span.
If you expect a dedicated span for the asynchronous operation, make sure it is instrumented using Micrometer Observation or another appropriate tracing mechanism.
Step 5: Check the resulting trace
Trigger the same request again.
Verify that:
The background span has the original trace ID.
The parent-child relationship makes sense.
The span is successfully exported.
Sampling, filtering, or dashboard configuration is not hiding the span.
5. What About Message Brokers and Separate Processes?
The solutions described above apply to asynchronous execution within the same application process.
If your background work crosses a message broker or starts in another process, a local task decorator cannot propagate context across that boundary.
For example, if a Spring Boot application publishes a message to Kafka and another service consumes it, tracing instrumentation must inject context into the message headers and extract it on the consumer side.
Use the appropriate tracing instrumentation for the transport rather than relying on ThreadLocal propagation.
Also, remember that trace IDs are identifiers, not authorization tokens. Avoid putting credentials or personal information into propagated baggage.
Conclusion
The key to troubleshooting missing Spring Boot traces is understanding where context propagation is required.
There are two different boundaries to consider:
Network boundary:
traceparentand tracing instrumentation connect operations across services.Execution boundary: Context propagation carries the active observation across local thread switches and asynchronous execution.
For Spring Boot's auto-configured @Async executor, enabling spring.task.execution.propagate-context is the straightforward solution.
For custom executors, use ContextPropagatingTaskDecorator.
Finally, remember that propagating context does not automatically create a span. If you want background operations to appear as meaningful spans, make sure those operations are instrumented.
Once context propagation and instrumentation are both handled correctly, asynchronous work can be investigated as part of the original request instead of appearing as a mystery of its own.
References
❤️ Support This Blog
If this post helped you, you can support my writing with a small donation. Thank you for reading.
- Get link
- X
- Other Apps

Comments
Post a Comment