DEV Community

Cover image for Day 10/100: Looper, Handler, MessageQueue — The Machinery Android Runs On
Hoang Son
Hoang Son

Posted on

Day 10/100: Looper, Handler, MessageQueue — The Machinery Android Runs On

This is Day 10 of my 100 Days to Senior Android Engineer series. Week 2 theme: Process & Memory. Each post: what I thought I knew → what I actually learned → interview implications.


🔍 The concept

Every Android developer knows: don't do heavy work on the main thread.

But fewer can answer the follow-up: what is the main thread actually doing when it's not running your code?

The answer is Looper, Handler, and MessageQueue — a trio that has been the beating heart of Android's threading model since API 1. Coroutines, RxJava, LiveData, Compose — they all ultimately dispatch work through this same machinery.

Understanding it doesn't just satisfy curiosity. It changes how you read stack traces, diagnose ANRs, and understand why certain operations must happen on the main thread at all.


💡 What I thought I knew

My mental model was: main thread runs UI code, background threads run everything else, Handler.post() is how you get back to the main thread from a background thread. Coroutines do this automatically with Dispatchers.Main.

That's accurate as far as it goes. But it's describing the interface, not the mechanism.


😳 What I actually learned

The main thread is a loop — literally

When your Android app starts, the framework calls Looper.prepareMainLooper() and then Looper.loop(). That second call never returns. It's a loop that runs for the entire lifetime of your app:

Looper.loop()  simplified pseudocode:

while (true) {
    val message = messageQueue.next()  // blocks if queue is empty
    message.target.dispatchMessage(message)
    message.recycle()
}
Enter fullscreen mode Exit fullscreen mode

The main thread is permanently blocked on messageQueue.next(). When a Message arrives in the queue, the loop wakes up, dispatches it to its target Handler, and goes back to waiting.

Every UI operation, every touch event, every frame draw, every Coroutine continuation on Dispatchers.Main — they all arrive as Message objects in this queue.


The three components and how they connect

┌─────────────────────────────────────────┐
│              Main Thread                │
│                                         │
│  MessageQueue  ←───── Handler.post()    │
│       │                    ↑            │
│       │             (from any thread)   │
│       ↓                                 │
│    Looper.loop()                        │
│       │                                 │
│       ↓                                 │
│  Handler.dispatchMessage()              │
│       │                                 │
│       ↓                                 │
│  Your callback/runnable runs here       │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

MessageQueue — a priority queue ordered by time. Messages scheduled for "now" go to the front; messages with a delay go further back. Thread-safe by design — any thread can enqueue messages safely.

Looper — the loop that drains the queue. Each thread can have at most one Looper. The main thread's Looper is created by the framework. Background threads don't have one by default — you create it explicitly with Looper.prepare() and Looper.loop().

Handler — the bridge. Associated with a specific Looper (and therefore a specific thread). When you call handler.post(runnable), it wraps the Runnable in a Message, adds your Handler as the target, and inserts it into the associated Looper's MessageQueue.


Why the main thread has to be a single loop

Touch events, layout passes, frame draws, accessibility events — all of these need to happen in a predictable, serialized order. A multi-threaded UI would require locking every view operation, which is both complex and slow.

The single-threaded loop model is a deliberate design decision: simplicity and correctness over raw parallelism. The contract is: all UI work happens on one thread, in the order it's enqueued.

