I know what you're thinking.
"I'm a great engineer who uses coroutines; I don't need to think about threads anymore. Definitely not thread locals."
I'm not going to lie, you're mostly right. BUT understanding thread locals and their implications on coroutines as well as the solution used for them could be good background knowledge to have in case you ever come across a similar issue.
The good news is: It's not that complicated.
Thread Locals, What Are They?
Back in the day if you're an engineer who wanted to impress everyone with some background work you might launch a thread.
Thread { doWork() }.start()
🎉🎉🎉 And the crowd goes absolutely wild 🎉🎉🎉
By now, we know that spinning up a new thread for every bit of work you want to do isn't a great idea but that's not what we are here to talk about today. If you're at all familiar with Compose locals, the concept of thread locals will be nothing new.
The idea is roughly: Thread Locals are a way to save a little pocket of data that you can set down and pick back up later as long as you're on the same thread. It's a way to avoid passing your data down 30 levels (prop drilling).
What that could look like:
val currentUser = ThreadLocal<String>() fun handleRequest(userId: String) { currentUser.set(userId) // now anything running on this thread can grab the userId // without us having to pass it through every function call doStepOne() doStepTwo() doStepThree() } fun doStepOne() { /* doesn't need userId, just passes through */ } fun doStepTwo() { /* doesn't need userId either */ } fun doStepThree() { // 3 levels deep but we can still grab it val user = currentUser.get() // "alice" log("Processing for $user") }
No prop drilling. You set it once and anything downstream on that thread can grab it. Logging frameworks like Timber use this pattern. You can set a tag in once place, then include it in a log in the next.
In Comes Coroutines
This hot new kid on the block doesn't care about your threads. You wanna do some background work? Go ahead, it'll give you a thread to run it on, but be warned, if you have any suspending code in there it can decide to... well... suspend your work and resume it on another thread.
Great for you cause now you don't need to think about threads and the costs of spinning them up and tearing them down.
But wait! What about thread locals? (queue a big piano "dun dun dun" sound)
If your async work can now just jump threads whenever it wants then you can't use thread locals anymore right?
WRONG. (And you're ridiculous for even thinking that.)
val currentUser = ThreadLocal<String>() currentUser.set("alice") launch(Dispatchers.Default) { // We're on some thread pool thread now. // Did "alice" come with us? println(currentUser.get()) // null 😬 delay(100) // suspend and resume on another thread println(currentUser.get()) // also null, or worse, // some stale value from whatever // ran on this thread before us }
Yeah that's not great.
Coroutine Context to the Rescue
This is where Coroutine Context comes in. You might've heard about context. Today isn't the day I'm going to break down context (mostly cause I don't know what I'm talking about) BUT my mental model for them currently goes a little like this:
I like to think of context as the handles on coroutines. Coroutines are wormy little guys. They go off and do their own thing. You don't really have much say in where they pause, resume, or switch threads. But you do have control over the context. You can grab a Job and decide to cancel or wait for the coroutine to finish. You can throw in a Dispatcher to tell it what thread pool to use. AND you can throw in elements to tell the coroutine to carry around a little info with it. Context elements are the handles, your little bit of control.
That's where
ThreadLocalElement comes in. The ThreadLocalElement is a coroutine context element that you can give to a coroutine and it will essentially ensure that before every resume point we read a thread local value and set it on whatever thread it's about to do work on.val currentUser = ThreadLocal<String>() currentUser.set("alice") launch(currentUser.asContextElement()) { // captures "alice" into the context println(currentUser.get()) // "alice" ✅ delay(100) // suspend, maybe resume on a different thread println(currentUser.get()) // still "alice" ✅ }
What's happening under the hood is a simple dance:
Right before the coroutine resumes on a thread, the coroutine's machinery:
- Grabs any ThreadLocalContextElements
- For each, it saves any values for that local that already exist on the current thread (just in case)
- It then takes the value in the ThreadLocalContextElement and uses that to set the thread local
- Once the coroutine suspends, the machinery takes the saved value from step 2 and restores the thread local. (in most cases, that value is null, effectively clearing that local)
Install on resume. Restore on suspend. That's it. The context is the source of truth and the thread local is just a projection of it onto whatever thread happens to be running the coroutine right now.
The Caveat
Once you convert a thread local to a context element, your work is done. If you then edit that thread local directly, it doesn't really matter. The context element is like a snapshot, like a photograph that gets passed around.
currentUser.set("alice") launch(currentUser.asContextElement()) { // snapshot taken println(currentUser.get()) // "alice" currentUser.set("bob") // ⚠️ direct mutation println(currentUser.get()) // "bob" (works... for now, no thread switch) delay(100) // suspend and resume println(currentUser.get()) // "alice" again 💥 // "bob" is gone. The context still has "alice" // and it just reinstalled it on resume. }
The context element's value is a
val. Immutable. It doesn't know or care that you scribbled "bob" onto the thread local. On the next resume it's going to install its photograph of "alice" and your scribble gets wiped.
If you actually need to change the value mid-coroutine, the right move is to swap in a new context element:
launch(currentUser.asContextElement()) { println(currentUser.get()) // "alice" withContext(currentUser.asContextElement("bob")) { println(currentUser.get()) // "bob" ✅ delay(100) println(currentUser.get()) // still "bob" ✅ survives the suspend } println(currentUser.get()) // "alice" again, outer context restored }
New snapshot, scoped to the
withContext block. The outer value comes back automatically when the block ends. No mutation, no surprises.TL;DR
Thread locals let you stash data on a thread without passing it everywhere. Coroutines break that because they can hop threads. The fix is
asContextElement(), which takes an immutable snapshot of the thread local value and installs it on whatever thread the coroutine runs on, before every resume, and cleans it up on every suspend. Just don't mutate the thread local directly after that. Let the context be the source of truth.