Looper, MessageQueue, and Handler Explained
The message-passing machinery behind Android's main thread: how Looper, MessageQueue, and Handler work together.
Why can you update the UI from runOnUiThread {} but crash if you touch a view
from a background thread? The answer lives in three cooperating classes:
Looper, MessageQueue, and Handler.
The big picture
A thread that wants to process events in a loop needs three things:
- A queue of work to do —
MessageQueue. - A loop that pulls work off the queue and runs it —
Looper. - A way to post work onto the queue —
Handler.
The main thread sets all of this up for you before your Application.onCreate()
ever runs.
Looper: the event loop
A Looper is a thread-local object that runs an infinite loop, blocking until a
message is available, then dispatching it. Conceptually:
// Roughly what Looper.loop() does
fun loop() {
val queue = myLooper().queue
while (true) {
val msg = queue.next() // blocks until a message is ready
msg.target.dispatchMessage(msg) // target is the Handler
msg.recycle()
}
}
The main thread’s loop is started by the framework via Looper.prepareMainLooper()
and Looper.loop(). Because this loop never returns, your app stays alive and
responsive to input.
MessageQueue: the prioritized queue
MessageQueue holds Message objects sorted by their when timestamp — the
time they should run. Posting a delayed message simply inserts it at the right
position.
When the queue has nothing due yet, next() blocks efficiently using a native
mechanism (epoll) rather than busy-waiting, so an idle main thread uses no CPU.
Handler: posting and handling work
A Handler is bound to a Looper at construction. You use it to enqueue either a
Runnable or a Message:
// Bound to the main thread's Looper
val mainHandler = Handler(Looper.getMainLooper())
fun loadThenShow() {
thread {
val result = expensiveWork() // background thread
mainHandler.post { // back on the main thread
textView.text = result
}
}
}
When the Looper dispatches a message, it calls back into the Handler that
posted it — that’s the msg.target in the loop above.
How this explains the rules
- “Only touch views on the main thread” — view code assumes it runs inside
the main
Looper. Calls from other threads break that assumption. - ANRs — if a message takes too long (e.g. blocking I/O on the main thread), the queue backs up and input messages aren’t processed; the system shows “Application Not Responding.”
postDelayed— just inserts a message with a futurewhentimestamp.
Coroutines fit on top
Modern code rarely creates raw Handlers. Dispatchers.Main is implemented on
top of this same machinery — it posts continuations to the main Looper.
lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { expensiveWork() }
textView.text = result // resumes on Dispatchers.Main → the main Looper
}
Key takeaways
- The main thread is a
Looperdraining aMessageQueueforever. - A
Handlerposts work onto a specific thread’s queue. - Long tasks on the main
Loopercause jank and ANRs. - Coroutine main dispatch is built on the very same loop.