Design a custom CoroutineContext element to carry request-scoped data (e.g. a trace id) through coroutines. How does this compare to a ThreadLocal, and what are the pitfalls?
answer
- AbstractCoroutineContextElement + companion Key
- Read with coroutineContext[Key], type-safe
- Follows coroutine across threads (ThreadLocal does not)
- Immutable: re-add to update
- asContextElement / ThreadContextElement for MDC interop
basics
~20 sMake a small class that extends the coroutine-context element base and give it a companion Key. Add it to a coroutine's context and read it anywhere with context[Key]. Unlike a ThreadLocal, it follows the coroutine across threads automatically.
solid answer
~40 sExtend AbstractCoroutineContextElement(Key) and expose a companion object Key : CoroutineContext.Key<T>. Add an instance with + when launching, and read it via coroutineContext[Key]. Because the context is inherited by child coroutines and travels with them across dispatcher hops, the data is available regardless of which thread currently runs the coroutine — solving the core problem with ThreadLocals, which are bound to a physical thread and get lost when a coroutine suspends and resumes elsewhere. The pitfall: the element is immutable, so updating per-step values means re-adding a new element; and if you genuinely need ThreadLocal interop (e.g. SLF4J MDC), use ThreadLocal.asContextElement() / ThreadContextElement so the value is installed/removed around each resumption. Custom elements are the type-safe, structured-concurrency-friendly way to propagate ambient data.
code
kotlin · 15 linesimport kotlin.coroutines.*
import kotlinx.coroutines.*
class Tenant(val id: String) : AbstractCoroutineContextElement(Tenant) {
companion object Key : CoroutineContext.Key<Tenant>
}
suspend fun tenantId(): String? = coroutineContext[Tenant]?.id
fun main() = runBlocking(Tenant("acme")) {
launch(Dispatchers.Default) { println(tenantId()) } // acme (inherited)
withContext(coroutineContext + Tenant("globex")) { // "update" by re-adding
println(tenantId()) // globex
}
}go deeper
Can read an existing custom element via coroutineContext[Key] but may not design one.
Defines a custom element with AbstractCoroutineContextElement + companion Key and adds it via +.
Explains why it beats ThreadLocal across dispatcher hops and uses asContextElement for MDC interop.
Designs ambient-data propagation as context elements, weighing ThreadContextElement cost and structured-concurrency implications.
## Defining a custom element A custom element is just a class that: 1. Extends `AbstractCoroutineContextElement(Key)` (which wires up `key` and the default `get/fold/plus/minusKey`). 2. Declares a companion `Key : CoroutineContext.Key<T>` so lookups are type-safe. ```kotlin import kotlin.coroutines.* import kotlinx.coroutines.* class TraceId(val value: String) : AbstractCoroutineContextElement(TraceId) { companion object Key : CoroutineContext.Key<TraceId> } suspend fun currentTrace(): String? = coroutineContext[TraceId]?.value fun main() = runBlocking { withContext(TraceId("abc-123")) { launch(Dispatchers.IO) { // child inherits TraceId println(currentTrace()) // abc-123, even on a different thread }.join() } } ``` ## Why this beats a ThreadLocal - A **`ThreadLocal`** stores data on the *physical thread*. A coroutine can **suspend on one thread and resume on another** (especially after a dispatcher switch), so the value silently disappears or leaks across requests when threads are pooled. - A **context element** is attached to the *coroutine*, inherited by children, and carried across suspensions/dispatcher hops. It is type-safe (`coroutineContext[TraceId]` returns `TraceId?`) and participates in structured concurrency. ## Pitfalls 1. **Immutability**: the element value can't be reassigned. To "update" it, add a new element: `withContext(coroutineContext + TraceId(newId)) { ... }`. 2. **ThreadLocal interop (MDC, security context)**: if a library *requires* a real `ThreadLocal` (e.g. SLF4J `MDC`), use `ThreadLocal.asContextElement(value)` to bridge — this returns a `ThreadContextElement` that installs the value into the thread-local on each resume and restores it on suspend. ```kotlin val mdcUser = ThreadLocal<String?>() withContext(mdcUser.asContextElement(value = "user-7")) { // mdcUser.get() == "user-7" on whatever thread runs this block } ``` 3. **Don't smuggle a `Job`/scope as your data element** — keep custom elements pure data; mixing lifecycle into ad-hoc elements muddies cancellation. 4. **Performance**: lookups walk the context; keep contexts small and avoid per-call re-adds in hot paths. ## ThreadContextElement `ThreadContextElement<S>` is the general SPI behind `asContextElement`: its `updateThreadContext`/`restoreThreadContext` run around every continuation resumption, letting you sync a coroutine value into and out of any thread-bound state. This is the correct mechanism whenever thread-local-based libraries must observe coroutine-scoped data.
- How do you propagate a value into SLF4J's MDC inside coroutines?Use ThreadLocal.asContextElement() (a ThreadContextElement) so the value is set into the MDC thread-local on each resume and cleared on suspend.
- Can you mutate a custom element's value mid-coroutine?Not the element itself — it's immutable. You add a new element (withContext(coroutineContext + NewValue)) to override it for an inner scope.
A ThreadLocal is a note pinned to a desk (thread); a context element is a note in the worker's pocket (coroutine) that goes with them when they move desks.
saying these in an interview costs you the question
- Recommending a plain ThreadLocal to carry per-request data across suspensions
- Forgetting the companion Key (lookups won't be type-safe / won't compile cleanly)
- Treating a custom element as mutable state
- Not knowing asContextElement/ThreadContextElement for thread-local interop
- Stuffing a Job or scope into a data element