Zygote
Threads & concurrencynerd

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.

11 minAPI 1–37 · 17interactivequizverified 2026-10-27

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() then Looper.loop().
  • A Message has a what, optional arg1/arg2, an obj, a Bundle-ish data and 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 epoll wait, so an idle app uses no CPU. Posting a message writes to a pipe/eventfd that wakes it.
  • postAtFrontOfQueue and sync barriers are how the framework reorders work — and how Choreographer gives 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#

Loading diagram…
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 thread

The 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:

FieldPurpose
whatan int identifier you define
arg1, arg2two ints, cheap to carry
objan arbitrary object; beware holding large graphs
dataa Bundle for a few values
targetthe Handler that will receive it
whenthe 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 barrier

This 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:

MethodOrderingWhen
post(r)appended at the current timeordinary work
postDelayed(r, d)due at now + dtimers, retries
postAtFrontOfQueue(r)ahead of everythingframework-internal priority
postAtTime(r, t)due at an absolute uptimescheduling 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#

Runtime changes9 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
247.0 Nougat
  • Hybrid JIT + AOT compilation in ART
3313 Tiramisu
  • The notification permission becomes a runtime permission
3717 Cinnamon Bun
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector

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#

Curiosity corner#

  • The main thread's Looper has a name: android.ui. HandlerThread lets you name yours, which makes traces readable.
  • quitSafely() versus quit(): the first drains messages already due, the second drops everything. Use quitSafely unless 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#

See the main thread's message queue in a trace

adb shell perfetto -o /data/misc/perfetto-traces/looper -t 3s sched freq adb pull /data/misc/perfetto-traces/looper

Dump an app's threads

adb shell kill -3 $(adb shell pidof com.example.notes) adb logcat -d | grep "DALVIK THREADS" -A 80

Check yourself#

Check yourself

1 / 12

A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?

0 answered · 0 correct

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.

  1. docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩
  2. docsAnrdeveloper.android.comaccessed 2026-10-27↩
  3. docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
  4. docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩
  5. docsBinder threadingsource.android.comaccessed 2026-10-27↩