Looper, Handler & Threading Explained
ANDROID › System
Android's UI toolkit is single-threaded: only one thread, the main thread, is allowed to touch views. That thread doesn't run your code top to bottom and exit, it runs an infinite loop that pulls one piece of work off a queue at a time, forever, for as long as the app is alive. Three pieces make that loop work together:
- **MessageQueue**: an ordered queue of pending work for one specific thread - **Looper**: owns that queue and runs the loop, dispatching each message in order - **Handler**: your API for putting work onto a specific Looper's queue, from any thread
Every button tap, every invalidate() call, every coroutine resumed on Dispatchers.Main, all of it eventually becomes a message dispatched by the main Looper. Interviewers reach for this topic because it's the foundation under three things they actually care about: why the UI thread must never block, how work gets scheduled onto other threads, and what a coroutine dispatcher is really doing underneath the suspend keyword. Once this loop is solid, ANRs, Handler quirks, and coroutine internals stop being separate topics and become the same mechanism seen from different angles.
A Looper is thread-local: a thread has at most one, and it doesn't get one automatically. Only the main thread has its Looper prepared for you at process start; every other thread starts out with no Looper and can't run a Handler on it until it gets one. To turn a plain background thread into one that can process messages, you call Looper.prepare() to create that thread's Looper and MessageQueue, then Looper.loop() to start dispatching:
thread {
Looper.prepare() // creates this thread's Looper + queue
val handler = Handler(Looper.myLooper()!!) { msg ->
Log.d("BG", "got ${msg.what}")
true
}
Looper.loop() // blocks here, dispatching until quit()
}
Looper.myLooper() reads the calling thread's Looper. It's forgiving rather than strict: on a thread that never called prepare(), it doesn't throw, it just returns null, so code that reads it needs a null check rather than an assumption that a Looper exists. prepare() itself is stricter: calling it a second time on a thread that already has a Looper throws, because a thread can only ever own one.
That prepare() then loop() sequence is boilerplate you rarely write by hand, HandlerThread does it for you. It's a Thread subclass that creates its own Looper and starts the message loop automatically as soon as it starts running:
val ht = HandlerThread("bg-worker").also { it.start() }
val handler = Handler(ht.looper)
handler.post { /* runs serialized on ht's single thread */ }
ht.quitSafely() // stop the loop when you're done with it
Once ht.looper exists, any thread in your app, the main thread, another background thread, anywhere, can build a Handler on it and post work; making that safe from any caller is exactly the Handler's job. What you get is a single dedicated thread that processes a stream of tasks strictly one at a time, in the order they arrived, which is a different guarantee from a thread pool, where several tasks can run concurrently and finish out of order.
Posting to the main thread from anywhere else follows the same shape: bind a Handler to Looper.getMainLooper().
Handler(Looper.getMainLooper()).post {
textView.text = "Updated on the UI thread"
}
The old no-arg Handler() constructor is deprecated for exactly the ambiguity this avoids. With no Looper passed in, it implicitly grabs whatever Looper belongs to the thread that happens to be constructing it, which is fragile two ways: on a thread with no prepared Looper it throws at construction time, and even when it doesn't throw, the target thread is invisible at the call site, it depends entirely on where that line of code happens to run. Passing the Looper explicitly removes both problems: Looper.getMainLooper() for the UI thread, or a specific HandlerThread's .looper for background work, either way the binding is visible and correct regardless of which thread does the constructing.
A Handler doesn't only post Runnables. It can also post a Message, a small structured bundle of fields, what, arg1, arg2, an object payload, handled in handleMessage() instead of running as a closure. The two aren't really separate mechanisms: internally, a Runnable you post() gets wrapped in a Message before it ever reaches the queue, Message is the actual unit the MessageQueue stores and the Looper dispatches.
val msg = Message.obtain(handler, MSG_UPDATE, someArg, 0)
handler.sendMessage(msg)
Under heavy, repeated posting, scroll callbacks, drag events, allocating a fresh Message object for every single post adds real garbage-collection pressure. Android maintains a small global pool of reusable Message instances for this reason. Message.obtain() pulls a recycled instance from that pool when one's available and resets its fields, instead of allocating Message() fresh every time; once a Handler finishes dispatching a Message, it's recycled back into the pool automatically. Prefer obtain() over the constructor anywhere posting happens frequently.
postDelayed doesn't spin up a timer or put the Looper to sleep on a separate thread. When you call it, the Handler computes an absolute wake time, SystemClock.uptimeMillis() plus the delay you gave it, stamps the Message with that time, and inserts it into the MessageQueue at the position that keeps the queue sorted by that time, not by the order you called post.
handler.postDelayed({ Log.d("T", "B") }, 100L) // due at now+100ms
handler.post { Log.d("T", "A") } // due at now, immediate
// dispatch order: "A" then "B", regardless of call order
The Looper's loop just keeps looking at the head of the queue: if the next message's due time is still in the future, the thread sleeps until then; the instant it's due, dispatch happens. That's why a Message posted later but due sooner still jumps ahead of one posted earlier with a longer delay attached to it.
A common real need: the user is typing in a search box, and you want to fire a network request only after they've paused for, say, 300 milliseconds, not on every keystroke. That's debouncing, and the Handler API supports it directly: post the work with a delay, and cancel any previous pending post before scheduling the new one.
private val searchRunnable = Runnable { performSearch(query) }
fun onQueryChanged(query: String) {
handler.removeCallbacks(searchRunnable) // cancel the pending one, if any
handler.postDelayed(searchRunnable, 300L) // schedule a fresh one
}
removeCallbacks(runnable) removes any pending message wrapping that exact Runnable from the queue, if it hasn't dispatched yet. It matches by reference, not by what the code inside does, so passing a different Runnable instance, even one with identical code, won't match anything pending. That's why the debounce pattern above stores searchRunnable as a single reused property instead of building a fresh lambda inside onQueryChanged on every call, a fresh lambda would defeat the cancellation entirely.
Looper.quit() and Looper.quitSafely() both stop a Looper's loop, but differently. quit() stops immediately, discarding every pending message, including ones due right now that haven't run yet. quitSafely() first dispatches whatever's already due, then discards only the messages scheduled for the future.
val ht = HandlerThread("worker").also { it.start() }
ht.looper.quit() // drops ALL pending messages immediately
// vs
ht.looper.quitSafely() // finishes due messages, drops only future ones
The main Looper can't be quit at all, doing so would tear down the app's entire message loop. A HandlerThread you own is different: once you're finished with it, shut it down with quitSafely() so its thread doesn't leak and sit around indefinitely.
Sometimes you want to run low-priority work, warming a cache, flushing analytics, only when the main thread genuinely has nothing more urgent queued, rather than on a timer that might interrupt something important. MessageQueue.addIdleHandler is built for exactly that.
Looper.myQueue().addIdleHandler {
warmUpCache()
false // false = run once; true = keep being called on future idles
}
Android invokes your IdleHandler when the queue has no message ready to dispatch right now, no due, immediate work sitting at the head, even though there may still be delayed messages waiting for a future time. Returning false removes the handler after this single call; returning true keeps it registered to fire again the next time the queue goes idle. It's a genuinely different mechanism from posting a Runnable: a posted Runnable gets queued alongside everything else and runs in its turn, an IdleHandler waits for a quiet moment instead.
An ANR, Application Not Responding, fires when the main thread is blocked too long to service something time-critical: roughly 5 seconds for an unhandled input event, around 10 seconds for a broadcast receiver's foreground work, and a missed deadline for a service or scheduled job. The reason a single slow operation can trigger any of these comes straight from the loop you've already learned: the main Looper never stops running, so if the message currently being dispatched does something slow, synchronous disk I/O, a network call made directly, a heavy computation, nothing else on that thread can happen until it returns, not the next message, not the next frame, not the next input event.
// BAD: blocks the main thread's Looper directly
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val bytes = File(filesDir, "data.bin").readBytes() // synchronous I/O
}
The fix is always the same shape: move the slow work off the main Looper, onto a background thread or a coroutine dispatcher, then hop back with a Handler or Dispatchers.Main to apply the result once it's ready.
Modern Android code rarely touches Handler directly, but it's never far underneath. Dispatchers.Main is implemented on top of a Handler bound to Looper.getMainLooper(); resuming a coroutine on the main thread is, under the hood, posting a message onto that exact same queue you've been reading about this whole lesson.
lifecycleScope.launch(Dispatchers.Main) {
textView.text = "via coroutine"
}
// roughly equivalent to:
Handler(Looper.getMainLooper()).post {
textView.text = "via Handler"
}
WorkManager is a different tool, not a wrapper around this machinery. Everything built on Handler and Looper, including coroutine delays, lives purely in process memory and vanishes the moment the process dies. WorkManager persists the work request to durable storage instead, so it survives process death and even a device reboot, which is why it's the right answer for "run this reliably later," while Handler and coroutine dispatchers are the right answer for "run this now, on a specific thread."
If an interviewer asks you to explain threading on Android, that's the whole shape of the answer: one Looper per thread running a MessageQueue, a Handler as the API for getting work onto a specific one, the main thread's Looper as the thing you must never block, and everything else, coroutine dispatchers, WorkManager, debouncing, idle work, built as a convenience layer on top of those same three primitives.