In the previous parts of this series, we explored what processes are, how they are launched, and the hierarchy Android uses to manage them. Now it's time to look inside a process and understand what actually happens when your app starts running.
This is where things get very practical. One of the best ways to understand a foundational concept is to build a simplified version of it yourself — so that's exactly what we'll do here with the Looper and the MessageQueue.
When an app launches, it runs in its own process container. Every process hosts at least one thread: the main thread. On Android, the main thread carries a lot of responsibility:
- Drawing and updating the UI
- Handling touch and keyboard input events
- Dispatching framework callbacks
- Invoking Activity lifecycle callbacks (
onCreate,onStart,onResume, etc.) - Invoking
BroadcastReceiver.onReceive() - Running Service callbacks (normal services run on the main thread by default)
But two concepts unique to Android are introduced on top of this thread that most developers have seen referenced without fully understanding them:
- Looper
- MessageQueue
Let's break both down.
Why Does a Thread Need a Looper?
In standard software development, a thread has a simple lifecycle: it starts, executes its code, and exits.
In Kotlin, this looks like:
thread {
println("Hello World!")
}
The thread launches, prints the message, and terminates. That's normal and expected behavior.
Now apply that same logic to the main thread of an Android app.
Your Activity launches. onCreate() fires. onStart() fires. onResume() fires. Your ViewModel initializes. The UI renders. And then... the main thread has nothing left to do. Under normal thread behavior, it would simply exit.
But that's obviously not what happens. The user could tap the screen at any moment. A keyboard event could arrive. A background thread could finish a network request and need to post a UI update. The main thread needs to stay alive and ready, not just for what it's doing right now, but for everything that might arrive in the future.
The Looper is what keeps the thread alive.
Every thread can optionally have one Looper. The Looper does one thing: it runs an infinite loop. When there's work to do, the thread processes it. When there's nothing to do, the Looper blocks, it waits, without burning CPU cycles, for the next instruction to arrive. The moment something new enters the queue, the loop resumes and processes it, then blocks again.
This is why the main thread never dies for as long as your app is running.
The MessageQueue
The MessageQueue is the data structure that feeds the Looper. It's a classic First In, First Out (FIFO) queue, just like a real-world queue at a ticket counter. The first event to arrive is the first event to be processed.
Here's a concrete example of how this plays out:
- The user taps the screen → a touch event enters the queue
- Milliseconds later, a keyboard event fires → it enters the queue behind the touch event
- The Looper processes the touch event first, then the keyboard event
All these events are packaged as Messages and queued up for sequential processing on the main thread.
Android exposes the Looper system through a simple API:
Looper.getMainLooper() // The Looper of the main thread
Looper.myLooper() // The Looper of the current thread
Looper.myQueue() // The MessageQueue of the current Looper
Building Our Own Looper
The best way to understand this is to implement a simplified version. The real Android Looper implementation is significantly more complex, but the core idea maps directly onto what we're about to build.
import java.util.concurrent.LinkedBlockingQueue
import kotlin.concurrent.thread
class MyLooper {
// The thread this Looper will run on
private var thread: Thread? = null
// The queue of events to be processed
// (Runnable is simply something that can be executed)
private val messageQueue = LinkedBlockingQueue<Runnable>()
// Add an event to the queue
fun enqueue(runnable: Runnable) {
if (thread == null) {
createLooperThread()
}
messageQueue.offer(runnable)
}
// Initialize the looper thread the first time something is enqueued.
// The thread stays alive by blocking on messageQueue.take(),
// which waits until a new message is available.
// We wrap everything in a try-catch because quit() interrupts the thread,
// which causes take() to throw an InterruptedException.
private fun createLooperThread() {
thread = thread ?: thread {
try {
while (true) { // This is our Looper
val message = messageQueue.take() // Blocks when queue is empty
message.run()
}
} catch (e: InterruptedException) {
return@thread
}
}
}
// Stop the looper and clean up the thread
fun quit() {
thread?.interrupt()
thread = null
}
}
A few things to notice here:
LinkedBlockingQueue.take() is the key to the whole thing. When the queue is empty, take() blocks — it suspends execution on that thread without spinning or wasting CPU. The moment a new Runnable is added via offer(), take() unblocks and the loop continues. This is the exact mechanism the Android Looper uses to keep a thread alive while idle.
The while (true) loop is the Looper itself, it keeps running forever until quit() is called, which interrupts the thread and breaks out of the loop via the InterruptedException.
Using Our Looper
Here's a practical demonstration using our custom MyLooper inside an Activity:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val myLooper = MyLooper()
// Enqueue 5 tasks immediately
repeat(5) {
myLooper.enqueue(sampleRunnable(it))
}
// After 10 seconds of idle, enqueue one more task
lifecycleScope.launch {
delay(10_000L)
myLooper.enqueue(sampleRunnable(5))
}
setContent {
AndroidInternalsTheme {}
}
}
// A sample Runnable that simulates a task taking 1 second to complete
private fun sampleRunnable(index: Int): Runnable {
return Runnable {
println("Runnable $index started.")
Thread.sleep(1000L)
println("Runnable $index finished.")
}
}
}
When you run this, you'll see:
- Runnables 0 through 4 execute one after another, each taking one second
- After a 10-second gap (simulating an idle thread), Runnable 5 arrives and executes
This is exactly what the main thread does in your app all day, processing events as they arrive, blocking between them, and never shutting down until the app is killed.
The Real-World Consequence: A MessageQueue Bug
Understanding the Looper and MessageQueue isn't just theoretical. It directly explains a category of bug that many Android developers have hit without understanding why it happens.
Consider a ViewModel with a simple counter and a button that increments it:
class MyViewModel {
private val _counter = MutableStateFlow(0)
val counter = _counter.asStateFlow()
fun increment() {
_counter.update { it + 1 }
}
}
And a UI that reads the counter state and prints it on click:
val counter by viewModel.counter.collectAsState()
Button(
onClick = {
viewModel.increment()
println("Counter: $counter") // What does this print?
}
) {
Text("Counter: $counter")
}
You might expect that after calling increment(), the println would show the new value. But it doesn't, it prints the old value.
Why?
Because of the MessageQueue. The onClick callback is processed as a single message in the queue, one loop iteration of the Looper. Within that same iteration, counter still holds the reference it had when the iteration started. The collectAsState() function collects state updates on the Main dispatcher, but that collection happens in a separate loop iteration, a separate message processed later in the queue. By the time the UI shows the new value, the onClick message has already finished.
The fix is one line:
val counter by viewModel.counter.collectAsState(
context = Dispatchers.Main.immediate
)
Dispatchers.Main.immediate changes the behavior: if the collector is already running on the main thread, it updates the value immediately within the same call stack rather than posting it as a new message to the queue. The result is that counter reflects the incremented value by the time println runs.
The full corrected implementation:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val myLooper = MyLooper()
repeat(5) {
myLooper.enqueue(sampleRunnable(it))
}
lifecycleScope.launch {
delay(10_000L)
myLooper.enqueue(sampleRunnable(5))
}
val viewModel = MyViewModel()
setContent {
AndroidInternalsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// Dispatchers.Main.immediate ensures the collector updates
// synchronously on the same call stack — not as a new queue message
val counter by viewModel.counter.collectAsState(
context = Dispatchers.Main.immediate
)
Button(
onClick = {
viewModel.increment()
println("Counter: $counter") // Now prints the correct value
},
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
) {
Text("Counter: $counter")
}
}
}
}
}
private fun sampleRunnable(index: Int): Runnable {
return Runnable {
println("Runnable $index started.")
Thread.sleep(1000L)
println("Runnable $index finished.")
}
}
}
Note: This exact class of bug is also the reason the new
TextField2API was introduced in Jetpack Compose. The oldTextFieldsuffered from stale state reads within the same message loop iteration — a subtle but frustrating issue caused by this very mechanism.
Putting It All Together
Here's a mental model that ties everything in this article together:
Every interaction your user has with your app, every lifecycle callback, every UI update, all of it flows through this single queue, processed one message at a time on the main thread.
The Looper keeps your thread alive. Without it, the main thread would exit as soon as it ran out of immediate work to do. The infinite loop, combined with a blocking queue, is what turns a one-shot thread into a persistent event processor.
The MessageQueue enforces sequential processing. Everything on the main thread happens in order, one message at a time. This is what guarantees UI thread safety, you don't need locks on the main thread because nothing can interrupt a running message.
Dispatchers.Main.immediate matters. When you need a state update to be visible within the same call stack, not deferred to the next queue iteration, this is the dispatcher to reach for. Knowing why it works the way it does makes it much easier to know when to use it.
