Looper, Handler, MessageQueue
Three classes that have not changed in fifteen years: a thread-local loop, a priority queue ordered by due time, and the native epoll wait that lets an idle app consume nothing.
Every asynchronous mechanism in Android — , View.post, coroutines on Dispatchers.Main,
, HandlerThread, WorkManager's internal executor — eventually puts a message on a
and wakes a . Learn the three classes and the rest stop being separate subjects.1
TL;DR#
- A Looper is a thread-local loop that pulls messages from a MessageQueue and dispatches them.
One Looper per thread;
Looper.prepare()thenLooper.loop(). - A Message has a
what, optionalarg1/arg2, anobj, aBundle-ishdataand a due time. The queue is ordered by due time, not by insertion order. - A Handler is bound to a Looper and is how you post messages to it, from any thread.1
- When the queue is empty, the Looper blocks in native code on an
epollwait, so an idle app uses no CPU. Posting a message writes to a pipe/eventfd that wakes it. postAtFrontOfQueueand sync barriers are how the framework reorders work — and howChoreographergives frames priority over ordinary messages.
Mental model#
A restaurant kitchen.
The pass is the MessageQueue: tickets are clipped in order of when the dish is due, not when the order arrived. The expediter is the Looper: they take the next ticket whose time has come and hand it to a cook, then repeat. A waiter is a Handler: anyone in the building can hand the expediter a ticket through a waiter bound to that kitchen.
Where the analogy breaks#
The expediter does not "check" the pass every few seconds. When there is nothing due, the kitchen goes to sleep — the expediter blocks on a doorbell that rings when a new ticket is clipped, or when the next ticket's time arrives. That is why an idle Android app can sit at zero CPU for hours and still wake up precisely when the next message is due.
The big picture#
Text description of this diagram
An interactive MessageQueue simulator. Messages posted to a Looper line up on a conveyor belt ordered by due time. postDelayed places a message further down the belt; postAtFrontOfQueue jumps it to the head, except when a sync barrier is posted; a sync barrier holds every synchronous message while asynchronous messages such as input pass through. Dispatching a message shows the main thread's busy time against a 16 ms frame budget, turning red when a message overruns it and raising an ANR banner when a task runs for several seconds. A worker thread can post into the same queue, which is how background results reach the main thread.
Try this sequence: post doFrame twice, post a delayed message for 1000 ms, then post a sync barrier.
The barrier stops the two doFrame messages but lets the async input event through. That is exactly
what the framework does on every frame.
Deep dive#
The three classes#
// A background thread with its own Looper.
class DownloadThread : Thread("downloads") {
lateinit var handler: Handler
private set
override fun run() {
Looper.prepare() // creates this thread's MessageQueue
handler = Handler(Looper.myLooper()!!) // bound to this thread
Looper.loop() // never returns
}
}
val worker = DownloadThread().apply { start() }
worker.handler.post { downloadAll() } // runs on the worker threadThe invariant is strict: one Looper per thread, created by Looper.prepare(), and it must be
prepared before Handler is constructed on that thread.1 The main thread does this for you inside
ActivityThread.main().
HandlerThread is the same thing wrapped in a Thread subclass, with a latch so that
getLooper() blocks until the loop is ready:
val thread = HandlerThread("io").apply { start() }
val handler = Handler(thread.looper)
handler.post { readFile() }
// Later:
thread.quitSafely()The message#
A Message is a small object, pooled to avoid allocation during scrolling:
| Field | Purpose |
|---|---|
what | an int identifier you define |
arg1, arg2 | two ints, cheap to carry |
obj | an arbitrary object; beware holding large graphs |
data | a Bundle for a few values |
target | the Handler that will receive it |
when | the due time, in SystemClock.uptimeMillis() |
Message.obtain() pulls from a pool of up to 50 messages per process; Handler.obtainMessage()
does the same. That pooling is why posting a message during a scroll does not allocate.
val msg = handler.obtainMessage(MSG_PROGRESS, progress, 0)
handler.sendMessage(msg)
// Or, with a Runnable:
handler.postDelayed({ refresh() }, 500)postDelayed uses uptimeMillis, which stops counting while the device is in deep sleep. It is not
an alarm clock — for work that must happen at a wall-clock time, use AlarmManager.2 This distinction
is the source of the classic "my timer stops when the screen is off" bug.
How the queue is ordered#
The queue is a singly linked list sorted by when. Insertion walks from the head until it finds the
right place, so posting is O(n) in the worst case but effectively O(1) for the common case of appending
at the end. When the new message is at the head, the Looper is woken.
The important consequence: the queue is a priority queue on due time, and ties break by insertion
order. A message with the same when as another goes after it. That is why postAtFrontOfQueue
exists — it is the only way to jump the sort.
The idle wait: epoll, not polling#
MessageQueue.next() calls into native code that blocks until either:
- the next message's due time arrives, or
- a new message is posted, signalled through a pipe (the "wake" fd), or
- the Looper is asked to quit.
Android 17 changed the implementation for apps targeting API 37: the MessageQueue became lock-free in the common path, removing contention between the UI thread and Binder threads posting work.3 The semantics are unchanged, which is the point worth taking away: the contract is stable even when the data structure is not.
postAtFrontOfQueue, and why it is dangerous#
handler.postAtFrontOfQueue { applyInsets() }It runs before everything currently queued, including input and frame callbacks. The framework uses it sparingly. If your code uses it, ask whether the ordering is genuinely required; a message that jumps ahead of input is a message that can make the app feel unresponsive.
Sync barriers and asynchronous messages#
A sync barrier is a message with no target that tells the queue: "hold every synchronous message
behind this point". Asynchronous messages — posted with Message.setAsynchronous(true) or sent through
a Handler created with Handler(async = true) — pass through.
val asyncHandler = Handler(Looper.getMainLooper(), null, true)
asyncHandler.post { onVsync() } // not blocked by a barrierThis exists so that Choreographer can guarantee a frame runs even if a flood of ordinary messages
would otherwise delay it. Input dispatch uses the same trick. The practical lesson for app code:
do not post a long task to the main queue and assume it will run before the frame. It will not; the
barrier exists precisely to prevent that.
Handler leaks, and the modern fix#
The classic leak:
// An anonymous Handler captures the Activity.
handler.postDelayed({ updateUi() }, 60_000)The Runnable holds this, the message holds the Runnable, the queue holds the message — so the
Activity survives for a minute after it is destroyed. Two fixes:
// 1. Remove the callback in onDestroy.
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
super.onDestroy()
}
// 2. Better: use a lifecycle-aware scope.
lifecycleScope.launch {
delay(60_000)
updateUi()
}lifecycleScope cancels automatically at onDestroy, which removes the entire class of bug. That is
the general direction of the platform: from manual message management to structured cancellation.
HandlerThread versus a thread pool#
A HandlerThread gives you serial execution on one thread. That is exactly right when the work
shares mutable state and must not run concurrently — a database writer, a log flusher, a state
machine. It is wrong for CPU-bound parallel work, where a pool is appropriate.
Dispatchers.Default is a pool sized to the CPU count; Dispatchers.IO is a pool designed for
blocking calls, with a much higher thread limit. Dispatchers.Main is a Handler on the main Looper.
Every one of them is the same three classes underneath.
The Handler API surface, and what it hides#
A Handler offers four ways to post work, and the differences are load-bearing:
| Method | Ordering | When |
|---|---|---|
post(r) | appended at the current time | ordinary work |
postDelayed(r, d) | due at now + d | timers, retries |
postAtFrontOfQueue(r) | ahead of everything | framework-internal priority |
postAtTime(r, t) | due at an absolute uptime | scheduling against a known clock |
Two subtleties hide behind that table. postDelayed measures in uptimeMillis, which does not advance
during deep sleep; and all four return a boolean that is false when the Looper has already quit. A
post to a dead HandlerThread silently does nothing, which is a common source of "the callback never
ran" bugs.
Why this design survived#
A queue with a due time and a blocking wait is a very small amount of machinery that solves several problems at once:
- Ordering without locks (single consumer).
- Timers without a timer thread.
- Cross-thread handoff through one well-defined boundary.
- Idle efficiency through the blocking wait.
Coroutines did not replace it. Dispatchers.Main is a Handler dispatcher; a suspend function that
resumes on main resumes by posting a message. The abstraction got better; the substrate did not
change.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 33 | 13 Tiramisu |
|
| 37 | 17 Cinnamon Bun |
|
The three classes shipped in API 1 and have kept their contract. The changes have been in performance
and in the surrounding abstractions — Choreographer in 4.1, coroutines in 2018, and a lock-free
fast path in 17.
Under the hood#
- Looper.javaprepare, loop, and the per-thread Looper map
- MessageQueue.javathe linked list, the barrier, and the native wake mechanism
- Handler.javapost, sendMessage, and the callback dispatch
- android_os_MessageQueue.cppthe native side: Looper::pollOnce and the wake pipe
- Choreographer.javathe async-message client that makes frames jump the queue
Curiosity corner#
- The main thread's Looper has a name:
android.ui.HandlerThreadlets you name yours, which makes traces readable. quitSafely()versusquit(): the first drains messages already due, the second drops everything. UsequitSafelyunless you genuinely want to abandon work.- A Handler is not a thread. It is a handle to someone else's loop. Creating a Handler on a thread with no Looper throws.
Try it#
adb shell perfetto -o /data/misc/perfetto-traces/looper -t 3s sched freq
adb pull /data/misc/perfetto-traces/looper
adb shell kill -3 $(adb shell pidof com.example.notes)
adb logcat -d | grep "DALVIK THREADS" -A 80
Check yourself#
Check yourself
A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsAnrdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩
- docsBinder threadingsource.android.comaccessed 2026-10-27↩