Before coroutines existed, Handler was the mechanism for communicating between threads on Android. It's still used today inside the framework, inside WorkManager's internals, and in any code that needs precise control over thread dispatch. Understanding it deeply prevents subtle bugs and memory leaks.
The Mental Model
Think of Handler + Looper + MessageQueue as a mailbox system:
- Looper — the mail carrier that runs continuously on one thread
- MessageQueue — the physical mailbox; messages wait here in time order
- Handler — the address label; it specifies which mailbox gets the message and who processes it
Thread A Thread B (has a Looper)
──────── ──────────────────────
handler.post { doWork() } Looper.loop() → picks up Runnable → runs doWork()
A Handler is permanently bound to the Looper of the thread where it was created. You can create a Handler on the main thread and post to it from any background thread — the Runnable always executes on the main thread.
Creating Handlers
Posting Runnables (most common):
// Handler tied to the main thread
val mainHandler = Handler(Looper.getMainLooper())
// From any background thread:
mainHandler.post {
// Runs on main thread
textView.text = "Done"
}
mainHandler.postDelayed({
// Runs 500ms from now on the main thread
hideLoadingSpinner()
}, 500L)
Sending Messages (more structured):
companion object {
const val MSG_UPDATE_PROGRESS = 1
const val MSG_DOWNLOAD_COMPLETE = 2
}
val handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
when (msg.what) {
MSG_UPDATE_PROGRESS -> progressBar.progress = msg.arg1
MSG_DOWNLOAD_COMPLETE -> showResult(msg.obj as DownloadResult)
}
}
}
// From a background thread:
handler.sendMessage(Message.obtain(handler, MSG_UPDATE_PROGRESS).apply {
arg1 = 75
})
Using Message.obtain() instead of Message() reuses pooled Message objects — important in high-frequency dispatch scenarios.
HandlerThread: A Thread With a Built-in Looper
Preparing your own Looper on a raw Thread is boilerplate. HandlerThread does it for you:
class ImageProcessor {
private val handlerThread = HandlerThread("image-processor")
private lateinit var handler: Handler
fun start() {
handlerThread.start()
// getLooper() blocks until Looper.prepare() has run on the new thread
handler = Handler(handlerThread.looper)
}
fun processImage(bitmap: Bitmap) {
handler.post {
// Runs on the image-processor thread, not main
val result = applyFilters(bitmap)
mainHandler.post { displayResult(result) }
}
}
fun stop() {
handlerThread.quitSafely() // drains pending messages before quitting
}
}
quitSafely() vs quit():
quit()— drops all pending messages immediatelyquitSafely()— processes messages already due, drops future-scheduled ones
Always use quitSafely() unless you're shutting down urgently.
Message Internals: The Object Pool
Android maintains a global pool of Message objects (max 50) to avoid GC pressure. When you call msg.recycle() after dispatch, the message is cleared and returned to the pool. When you call Message.obtain(), you get a recycled instance instead of allocating.
Message pool (max 50)
┌────┬────┬────┬────┐
│ M1 │ M2 │ M3 │... │ ← obtain() pulls from here
└────┴────┴────┴────┘
↑
recycle() returns here
Never hold a reference to a Message after you've passed it to a Handler — the framework owns it from that point and may recycle it.
The Classic Handler Memory Leak
This pattern leaks the Activity:
// ❌ Leaks MyActivity
class MyActivity : AppCompatActivity() {
private val handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
// Implicit reference to MyActivity.this
updateUI()
}
}
override fun onCreate(...) {
handler.postDelayed({ finish() }, 60_000) // 60 second delay
}
}
The anonymous Handler subclass holds an implicit reference to MyActivity. If the Activity is destroyed before the 60-second delay fires, the GC cannot collect it — the Handler still holds a reference from the MessageQueue's pending message.
Fix: use a WeakReference
class MyActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper())
private val finishRunnable = Runnable { finish() }
override fun onCreate(...) {
handler.postDelayed(finishRunnable, 60_000)
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacks(finishRunnable) // ← critical
}
}
Or even better with coroutines:
lifecycleScope.launch {
delay(60_000)
finish() // cancelled automatically when Activity is destroyed
}
Barrier Messages: Synchronous Barriers
The MessageQueue supports synchronous barriers — an advanced mechanism used by the Choreographer internally. A barrier blocks synchronous messages while allowing async messages to jump the queue.
// Mark a message as async so it can pass synchronous barriers
val msg = Message.obtain(handler, { drawNextFrame() })
msg.isAsynchronous = true
handler.sendMessage(msg)
The framework uses this to ensure VSYNC callbacks (frame rendering) always fire ahead of pending synchronous messages — guaranteeing the UI stays responsive even when the queue is busy.
When to Use Handler Today
| Scenario | Recommendation |
|---|---|
| UI updates from background thread | lifecycleScope.launch(Dispatchers.Main) — simpler |
| Delayed UI action (< lifecycle scope) | handler.postDelayed() with removeCallbacks on destroy |
| Low-level framework integration | Handler is appropriate |
| Custom thread message loop | HandlerThread + Handler |
| Periodic background work | WorkManager — not Handler |
Handler is not obsolete — it's used inside the Android framework itself. But for new application code, Kotlin coroutines with the right Dispatcher express the same intent more safely and with automatic cancellation tied to the lifecycle.