In the previous post we looked at how Reactor Context keeps the traceId alive in WebFlux’s event loop environment. It solved the thread-switching problem by binding a Context to the Subscriber chain.
But if you’re using Kotlin, things get a bit more complicated. Kotlin coroutines come with their own context system: CoroutineContext. That brings the number of contexts we have to deal with up to three.
flowchart LR
subgraph "Three Contexts"
TL["ThreadLocal<br/>(MDC, TraceContext)"]
RC["Reactor Context<br/>(WebFlux)"]
CC["CoroutineContext<br/>(Kotlin Coroutine)"]
end
TL <-->|"Context Propagation"| RC
RC <-->|"ReactorContext"| CC
TL <-->|"ThreadContextElement"| CC
Use Kotlin coroutines in Spring WebFlux and all three of these contexts get tangled together. For the traceId to propagate correctly, the bridges between them have to be wired up properly.
In this post we’ll dig into how CoroutineContext works, and how Spring Boot 3 connects the three contexts seamlessly.
Why ThreadLocal Doesn’t Work in Coroutines
suspend and Thread Switching
The essence of coroutines is suspending and resuming. When a suspend function needs to wait — on I/O, say — it pauses, and once the result is ready it resumes. The problem: it may resume on a different thread.
🤔 Are coroutines an event loop, like WebFlux?
No. Coroutines use a Dispatcher that assigns an available thread from a thread pool. When a coroutine resumes after a suspend, if the original thread isn’t free, a different thread may be assigned. It’s a different mechanism from an event loop, but in the sense that “the thread can change”, the ThreadLocal problem is exactly the same.
suspendfunprocessOrder(orderId: String) {
// 1️⃣ running on Thread-1
MDC.put("traceId", "abc123")
log.info("[${MDC.get("traceId")}] Order processing started") // ✅ abc123
delay(100) // suspend point! 💤
// 2️⃣ may resume on Thread-2
log.info("[${MDC.get("traceId")}] Order processing finished") // ❌ null!
}
Output:
[abc123] Order processing started // Thread-1
[null] Order processing finished // Thread-2 (no ThreadLocal value!)
sequenceDiagram
participant T1 as Thread-1
participant T2 as Thread-2
participant C as Coroutine
T1->>C: start execution
Note over T1: MDC.put("traceId", "abc123")
T1->>C: delay(100) - suspend
Note over T1: thread released
Note over C: 💤 waiting...
T2->>C: resume
Note over T2: MDC.get("traceId") = null ❌
T2->>C: finish execution
Same Problem as Reactor Context, Different Solution
This is fundamentally the same problem we saw with Reactor in Part 3: the thread changes, and the ThreadLocal value is gone. But the way it’s solved is different.
Environment
Cause of the problem
Solution
Reactor
thread switches between operators
Reactor Context (bound to the Subscriber)
Coroutine
thread switches on suspend/resume
CoroutineContext (bound to the coroutine)
Coroutines have their own context system, and it survives for the coroutine’s entire lifecycle.
Understanding CoroutineContext
A Context Is a Set of Elements
A CoroutineContext is a set of Elements. Each Element has a unique Key, and you can look up an Element in the context by its Key.
// the main Elements of a CoroutineContext
val context: CoroutineContext=
Job() +// manages the coroutine's lifecycle
Dispatchers.IO +// decides which thread runs it
CoroutineName("order-worker") // a name for debugging
// look up Elements by Key
val job = context[Job]
val dispatcher = context[CoroutineDispatcher]
val name = context[CoroutineName]
flowchart LR
CC["CoroutineContext"]
CC --> E1["Job"]
CC --> E2["Dispatcher"]
CC --> E3["CoroutineName"]
CC --> E4["..."]
Each Element can be looked up by its unique Key: context[Job], context[CoroutineDispatcher], context[CoroutineName]
Combining Contexts: the + Operator
Contexts can be combined with the + operator. If both sides contain an Element with the same Key, the one on the right overwrites the one on the left.
val base = Dispatchers.Default +CoroutineName("base")
// base = {Dispatcher: Default, CoroutineName: "base"}
Thanks to this inheritance mechanism, a context Element set in the parent propagates automatically to every child coroutine. The same goes for an Element carrying a traceId.
sequenceDiagram
participant T1 as Thread-1
participant T2 as Thread-2
participant TCE as ThreadContextElement
participant TL as ThreadLocal
Note over T1: coroutine starts/resumes
T1->>TCE: updateThreadContext()
TCE->>TL: ThreadLocal.set(value)
Note over T1: coroutine running...
T1->>TCE: (suspend) restoreThreadContext()
TCE->>TL: ThreadLocal.remove()
Note over T1: thread released
Note over T2: coroutine resumes
T2->>TCE: updateThreadContext()
TCE->>TL: ThreadLocal.set(value)
Note over T2: same value restored! ✅
The key idea: a ThreadContextElement stores the value in the CoroutineContext, and restores it into the ThreadLocal every time the thread changes.
Implementing One Ourselves
To understand what asContextElement() does internally, let’s build a custom ThreadContextElement by hand. This example keeps Spring Security’s SecurityContext alive across a coroutine.
classSecurityCoroutineContext(
// 1️⃣ capture the SecurityContext at instance-creation time (as the default value)
SecurityContextHolder.setContext(oldState) // restore the original value
}
}
}
💡 Key points
Every call to SecurityCoroutineContext()creates a new instance, capturing the SecurityContext as of that moment.
When the coroutine resumes → the captured value is set into the ThreadLocal
When the coroutine suspends → the ThreadLocal is restored to its previous value
Even when the thread changes, the value stored in the CoroutineContext is restored into the ThreadLocal every time.
Note: SecurityContextHolder is Spring Security’s ThreadLocal-based singleton. getContext() returns the current thread’s SecurityContext, and setContext() sets it.
Here’s the flow:
sequenceDiagram
participant App as Application
participant SCE as SecurityCoroutineContext
participant TL as ThreadLocal<br/>(SecurityContextHolder)
participant Code as Coroutine code
App->>SCE: create instance
Note over SCE: captures the current SecurityContext
App->>Code: launch(SecurityCoroutineContext()) starts (Thread-1)
SCE->>TL: updateThreadContext()
Note over TL: sets the captured value<br/>returns previous value as oldState
Code->>Code: code runs
Note over Code: SecurityContextHolder<br/>.getContext() ✅
Code->>Code: delay() - suspend
SCE->>TL: restoreThreadContext(oldState)
Note over TL: restores the previous value
Note over Code: Thread-1 released
rect rgb(255, 245, 238)
Note over Code: resumes on Thread-2
end
SCE->>TL: updateThreadContext()
Note over TL: sets the captured value again
Code->>Code: code continues
Note over Code: SecurityContextHolder<br/>.getContext() ✅
Code->>App: coroutine completes
SCE->>TL: restoreThreadContext(oldState)
Note over TL: restores the previous value
Usage:
// the current SecurityContext gets captured
launch(SecurityCoroutineContext()) {
// running on Thread-1
val userName = SecurityContextHolder.getContext().authentication.name
log.info("User: $userName") // ✅ prints correctly
delay(100) // thread switch happens!
// running on Thread-2 — the ThreadContextElement restored the SecurityContext
val sameUser = SecurityContextHolder.getContext().authentication.name
log.info("Still the same user: $sameUser") // ✅ same value!
}
Without the ThreadContextElement, SecurityContextHolder.getContext() after the delay() would return an empty SecurityContext, or throw.
MDCContext: a Dedicated Solution for SLF4J MDC
The kotlinx-coroutines-slf4j Library
Using MDC from coroutines is so common that there’s an official library for it.
There’s an important trap here. Even if you change a value with MDC.put() inside the coroutine, after the next suspension it gets restored to the original value.
MDC.put("traceId", "abc123")
launch(MDCContext()) {
log.info("[${MDC.get("traceId")}]") // abc123
MDC.put("traceId", "xyz789") // change the value!
log.info("[${MDC.get("traceId")}]") // xyz789
delay(100) // suspension!
// ❌ MDCContext restores the original value (abc123)
MDCContext()captures the MDC values at creation time. When the coroutine resumes after a suspension, it restores the captured values into the ThreadLocal. Changes made inside the coroutine are never reflected in that capture.
sequenceDiagram
participant Code as Coroutine code
participant MDCCtx as MDCContext
participant MDC as MDC (ThreadLocal)
Note over MDCCtx: captured {traceId: abc123} at creation
Code->>MDC: MDC.put("traceId", "xyz789")
Note over MDC: current value: xyz789
Code->>Code: delay(100) - suspend
Note over MDCCtx: restores the capture on resume
MDCCtx->>MDC: MDC.put("traceId", "abc123")
Note over MDC: current value: abc123 (change lost!)
The Fix: Create a Fresh Capture with withContext(MDCContext())
To keep a modified MDC value, you have to create a fresh capture with withContext(MDCContext()).
MDC.put("traceId", "abc123")
launch(MDCContext()) {
log.info("[${MDC.get("traceId")}]") // abc123
MDC.put("traceId", "xyz789")
// new MDCContext → captures the current MDC value (xyz789)
withContext(MDCContext()) {
delay(100)
log.info("[${MDC.get("traceId")}]") // ✅ xyz789
}
}
💡 Practical tip
In typical tracing, you use the traceId set at the start of the request as-is. You rarely need to change MDC values inside a coroutine, so you won’t hit this problem often. But if you’re dealing with dynamic values like baggage, watch out.
Reactor Context ↔ Coroutine Integration
The Complexity of Combining WebFlux and Coroutines
When you use Kotlin coroutines in Spring WebFlux, two context systems meet:
Reactor Context: bound to WebFlux’s Subscriber chain
CoroutineContext: bound to the Kotlin coroutine
@RestController
classOrderController {
@GetMapping("/orders/{id}")
suspendfungetOrder(@PathVariable id: String): Order {
// we're in coroutine land here
// how do we get the traceId that lives in the Reactor Context?
delay(100)
return orderService.findById(id)
}
}
ReactorContext: the Bridge Between the Two Worlds
The kotlinx-coroutines-reactor library provides a bridge called ReactorContext.
// Reactor → Coroutine: inject the Reactor Context into the CoroutineContext
mono {
// access the Reactor Context from inside the coroutine
val reactorCtx = coroutineContext[ReactorContext]?.context
val traceId = reactorCtx?.get<String>("traceId")
}
// Coroutine → Reactor: extract the Reactor Context from the CoroutineContext
val mono =mono(ReactorContext(Context.of("traceId", "abc123"))) {
// ...
}
Spring WebFlux’s Automatic Integration
The good news: Spring WebFlux handles this integration automatically. A controller method declared as a suspend function is internally wrapped in Reactor’s mono { } builder, and the Reactor Context is injected into the CoroutineContext for you.
// this is roughly what Spring does internally (simplified)
Correct! Spring converts the suspend function’s result into a Mono and plugs it into the Reactor pipeline. In the process, the mono builder automatically injects the current Reactor Context into the CoroutineContext. The net effect is that the suspend function’s body can access the Reactor Context.
flowchart TB
subgraph "HTTP request handling"
A["HTTP request"] --> B["WebFilter<br/>(creates Observation)"]
B --> C["stores traceId in<br/>Reactor Context"]
C --> D["Controller invocation"]
end
subgraph "Spring's internal conversion"
D --> E["wraps the suspend fun<br/>in mono { }"]
E --> F["injects ReactorContext into<br/>CoroutineContext"]
end
subgraph "Coroutine execution"
F --> G["suspend function runs"]
G --> H["Reactor Context reachable via<br/>coroutineContext[ReactorContext]"]
end
Functions that return a Flow are not suspend. Think of Flow as the coroutine world’s Flux:
Reactor
Coroutine
Mono (0–1 items)
return value of a suspend fun
Flux (0–N items)
Flow
A Flow is a cold stream — nothing runs when the function is called. Actual execution starts only when someone calls collect().
fungetAllOrders(): Flow<Order> =flow { ... } // only "defines" the Flow
// actual execution happens at collect time
getAllOrders().collect { order ->... }
Spring WebFlux converts the Flow into a Flux for processing, and the context is propagated automatically along the way.
Output (getOrder):
14:23:45.123 [abc123,111aaa] INFO OrderController - Fetching order: 123
14:23:45.230 [abc123,111aaa] INFO OrderController - Before DB lookup
14:23:45.456 [abc123,111aaa] INFO OrderController - Order fetched
Output (getAllOrders – Flow):
14:24:01.100 [def456,222bbb] INFO OrderController - Fetching all orders
14:24:01.150 [def456,222bbb] INFO OrderController - Emitting order: order-1
14:24:01.160 [def456,222bbb] INFO OrderController - Emitting order: order-2
14:24:01.170 [def456,222bbb] INFO OrderController - Emitting order: order-3
🤔 Why don’t I have to set MDCContext() myself?
In Spring Boot 3.2+ with spring.reactor.context-propagation=auto, the following happens automatically:
When the request starts, a Micrometer Observation creates the traceId/spanId
Those values are stored in the Reactor Context
When Spring invokes the suspend function, the ReactorContext (Element) is injected into the CoroutineContext
When the coroutine runs/resumes, the Context Propagation library reads the values from the Reactor Context and restores them into the ThreadLocal (MDC)
When the coroutine suspends, the ThreadLocal values are cleaned up
Step 4 is the key. The Context Propagation library handles the Reactor Context → ThreadLocal restoration automatically. Because the ReactorContext is injected into the CoroutineContext, this mechanism keeps working inside coroutine land too.
In short: MDCContext is a coroutine-native mechanism, while Context Propagation is the automatic Reactor Context ↔ ThreadLocal bridge. Spring Boot 3 enables the latter by default, so no extra setup is needed.
Since the whole pipeline is automated, you just write suspend functions.
The Full Context Propagation Flow
Let’s put together the entire flow of how context propagates from the HTTP request all the way into the coroutine.
sequenceDiagram
participant Client
participant Filter as WebFilter
participant RC as Reactor Context
participant Spring as Spring Framework
participant CC as CoroutineContext
participant CP as Context Propagation
participant TL as ThreadLocal (MDC)
participant Code as suspend function
Client->>Filter: HTTP request (Thread-1)
Filter->>RC: creates Observation, stores traceId
RC->>Spring: Controller invocation
Spring->>CC: injects ReactorContext (Element)
Note over Spring,CC: mono(Dispatchers.Unconfined) { ... }
Note over CC: CoroutineContext now<br/>contains ReactorContext
CC->>CP: coroutine starts
CP->>TL: Reactor Context → ThreadLocal restore
TL->>Code: suspend function runs (Thread-1)
Note over Code: MDC.get("traceId") ✅
Code->>Code: delay() - suspend
CP->>TL: ThreadLocal cleanup
Note over Code: Thread-1 released
rect rgb(255, 245, 238)
Note over RC: Reactor scheduler<br/>assigns Thread-2
Note over CC: CoroutineContext survives<br/>unchanged<br/>(ReactorContext included)
end
CC->>CP: coroutine resumes (Thread-2)
CP->>TL: Reactor Context → ThreadLocal restore
Note over CP: reads Reactor Context<br/>from ReactorContext and<br/>sets it into the ThreadLocal
TL->>Code: execution continues (Thread-2)
Note over Code: MDC.get("traceId") ✅
Code->>Client: response
The key point: even when the thread changes, the CoroutineContext survives unchanged. Inside the CoroutineContext is the ReactorContext (Element), and inside that is the Reactor Context (the store). When the coroutine resumes on a new thread, the Context Propagation library reads the values from the Reactor Context and restores them into the ThreadLocal.
Who Does What
Component
Role
Notes
Micrometer Observation
creates and manages traceId/spanId
stores values in the Reactor Context
Reactor Context
WebFlux’s context store
bound to the Subscriber chain
Context Propagation
automatic Reactor Context ↔ ThreadLocal restore
enabled by spring.reactor.context-propagation=auto
ReactorContext
a CoroutineContext.Element wrapping the Reactor Context
makes the Reactor Context reachable from coroutines
CoroutineContext
the coroutine’s context store
survives for the coroutine’s lifecycle
ThreadLocal (MDC)
used by the logging framework
bound to the current thread
💡 Terminology
Reactor Context: the Reactor library’s context store (reactor.util.context.Context)
ReactorContext: the CoroutineContext.Element provided by kotlinx-coroutines-reactor (kotlinx.coroutines.reactor.ReactorContext)
The names are confusingly similar:
Reactor Context = the store (holds the traceId and other values)
ReactorContext = the Element (wrapper) that carries that store inside a CoroutineContext
When Spring invokes a suspend function, it injects the ReactorContext (Element) into the CoroutineContext. The Reactor Context (store) rides inside it, which is why coroutine land can reach the Reactor Context at all.
Caveats and Troubleshooting
1. GlobalScope.launch Does Not Inherit Context
GlobalScope has an empty context, so the parent’s context never propagates into it.
@GetMapping("/orders/{id}")
suspendfungetOrder(@PathVariable id: String): Order {
log.info("Start") // ✅ traceId present
// ❌ wrong!
GlobalScope.launch {
log.info("Async work") // ❌ no traceId!
}
return orderService.findById(id)
}
The fix: use coroutineScope or an injected CoroutineScope.
@GetMapping("/orders/{id}")
suspendfungetOrder(@PathVariable id: String): Order {
coroutineScope {
launch {
log.info("Async work") // ✅ traceId inherited
}
}
return orderService.findById(id)
}
2. Passing Context When Using runBlocking
When starting a coroutine with runBlocking, you must pass the context explicitly.
// ❌ context not passed
runBlocking {
log.info("Work") // no traceId
}
// ✅ pass MDCContext
runBlocking(MDCContext()) {
log.info("Work") // traceId present
}
3. Context Is Preserved with async Parallelism Too
Parallel work via async also inherits the parent context automatically.
}.flowOn(Dispatchers.IO) // context preserved even with a different Dispatcher
5. Setting Up Context in Tests
In tests, you have to set up the context yourself.
@Test
fun`traceId should be preserved when fetching an order`() =runTest {
// set up MDC
MDC.put("traceId", "test-trace-id")
// run the test with MDCContext
withContext(MDCContext()) {
val result = orderController.getOrder("123")
// verify the logs contain the traceId
// ...
}
}
Conclusion
When you use Kotlin coroutines with Spring WebFlux, three context systems have to cooperate for the traceId to propagate correctly.
To recap the key points:
CoroutineContext is bound to the coroutine: its context Elements survive thread switches.
ThreadContextElement propagates ThreadLocals: with asContextElement() or MDCContext, ThreadLocal values are restored across suspend/resume.
ReactorContext bridges the two worlds: Spring WebFlux automatically injects the Reactor Context into the CoroutineContext.
Spring Boot 3.2+ auto-configuration: one line — spring.reactor.context-propagation=auto — gets you automatic propagation in most situations.
Never use GlobalScope: it doesn’t inherit context; use coroutineScope and structured concurrency instead.
flowchart LR
subgraph "Context propagation chain"
TL["ThreadLocal<br/>(MDC)"]
RC["Reactor<br/>Context"]
CC["Coroutine<br/>Context"]
end
TL <-->|"Context<br/>Propagation"| RC
RC <-->|"Reactor<br/>Context"| CC
TL <-->|"Thread<br/>ContextElement"| CC
style TL fill:#e1f5fe,color:#0f172a
style RC fill:#fff3e0,color:#0f172a
style CC fill:#f3e5f5,color:#0f172a
In the next post we’ll cover Java Agent vs Library Instrumentation — how a Java agent solves the “traceId lost in a library’s internal logging” problem mentioned in Part 3, and how the two approaches compare.