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:

  1. A queue of work to do — MessageQueue.
  2. A loop that pulls work off the queue and runs it — Looper.
  3. 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 future when timestamp.

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 Looper draining a MessageQueue forever.
  • A Handler posts work onto a specific thread’s queue.
  • Long tasks on the main Looper cause jank and ANRs.
  • Coroutine main dispatch is built on the very same loop.