This is also why ANR exists — the loop is stuck processing a slow message, so new messages (like the user's touch event) can't be dispatched. The system notices nothing is being processed and reports ANR.


How Coroutines use this machinery

Dispatchers.Main is not magic. It's a CoroutineDispatcher that posts work to the main thread's Handler:

// Simplified — what Dispatchers.Main actually does internally
object MainDispatcher : CoroutineDispatcher() {
    private val handler = Handler(Looper.getMainLooper())

    override fun dispatch(context: CoroutineContext, block: Runnable) {
        // Posts a Message to the main MessageQueue
        handler.post(block)
    }
}
Enter fullscreen mode Exit fullscreen mode

Every withContext(Dispatchers.Main), every launch(Dispatchers.Main), every StateFlow collection on the main thread — they all eventually call handler.post() under the hood.

This means:

// These two are functionally equivalent:
Handler(Looper.getMainLooper()).post {
    textView.text = "updated"
}

viewModelScope.launch(Dispatchers.Main) {
    textView.text = "updated"
}
Enter fullscreen mode Exit fullscreen mode

The Coroutines version is safer and more ergonomic, but the mechanism is the same. Knowing this is useful when you're debugging a dispatch delay or reading a stack trace from a crash that happened during a Coroutine continuation.


Creating a background Looper thread

Sometimes you need a dedicated background thread with its own message queue — for example, a worker that processes camera frames in order, or a serial executor for database writes that must be sequenced.

// The manual way — rarely needed but important to understand
class SerialWorkerThread : Thread() {
    lateinit var handler: Handler

    override fun run() {
        Looper.prepare()  // Creates a Looper for this thread
        handler = Handler(Looper.myLooper()!!)
        Looper.loop()     // Starts the message loop — blocks here
    }

    fun quit() {
        handler.looper.quit()  // Allows loop() to return and thread to finish
    }
}

val worker = SerialWorkerThread().apply { start() }
worker.handler.post { /* runs on the worker thread, in order */ }
Enter fullscreen mode Exit fullscreen mode

In practice, you'd use HandlerThread — the framework's built-in version of this pattern:

val handlerThread = HandlerThread("CameraWorker").apply { start() }
val cameraHandler = Handler(handlerThread.looper)

cameraHandler.post { processCameraFrame(frame) }

// When done:
handlerThread.quitSafely()
Enter fullscreen mode Exit fullscreen mode

HandlerThread is used internally by many Android APIs — including CameraDevice callbacks and SurfaceTexture.


postDelayed, postAtTime, and the scheduling model

MessageQueue is a priority queue ordered by when each message should be processed:

val handler = Handler(Looper.getMainLooper())

// Runs immediately (next iteration of the loop)
handler.post { doSomething() }

// Runs after 500ms
handler.postDelayed({ doSomethingLater() }, 500)

// Runs at a specific uptime millisecond
handler.postAtTime({ doSomethingAtTime() }, SystemClock.uptimeMillis() + 500)
Enter fullscreen mode Exit fullscreen mode

An important nuance: postDelayed guarantees the message won't run before the delay — but it doesn't guarantee it runs exactly at the delay. If the main thread is busy processing other messages when the delay expires, your message waits until the current message finishes.

This is why postDelayed for animations is imprecise, and why Choreographer (which uses its own VSYNCsynchronized message) exists for frame-rate animation.


The IdleHandler — runs when nothing else is queued

MessageQueue has a feature that almost nobody knows about:

Looper.myQueue().addIdleHandler {
    // Runs when the MessageQueue is empty — main thread has nothing to do
    // Return true to keep being called, false to remove
    performLowPriorityWork()
    false  // Remove after one call
}
Enter fullscreen mode Exit fullscreen mode

IdleHandler runs when the message queue drains — when the main thread is genuinely idle. Jetpack's ProcessLifecycleOwner uses this. Some layout inflation optimizations use this. It's a hook for work that should happen eventually but shouldn't compete with user interactions.


Reading a stack trace with this mental model

When you see an ANR or a crash on the main thread, the stack trace usually shows something like:

at android.os.MessageQueue.nativePollOnce(Native Method)
at android.os.MessageQueue.next(MessageQueue.java:335)
at android.os.Looper.loop(Looper.java:183)
at android.app.ActivityThread.main(ActivityThread.java:7623)
Enter fullscreen mode Exit fullscreen mode

This is the normal, healthy state of the main thread — blocked in nativePollOnce, waiting for the next message. When you see this at the bottom of a trace (the thread origin), everything above it is a Message that was dispatched from the queue.

An ANR trace shows the main thread stuck in your code — somewhere above the Looper line. The question becomes: what Message was dispatched that is taking too long? That's the frame you look for above Looper.loop().


🧪 The mental model that stuck

Think of the main thread as a single-lane road with a traffic light:

  • The MessageQueue is the queue of cars waiting at the light
  • The Looper is the traffic light — it lets one car through at a time
  • Each Message is a car — it drives through, does its thing, and leaves
  • Your touch event is a car in the queue — it can only go when the light is green
  • An ANR is a car that stalled in the intersection — nothing else can move

Every optimization technique for the main thread — offloading to coroutines, using RecyclerView instead of ListView, prefetching layouts — is fundamentally about keeping the intersection clear so touch events get through immediately.


❓ The interview questions

Question 1 — Mid-level:

"How does Handler.post() work, and how is it different from running code on a background thread?"

Handler.post(runnable) wraps the Runnable in a Message, sets the Handler as the target, and inserts it into the MessageQueue of the Looper associated with that Handler. When the Looper's loop reaches that message, it calls handler.dispatchMessage(), which runs the Runnable.

The key difference from a background thread: Handler.post() is serialized — messages are processed one at a time, in order. A background thread pool runs tasks concurrently. For UI operations, serialization is required. For CPU-bound parallel work, a thread pool is faster.


Question 2 — Senior:

"Why can't you update the UI from a background thread, technically? What would happen if you tried?"

Android's View system is not thread-safe. There are no locks around view properties. If two threads simultaneously write to TextView.setText(), you get race conditions — partial state, corrupted layout, visual glitches, or crashes.

The single-threaded model enforces this: all view operations must go through the main thread's Looper. ViewRootImpl.checkThread() explicitly throws CalledFromWrongThreadException if a view is modified from a non-main thread.

android.view.ViewRootImpl$CalledFromWrongThreadException:
Only the original thread that created a view hierarchy can touch its views.
Enter fullscreen mode Exit fullscreen mode

This check was added as a deliberate safeguard — without it, the error would be a silent race condition that's nearly impossible to reproduce consistently.


Question 3 — Senior:

"Explain how Dispatchers.Main in Coroutines relates to the Android main thread model. What happens if the main thread is busy when a coroutine tries to resume on Dispatchers.Main?"

Dispatchers.Main is implemented using Handler(Looper.getMainLooper()). Resuming a coroutine on Dispatchers.Main posts a Message to the main MessageQueue.

If the main thread is busy — processing a long-running message, running a synchronous network call, traversing a large view hierarchy — the coroutine's resume message sits in the queue and waits. The coroutine does not resume until the current message finishes and the loop reaches the next message.

viewModelScope.launch(Dispatchers.IO) {
    val data = repository.fetchData()  // runs on IO thread pool

    // Posts a Message to the main MessageQueue
    withContext(Dispatchers.Main) {
        // This line only executes when the main thread
        // finishes its current message and gets to this one
        binding.textView.text = data.title
    }
}
Enter fullscreen mode Exit fullscreen mode

Practical implication: if you're seeing delayed UI updates even though your data is ready, the main thread may be congested with other messages. Profile with Perfetto to see what's blocking the queue.


What surprised me revisiting this

  1. Dispatchers.Main being a Handler post — I knew Coroutines dispatch to the main thread, but I hadn't connected it mechanically to Handler.post(). Seeing the implementation made the whole mental model click.

  2. IdleHandler being used by Jetpack internally — I'd never consciously looked at it before. It's a clean solution for work that needs to happen but shouldn't compete with user input. More useful than postDelayed(runnable, 0) for "run after the current frame."

  3. ANR traces making sense — Before understanding the Looper model, ANR traces looked like noise. Now I can identify the specific Message that caused the stall. Stack traces went from intimidating to readable.


Tomorrow

Day 11 → ANR: 3 types, how to reproduce them, and how to actually read an ANR trace in production.

Have you ever used Handler and Looper directly, or do you always reach for Coroutines? Is there still a place for the raw API?


Day 9: onSaveInstanceState vs ViewModel vs SavedStateHandle

Top comments (0)