[P1] Inside Android’s Handler & Looper — The Engine That Keeps Your App Alive
Ever wonder how your app stays responsive, processes taps, and updates the screen without crashing into itself? It’s not magic — it’s a…
[P1] Inside Android’s Handler & Looper — The Engine That Keeps Your App Alive
Ever wonder how your app stays responsive, processes taps, and updates the screen without crashing into itself? It’s not magic — it’s a message loop.
Photo by Bhuwan Bansal on Unsplash
The Problem: One Thread to Rule Them All
You’re building your first Android app. You fetch data from the internet, and you want to show it in a TextView. So you write:
thread {
val result = URL("https://api.example.com/data").readText()
textView.text = result // crash!
}
Boom. CalledFromWrongThreadException. Android throws this at you the second you touch a View from any thread that isn’t the main thread. Only the main thread (aka the UI thread) is allowed to update the UI.
Fine. So you move the network call to a background thread and then… how do you get the result back to the main thread safely? You can’t just call a function on it — threads don’t work that way.
This is the core problem Android’s Handler and Looper were built to solve.
The Naive Solution (And Why It Fails)
You might think: “I’ll keep the main thread alive with a loop and a queue. Background threads dump results into the queue, the main thread picks them up one by one.”
class NaiveMainThread : Thread() {
val queue = ConcurrentLinkedQueue<Runnable>()
override fun run() {
while (true) {
queue.poll()?.run() // pick up work and do it
}
}
}
This works… but it’s disastrous for battery. When the queue is empty, poll() returns null instantly and the loop spins again at full CPU speed — pinning a core at 100% while doing nothing. Your phone would overheat in minutes.
You need a thread that sleeps when idle and wakes instantly when work arrives. That’s what Looper gives you — powered by a Linux epoll file descriptor in native code that parks the thread with zero CPU cost.
What is Handler, Looper & MessageQueue?
Imagine a coffee shop with one barista (the main thread). Customers don’t shout orders all at once — they write them on slips and drop them in a queue.
-
MessageQueue — the box of order slips. Tasks are queued in order.
-
Looper — the barista’s work rhythm. Forever: pick the next slip, make the drink, repeat.
-
Handler — the pen. You write a new order slip and drop it into the queue.
In code terms:
| Piece | Job |
| MessageQueue | Holds a list of pending `Message`/`Runnable` objects, ordered by time |
| Looper | Runs an infinite loop that pulls from the queue and dispatches each item |
| Handler | The public API — lets you `post()` or `sendMessage()` into a specific Looper's queue |
Every Looper is tied to exactly one thread. That’s the magic — work dispatched by a Looper always runs on its owning thread.
You’ve Already Been Using It
Every Android developer has used Handler without knowing it:
textView.post { updateUI() }
That .post() is a Handler call. View.post() internally grabs the main thread’s Handler and posts your Runnable into its MessageQueue. The main thread’s Looper picks it up on the next iteration and runs it — safely on the UI thread.
The main thread already has a Looper set up for you. When your app starts, ActivityThread.main() calls Looper.prepareMainLooper() then Looper.loop(). That loop processes every touch event, every frame render, and every Runnable you post — for the entire lifetime of your app.
How to Create Your Own Looper Thread
But Android doesn’t limit Looper to the main thread. You can give any thread an event loop:
class WorkerThread : Thread() {
lateinit var handler: Handler
override fun run() {
// Step 1: Prepare a Looper for THIS thread
Looper.prepare()
// Step 2: Create a Handler tied to this thread's Looper
handler = Handler(Looper.myLooper()!!) { msg ->
Log.d("Worker", "Received: ${msg.what}")
true
}
// Step 3: Start the infinite message loop
Looper.loop()
// This line NEVER runs until Looper.quit() is called
Log.d("Worker", "Looper ended")
}
}
// Usage
val worker = WorkerThread().apply { start() }
Thread.sleep(100) // wait for Looper.prepare()
worker.handler.sendEmptyMessage(1) // runs on worker thread
worker.handler.looper.quit() // stop the loop
⚠️ Forget
Looper.prepare()and you get: ”Can’t create handler inside thread that has not called Looper.prepare()”.
Post vs Message — Two Ways to Send Work
Handler gives you two styles:
val handler = Handler(Looper.getMainLooper())
// Style 1: Post a Runnable (simple, one-off)
handler.post {
textView.text = "Updated!"
}
// Style 2: Send a Message (reusable, object-pooled)
val msg = Message.obtain(handler, 42)
msg.obj = "Some data"
handler.sendMessage(msg)
// Receiving messages
val handler2 = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
when (msg.what) {
42 -> textView.text = msg.obj as String
}
}
}
Use post {} for quick one-off tasks. Use Message when you’re sending the same kind of work repeatedly — Message.obtain() recycles from a global pool, reducing GC pressure.
Scheduling: Delayed and Repeated Tasks
Handler can schedule work in the future too:
// Run once after a delay
handler.postDelayed({
textView.text = "Shown after 2 seconds"
}, 2000)
// Run repeatedly — re-post yourself
handler.post(object : Runnable {
override fun run() {
tickCounter()
handler.postDelayed(this, 1000) // repeat every 1s
}
})
Demo: A Complete Mini-App
Here’s everything you’ve learned in one app — background work → send result to main thread → update UI:
class MainActivity : AppCompatActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private lateinit var backgroundThread: BackgroundThread
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
backgroundThread = BackgroundThread().apply { start() }
findViewById<Button>(R.id.btn_load).setOnClickListener {
backgroundThread.handler?.post { doHeavyWork() }
}
}
private fun doHeavyWork() {
Thread.sleep(2000) // simulate network call
val result = "Fetched at ${System.currentTimeMillis()}"
// Post result back to main thread's Handler
mainHandler.post {
findViewById<TextView>(R.id.tv_result).text = result
}
}
override fun onDestroy() {
backgroundThread.handler?.looper?.quit()
super.onDestroy()
}
class BackgroundThread : Thread() {
var handler: Handler? = null
override fun run() {
Looper.prepare()
handler = Handler(Looper.myLooper()!!)
Looper.loop()
}
}
}
Step by step:
-
BackgroundThread calls prepare() + loop() — stays alive sleeping for work.
-
Button taps post doHeavyWork() onto the background Handler.
-
doHeavyWork() runs on the worker thread, simulates network I/O, then posts the result back via
mainHandler. -
The main thread’s Looper picks up that post and updates the
TextView— safely. -
On
onDestroy(),looper.quit()kills the background loop so the thread can die.
TL;DR: Looper keeps a thread alive with a sleeping event loop (zero CPU when idle). Handler is the API for pushing work into that loop. Together they’re the foundation for every threading mechanism in Android — including coroutines, RxJava, and LiveData.
메타데이터
- post_id
- 9c26330e670a
- slug
- p1-inside-androids-handler-looper-the-engine-that-keeps-your-app-alive-9c26330e670a
- url
- https://medium.com/@kaito_and_droid/p1-inside-androids-handler-looper-the-engine-that-keeps-your-app-alive-9c26330e670a
- canonical_url
- https://medium.com/@kaito_and_droid/p1-inside-androids-handler-looper-the-engine-that-keeps-your-app-alive-9c26330e670a
- author_url
- https://medium.com/@kaito_and_droid
- status
- ok
- fetched_at
- 2026-08-03 17:15:07