skip to content

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?

level: seniorimportance: nice to knowfreq 25%

answer

  1. AbstractCoroutineContextElement + companion Key
  2. Read with coroutineContext[Key], type-safe
  3. Follows coroutine across threads (ThreadLocal does not)
  4. Immutable: re-add to update
  5. asContextElement / ThreadContextElement for MDC interop

basics

~20 s

Make 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 s

Extend 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 lines
kotlin
import 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

for a junior

Can read an existing custom element via coroutineContext[Key] but may not design one.

for a middle

Defines a custom element with AbstractCoroutineContextElement + companion Key and adds it via +.

for a senior

Explains why it beats ThreadLocal across dispatcher hops and uses asContextElement for MDC interop.

for a principal

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

context