In the previous post we looked at how ThreadLocal and MDC store and propagate tracing context. In a synchronous environment, the simple “one request = one thread” model meant ThreadLocal alone was enough.
But move to Spring WebFlux and the situation changes completely. WebFlux uses an event loop model where a handful of threads handle thousands of requests concurrently. The thread can change repeatedly while a single request is being processed. In this environment, ThreadLocal can no longer be trusted.
So how do we keep a traceId alive in WebFlux? In this post we’ll dig into how Reactor Context solves this problem, and how to put it to work in Spring Boot 3.
MVC vs WebFlux: A Fundamental Difference in Threading Models
Spring MVC: Thread-per-Request
Spring MVC uses a thread-per-request model. When a request arrives, a thread is allocated from the thread pool, and that thread owns the request until the response completes.
sequenceDiagram
participant R as Request
participant T as Thread-1
participant DB as Database
R->>T: Request arrives
Note over T: Thread assigned
T->>DB: Execute DB query
Note over T: ⏳ Blocked waiting<br/>(thread can do nothing)
DB-->>T: Result returned
Note over T: Processing continues
T->>R: Return response
Note over T: Thread returned to pool
The characteristics of this model are clear:
One thread owns each request from start to finish
On DB or external API calls, the thread waits in a blocking state
Concurrent request count = limited by thread pool size (usually 200)
This is exactly why ThreadLocal works perfectly here. The thread never changes.
Spring WebFlux: Event Loop
WebFlux takes a completely different approach. In the event loop model, a small number of threads (usually as many as there are CPU cores) handle thousands of requests asynchronously.
sequenceDiagram
participant R1 as Request 1
participant R2 as Request 2
participant W as Worker Thread<br/>(just 1)
participant DB as Database
R1->>W: Request 1 arrives
W->>DB: DB query (non-blocking)
Note over W: No waiting!<br/>Moves straight on to other work
R2->>W: Request 2 arrives
W->>DB: DB query (non-blocking)
DB-->>W: Result for request 1
W->>R1: Response 1
DB-->>W: Result for request 2
W->>R2: Response 2
Key differences:
Aspect
Spring MVC
Spring WebFlux
Threading model
Thread-per-Request
Event Loop
Default thread count
~200
CPU core count (4–8)
I/O waiting
Blocking (thread occupied)
Non-blocking (callbacks)
Concurrent capacity
Limited by pool size
Tens of thousands of connections
Thread switches
Almost none
Happen constantly
🤔 Why can WebFlux handle more requests with fewer threads?
The key is making use of wait time. In MVC, while waiting for a DB response, the thread just sits there doing nothing. In WebFlux, after kicking off a DB call, the thread registers a callback — “let me know when the response comes” — and immediately goes off to handle other requests. When the response arrives, processing resumes from there.
public Mono<Order> getOrder(@PathVariable String id) {
// 1. Request starts - reactor-http-nio-1 thread
traceId.set(generateTraceId());
log.info("[{}] Order lookup started", traceId.get()); // ✅ prints correctly
return orderRepository.findById(id) // Non-blocking DB call
.map(order -> {
// 2. After DB response - reactor-http-nio-3 thread (a different thread!)
log.info("[{}] Order lookup completed", traceId.get()); // ❌ null!
return order;
});
}
}
Output:
[abc123] Order lookup started // reactor-http-nio-1
[null] Order lookup completed // reactor-http-nio-3 (no ThreadLocal value!)
When the thread changes, the ThreadLocal value is gone. This is the fundamental reason you cannot use ThreadLocal directly in WebFlux.
sequenceDiagram
participant C as Client
participant T1 as Thread-1
participant T2 as Thread-2
participant DB as Database
C->>T1: GET /order/123
Note over T1: ThreadLocal.set("abc123")
T1->>DB: findById(123) [non-blocking]
Note over T1: Thread released (handles other requests)
DB-->>T2: Result returned
Note over T2: ThreadLocal.get() = null ❌
T2->>C: Response
Reactor Context: The Reactive World’s ThreadLocal
What Is Context?
Project Reactor introduced the concept of a Context to solve this problem. Put simply, a Context is an immutable Map attached to the Subscriber.
// ThreadLocal approach (❌ fails in WebFlux)
ThreadLocal<String> traceId =new ThreadLocal<>();
traceId.set("abc123");
String value = traceId.get();
// Reactor Context approach (✅ works in WebFlux)
Mono.just("data")
.contextWrite(ctx -> ctx.put("traceId", "abc123")) // store in Context
.flatMap(data ->
Mono.deferContextual(ctx -> { // read from Context
String traceId = ctx.get("traceId");
return Mono.just(data +" with "+ traceId);
})
);
Key differences:
Aspect
ThreadLocal
Reactor Context
Bound to
Thread
Subscriber
On thread switch
Value lost
Value kept ✅
Data structure
Mutable
Immutable
Propagation direction
None
Bottom → Top
Context Propagation Direction: Bottom-Up
The most important — and most confusing — concept in Reactor Context is its propagation direction. Context propagates from the bottom up.
Mono.deferContextual(ctx -> {
// 3. What do we read here? → "World" (the nearest contextWrite)
log.info("message = {}", ctx.get("message"));
return Mono.just("result");
})
.contextWrite(ctx -> ctx.put("message", "World")) // 2. second write
.contextWrite(ctx -> ctx.put("message", "Hello")) // 1. first write
.subscribe();
Output: message = World
Why “World” and not “Hello”?
flowchart TB
subgraph " "
A["deferContextual (read)"]
B["contextWrite('World')"]
C["contextWrite('Hello')"]
D["subscribe()"]
end
subgraph "Context propagation direction (bottom→top)"
D2["subscribe()"] -->|"Context starts"| C2["contextWrite('Hello')"]
C2 -->|"message=Hello"| B2["contextWrite('World')"]
B2 -->|"message=World (overwritten)"| A2["deferContextual"]
end
A --- A2
B --- B2
C --- C2
D --- D2
The rule: starting from subscribe(), the Context travels upward, and each contextWrite modifies it along the way. A reading operator (deferContextual) sees the value from the contextWriteimmediately below it.
🤔 Why was it designed this way?
To match the subscription flow of Reactive Streams. When you call subscribe(), the subscription signal propagates from the bottom up. The Context rides along with this flow, so no matter how often the thread changes, the Context stays with the Subscriber.
log.info("[{}] Order lookup completed: {}", ctx.get("traceId"), response)
)
)
.contextWrite(ctx -> ctx.put("traceId", traceId)); // set the Context at the very bottom
}
}
Now the traceId survives no matter how often the thread changes!
How Reactor Context Works Internally
Let’s go beyond the beginner level and look inside at how the Context survives thread switches.
The Subscriber Chain and Context
When you chain operators in Reactor, a Subscriber chain is built internally. Each operator creates its own Subscriber, and these Subscribers are linked to one another.
Flux.just(1, 2, 3) // FluxArray
.map(i -> i *2) // FluxMap (with a MapSubscriber inside)
.filter(i -> i >2) // FluxFilter (with a FilterSubscriber inside)
In Reactor, every operator call creates a new Publisher. Calling .map() creates a FluxMap that wraps the original Flux, and calling .filter() creates a FluxFilter that wraps that in turn.
FluxFilter ─wraps→ FluxMap ─wraps→ FluxArray
Then, when you call subscribe(), each of these Publishers creates its own Subscriber. The Subscriber chain is built at subscription time, linked in the opposite direction of the data flow.
The internal structure:
flowchart TB
subgraph CODE["Order the code is written"]
C1["Flux.just(1,2,3)"]
C2[".map(i -> i * 2)"]
C3[".filter(i -> i > 2)"]
C4[".subscribe(println)"]
end
subgraph PUB["Resulting Publisher structure"]
P1["FluxArray [1,2,3]"]
P2["FluxMap"]
P3["FluxFilter"]
end
C1 --> C2 --> C3 --> C4
P3 -->|"source"| P2 -->|"source"| P1
CODE ~~~ PUB
sequenceDiagram
participant USER as .subscribe(println)
participant P3 as FluxFilter
participant P2 as FluxMap
participant P1 as FluxArray
Note over USER: subscribe() called
USER->>P3: ① subscribe with LambdaSubscriber
P3->>P2: ② create FilterSubscriber, then subscribe
P2->>P1: ③ create MapSubscriber, then subscribe
Note over P1,USER: Now the data starts flowing
P1-->>P2: onNext(1)
P2-->>P3: onNext(2)
P3-->>USER: onNext(2) → println
The key point: calling subscribe() creates the Subscribers in sequence, building up the chain. The LambdaSubscriber is created first, then the FilterSubscriber, then the MapSubscriber.
CoreSubscriber and currentContext()
Every Subscriber in Reactor implements the CoreSubscriber interface, which carries the Context-related method:
ContextWriteSubscriber.currentContext() → creates the Context and returns it here!
So only the operators abovecontextWrite() can read the Context. The operators below it can’t access it because the Context hasn’t been created yet at that point.
Context Implementations: The Optimization Secret
For performance, Reactor’s Context uses different implementations depending on size:
🤔 Why go to all this trouble instead of just using a Map?
Performance. The keys used in tracing are mostly traceId, spanId, and baggage — rarely more than five. In that range, dedicated fields are much faster than a HashMap:
Minimal object allocation
Fewer equals() calls
Cache-friendly memory layout
Why Context Survives Thread Switches
Now we can answer the core question. Why does the Context survive even when the thread changes?
sequenceDiagram
participant T1 as Thread-1
participant T2 as Thread-2
participant Sub as Subscriber
participant Ctx as Context
Note over Sub,Ctx: The Subscriber holds a reference to the Context
T1->>Sub: onNext(data)
Sub->>Ctx: currentContext()
Ctx-->>Sub: Context{traceId=abc}
Note over T1: Thread switch happens
T2->>Sub: onNext(data2)
Sub->>Ctx: currentContext()
Ctx-->>Sub: Context{traceId=abc}
Note over T2: Same Context!
The answer: the Context is attached to the Subscriber object, not to the Thread.
The Subscriber chain is created at subscribe() time
Each Subscriber holds a reference to its downstream Subscriber
The Context is looked up along this reference chain
Even if the thread changes, the Subscriber objects are the same → so is the Context
If ThreadLocal “stores data on the thread”, Reactor Context “stores data on the subscription chain”.
Micrometer Context Propagation: A Bridge Between Two Worlds
Reactor Context looks perfect, but there’s one problem. Existing libraries still use ThreadLocal.
SLF4J MDC → ThreadLocal
Micrometer Tracing → ThreadLocal
Spring Security → ThreadLocal
To use these in WebFlux, we need a bridge between Reactor Context ↔ ThreadLocal. That bridge is the Micrometer Context Propagation library.
How It Works
flowchart LR
subgraph "Reactor world"
RC["Reactor Context<br/>{traceId: 'abc123'}"]
end
subgraph "Context Propagation"
RCA["ReactorContextAccessor"]
TLA["ThreadLocalAccessor"]
end
subgraph "ThreadLocal world"
TL["ThreadLocal<br/>MDC.get('traceId')"]
end
RC <-->|"read/write"| RCA
RCA <-->|"convert"| TLA
TLA <-->|"read/write"| TL
The core interfaces:
// interface for accessing ThreadLocal values
publicinterfaceThreadLocalAccessor<V> {
Object key(); // the key to use in the Context
V getValue(); // read the value from ThreadLocal
voidsetValue(V value); // write a value to ThreadLocal
voidsetValue(); // clear the ThreadLocal value (to null)
}
// interface for accessing Map-like contexts such as Reactor Context
Micrometer provides ObservationThreadLocalAccessor, and Reactor provides ReactorContextAccessor. Working together, they propagate context in both directions.
Two Modes: Default vs Automatic
Reactor Core supports Context Propagation since 3.5.0, in two modes:
1. Default mode (limited restoration)
ThreadLocal is restored only in specific operators (handle, tap):
flux.handle((item, sink) -> {
// ThreadLocal is restored only inside this block
log.info("traceId = {}", MDC.get("traceId")); // ✅ works
sink.next(transform(item));
});
2. Automatic mode (full restoration)
ThreadLocal is automatically restored in every operator:
// enable at application startup
Hooks.enableAutomaticContextPropagation();
flux.map(item -> {
// ThreadLocal restored in every operator!
log.info("traceId = {}", MDC.get("traceId")); // ✅ works
returntransform(item);
});
🤔 Automatic mode looks great — why is Default the default?
Performance. Automatic mode saves/restores ThreadLocal before and after every operator execution. That overhead can be non-negligible.
The official docs also recommend that “if maximum scalability and performance is the goal, consider an explicit approach that doesn’t rely on ThreadLocal.” Automatic mode is for when migrating existing code or convenience takes priority.
The Role of contextCapture()
contextCapture()captures the current thread’s ThreadLocal values into the Reactor Context:
// with a value already in ThreadLocal
MDC.put("traceId", "abc123");
Mono.just("data")
.contextCapture() // capture current ThreadLocal values into the Context
.flatMap(data ->someAsyncOperation())
.subscribe();
It’s mainly used when starting a reactive chain from imperative code:
@GetMapping("/order")
public Mono<Order>createOrder(@RequestBody OrderRequest request) {
// traceId is set in ThreadLocal at the controller level
// block() - waits on the current thread until completion
Order result = orderService.createOrder(request)
.contextCapture()
.block(); // runs on the same thread, Context naturally preserved
// subscribe() - runs asynchronously on another thread
orderService.createOrder(request)
.contextCapture() // required! without it, the Context is lost
.subscribe(result -> log.info("done"));
block() occupies the current thread, so Context is less of an issue — but subscribe() may run on a different thread, making contextCapture() essential.
🚨 Important: using block() in WebFlux blocks an event loop thread. WebFlux’s core advantage is handling thousands of requests with a handful of threads (usually 4–8) — block one of those and total throughput plummets. At that point, using WebFlux is essentially pointless.
// ❌ Never do this: block() on an event loop thread
@GetMapping("/order/{id}")
public Mono<Order>getOrder(@PathVariable String id) {
Order order = orderRepository.findById(id).block(); // blocks the event loop thread!
return Mono.just(order);
}
// ✅ The right way: keep the reactive chain
@GetMapping("/order/{id}")
public Mono<Order>getOrder(@PathVariable String id) {
<!-- Context Propagation (pulled in automatically, but stated explicitly) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>context-propagation</artifactId>
</dependency>
</dependencies>
🤔 Why the OTLP exporter?
OTLP (OpenTelemetry Protocol) is OpenTelemetry’s standard transport protocol. We used to need a different exporter per backend — Zipkin, Jaeger, and so on — but the ecosystem is converging on OTLP.
Jaeger: native OTLP support (ports 4317/4318)
Zipkin: integrates via the OTLP Collector
Grafana Tempo: native OTLP support
AWS X-Ray, Datadog, etc.: integrate via the OTLP Collector
With OTLP, switching backends requires no application code changes.
application.yml
application.yml
spring:
application:
name: order-service
# 🔑 The key setting: enable Automatic Context Propagation
reactor:
context-propagation: auto# Spring Boot 3.2+
management:
tracing:
sampling:
probability: 1.0# 100% sampling in development
propagation:
type: w3c# use W3C Trace Context
baggage:
enabled: true
correlation:
enabled: true
fields:
- user-id
- tenant-id
# OTLP exporter settings (send to Jaeger, Tempo, a Collector, etc.)
Most OTLP receivers (Jaeger, Tempo, OTel Collector) support both ports. Spring Boot uses HTTP by default, so you append the /v1/traces path to port 4318.
🤔 What spring.reactor.context-propagation=auto does
This setting, added in Spring Boot 3.2, internally calls Hooks.enableAutomaticContextPropagation(). On earlier versions you had to call it yourself:
// Spring Boot 3.1 and below
@SpringBootApplication
publicclassMyApplication {
publicstaticvoidmain(String[] args) {
Hooks.enableAutomaticContextPropagation(); // call it directly
Automatic Context Propagation is convenient but has a performance cost:
// this happens in every operator
// 1. before the operator runs: Context → restore ThreadLocal
// 2. run user code
// 3. after the operator runs: ThreadLocal → restore previous state
Default mode + handle()/tap() example:
Instead of Automatic mode, you can use handle() or tap() in Default mode to restore ThreadLocal only where needed. Each of the two operators restores ThreadLocal independently.
Operator
Role
Data transformation
handle()
transform + filter + logging
✅ transform/filter via sink.next()
tap()
side effects only (logging, metrics)
❌ data passes through unchanged
// Default mode (no spring.reactor.context-propagation setting)
@GetMapping("/orders")
public Flux<Order>getOrders() {
return orderRepository.findAll()
// handle(): when you need transformation + filtering + logging together
sink.next(order); // pass through valid orders only
}
// not calling sink.next() filters the item out
})
// tap(): when you only log and let data pass through untouched
.tap(signal -> {
if (signal.isOnNext()) {
// ✅ ThreadLocal is restored inside this block
Order order = (Order) signal.get();
log.info("[{}] Order passed: {}", MDC.get("traceId"), order.getId());
}
})
.doOnNext(order -> {
// ❌ ThreadLocal NOT restored here! (in Default mode)
log.info("Processing done: {}", order.getId()); // no traceId
});
}
🤔 What are sink and signal?
Parameter
Type
Role
sink
SynchronousSink<T>
the outlet that emits data to the next operator
signal
Signal<T>
an info object for reading the current event
sink (used in handle):
sink.next(value); // pass a value to the next operator
sink.error(exception); // raise an error
// not calling sink.next() → that item is filtered out
signal (used in tap):
signal.isOnNext() // did data flow through?
signal.isOnError() // did an error occur?
signal.isOnComplete() // did the stream complete?
signal.get() // get the actual value for onNext
signal.getThrowable() // get the exception for onError
🤔 handle() vs tap() — when to use which?
handle(): when you need to transform data or filter it conditionally. map() + filter() + ThreadLocal restoration in one shot.
tap(): when you don’t touch the data and only log or record metrics. Data flows straight through to the next operator.
Both restore ThreadLocal, so pick whichever fits your need.
Recommendations:
Situation
Recommended mode
Why
Typical service
Automatic
convenience first, performance is fine
High-performance service (high TPS)
Default + handle()/tap()
restore only where needed
Mid-migration
Automatic
fast transition, optimize later
Service with little logging
Default
minimize overhead
4. traceId Lost in a Library’s Internal Logging
Example symptom: your application code’s logs show the traceId just fine, but DEBUG logs from libraries — the Reactive Mongo Client, R2DBC drivers, and so on — have an empty traceId
14:23:45.123 [abc123,111aaa] INFO c.e.OrderService - Order lookup started // ✅ fine
14:23:45.124 [abc123,111aaa] DEBUG c.m.r.c.internal - Executing query // ✅ fine (inside the chain)
14:23:45.125 [,] DEBUG c.m.r.c.internal - Socket connected // ❌ lost (outside the chain)
14:23:45.126 [,] DEBUG c.m.r.c.internal - Sending bytes // ❌ lost (outside the chain)
14:23:45.234 [abc123,222bbb] INFO c.e.OrderService - Order lookup completed // ✅ fine
Same library — so why do some logs have the traceId and others don’t?
Cause: depending on the library’s internal implementation, where the log is emitted can differ.
flowchart TB
subgraph IN["Inside the chain ✅ Context available"]
A["repository.findById()"]
B["inside a library operator<br/>log.debug('Executing query')"]
end
subgraph OUT["Outside the chain ❌ no Context"]
C["network event handler<br/>log.debug('Socket connected')"]
D["I/O callback<br/>log.debug('Sending bytes')"]
end
A --> B
B -.->|"actual I/O work"| C
C --> D
style B fill:#ccffcc,color:#0f172a,stroke:#00aa00
style C fill:#ffcccc,color:#0f172a,stroke:#ff0000
style D fill:#ffcccc,color:#0f172a,stroke:#ff0000
When a library logs internally:
The log runs in a separate callback or event handler, not in a reactive-chain operator
At that point it isn’t connected to the Reactor Context
Automatic Context Propagation also only works at reactive operator boundaries
Fixes:
Adjust the library’s log level (a stopgap) logging: level: com.mongodb.reactivestreams: WARN io.r2dbc: WARN
Use a Java Agent (the fundamental fix) The OpenTelemetry Java Agent instruments library internals through bytecode manipulation. Even if a library isn’t aware of Context Propagation, the Agent injects the Context by force. java -javaagent:opentelemetry-javaagent.jar \ -Dotel.service.name=order-service \ -jar myapp.jar
📌 Java Agent vs Library Instrumentation
This problem stems from a difference in instrumentation approach. The library approach (Micrometer, Spring Boot auto-configuration) only works at reactive chain boundaries, while the Java Agent approach inserts instrumentation everywhere at the bytecode level.
This topic gets its own post later in the series — “Comparing Instrumentation Approaches: How Java Agent vs Library Works”. We’ll look at how the Agent propagates the traceId all the way into library internals, and compare the trade-offs of each approach.
flowchart TB
subgraph MVC["Spring MVC"]
direction LR
M1["Store directly<br/>in ThreadLocal"] --> M2["Thread stays the same →<br/>propagates naturally"] --> M3["Extra handling needed<br/>with @Async"]
end
subgraph WebFlux["Spring WebFlux"]
direction LR
W1["Store in<br/>Reactor Context"] --> W2["Propagates along the<br/>Subscriber chain"] --> W3["MDC integration via<br/>Context Propagation"]
end
MVC ~~~ WebFlux
Conclusion
In WebFlux’s event loop model, you can’t use ThreadLocal directly. Instead, Reactor Context propagates data along the Subscriber chain.
The key points:
Reactor Context is bound to the Subscriber: even when threads change, the subscription chain stays intact, so the Context does too.
Bottom-up propagation: Context starts at subscribe() and propagates upward. Always place contextWrite() low in the chain.
Bridge with Context Propagation: to integrate with ThreadLocal-based libraries like MDC, use Micrometer Context Propagation.
Spring Boot 3.2+ makes it simple: one line — spring.reactor.context-propagation=auto — configures it automatically.
In the next post we’ll cover Context Propagation in Kotlin coroutines — how CoroutineContext, Reactor Context, and ThreadLocal work together.