# Same Thread, Different Moment: What Dispatchers.Main.immediate Really Changes

*A short detour into coroutine dispatch on Android, and the production bugs that hide inside it.*

![Same Thread, Different Moment: What Dispatchers.Main.immediate Really Changes](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/2a657a4f-6197-44b0-bbf7-23d715dadaab.jpg align="center")

*Same Thread, Different Moment: What* `Dispatchers.Main.immediate` *Really Changes*

Ask ten Android developers what separates `Dispatchers.Main` from `Dispatchers.Main.immediate`, and most will say "nothing, they both run on the main thread." That answer is correct about *where* and silent about *when*. The *when* is where the bugs live.

This post walks through the mechanism, shows what the AndroidX scopes do for you by default, and then goes through the failure modes I would actually expect to see in a code review or a crash report.

## The short version

- Both dispatchers target the main thread. Neither one creates a thread or gives you parallelism.
- `Dispatchers.Main` **always** posts a message to the main `Looper`, even when you are already on the main thread. Your coroutine body runs on a later looper turn.
- `Dispatchers.Main.immediate` skips the post **if you are already on the main thread**, and runs the body inline, on the caller's stack, until the first suspension point.
- `viewModelScope` and `lifecycleScope` both default to `Main.immediate`.

## A two-line experiment

Drop this into a scratch `Activity`:

```kotlin
class CartActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        Log.d(TAG, "1: before launch")
        lifecycleScope.launch(Dispatchers.Main) {
            Log.d(TAG, "2: inside coroutine")
        }
        Log.d(TAG, "3: after launch")
    }

    private companion object {
        const val TAG = "DispatchDemo"
    }
}
```

Logcat shows the lines in the order 1, 3, 2. The coroutine was handed to the main looper as a message, and `onCreate` had to finish before the looper could pick that message up.

Now swap the dispatcher for `Dispatchers.Main.immediate`:

```kotlin
lifecycleScope.launch(Dispatchers.Main.immediate) {
    Log.d(TAG, "2: inside coroutine")
}
```

The order becomes 1, 2, 3. No message was posted. The coroutine body ran in the middle of `onCreate`, on the same call stack, and control returned to the next line only after the body finished (or hit a suspension point).

![main-vs-immediate-timeline](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/0065aa03-8910-49f8-a1e3-f0dc00d30461.png align="center")

One detail worth pausing on: because `lifecycleScope` already uses `Main.immediate`, a plain `lifecycleScope.launch { ... }` gives you the 1, 2, 3 order. In the first snippet, passing `Dispatchers.Main` explicitly is what *opted out* of the immediate behavior.

## What “dispatch” means here

A dispatcher is asked one question every time a coroutine starts or resumes: *is a dispatch needed?* If yes, the continuation is handed to the dispatcher, which on Android posts it to the main looper’s queue. If no, the continuation keeps running right where it is.

Conceptually, the Android main dispatcher answers like this (a sketch of the idea, not library source):

```kotlin
override fun isDispatchNeeded(context: CoroutineContext): Boolean =
    !runsImmediatelyWhenPossible || Looper.myLooper() != mainLooper
```

For plain `Main`, the first operand is always true, so the answer is always "yes, post it." For `Main.immediate`, the answer is "post it only if the current thread is not the main looper's thread."

![dispatch-decision-flow](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/9137eb6c-c42c-456c-b1aa-a084bba7acdf.png align="center")

Two consequences follow:

1. **Called from a background thread, the two dispatchers behave identically.** `Main.immediate` has nothing to skip.
2. **“Immediate” is not “synchronous to completion.”** The body runs inline only until its first real suspension. After that, resumption goes through normal dispatching rules again.

## What the AndroidX scopes give you

`viewModelScope` is built on a `SupervisorJob` plus `Dispatchers.Main.immediate`, and `lifecycleScope` follows the same recipe. In practice:

```kotlin
class ProfileViewModel(private val repo: ProfileRepository) : ViewModel() {

    private val _title = MutableStateFlow("")
    val title: StateFlow<String> = _title.asStateFlow()

    fun onScreenShown() {
        viewModelScope.launch {
            _title.value = "Loading…"          // runs right now, inside onScreenShown()
            _title.value = repo.loadTitle()    // may suspend; resumes later
        }
    }
}
```

The first assignment happens synchronously during the call to `onScreenShown()`. That is a feature: the UI state can change without waiting for the next looper turn. It is also the source of everything below.

## Failure mode 1: the stuck spinner

This is the one I would bet on seeing first in real code:

```kotlin
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {

    private val _state = MutableStateFlow<FeedState>(FeedState.Idle)
    val state: StateFlow<FeedState> = _state.asStateFlow()

    fun refresh() {
        viewModelScope.launch {
            _state.value = FeedState.Content(repo.latest())
        }
        _state.value = FeedState.Loading
    }
}
```

When `repo.latest()` genuinely suspends (network, disk), the order is: body starts, suspends, `Loading` is set, later `Content` arrives. Looks fine.

When `repo.latest()` is a `suspend` function that happens to return a cached value without ever suspending, the body runs to completion inline. `Content` is set first, and then the line after `launch` sets `Loading` on top of it. Nothing is left to overwrite it. The screen spins forever, but only on cache hits, which makes it a nasty intermittent bug.

![stuck-spinner-ordering](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/1d01a83c-bcf6-45b6-8e55-8526491e5690.png align="center")

With plain `Dispatchers.Main` this bug could not exist, because the body would always run after the caller's remaining lines. The fix is to stop depending on ordering between "after launch" code and the body:

```kotlin
fun refresh() {
    _state.value = FeedState.Loading
    viewModelScope.launch {
        _state.value = FeedState.Content(repo.latest())
    }
}
```

Rule of thumb: **set the “before” state before the** `launch`**, never after it.**

This bug is easy to pin down with a test. I show how in Failure mode 4.

## Failure mode 2: re-entrancy during bind or layout

Inline execution means your coroutine can run in the middle of someone else’s critical section. The classic Android victim is `RecyclerView`. Here is an anti-pattern. It is shown so you can recognize it in a review, not to copy:

```kotlin
// ANTI-PATTERN: do not ship this
override fun onBindViewHolder(holder: VH, position: Int) {
    val item = items[position]
    holder.bind(item)

    scope.launch {                                   // scope uses Main.immediate
        val badge = badgeCache.get(item.id)          // suspend, but instant on a cache hit
        items[position] = item.copy(badge = badge)   // stale position after any suspension
        notifyItemChanged(position)                  // can throw during layout or scrolling
    }
}
```

This snippet contains three separate bugs, and `Main.immediate` only causes the first:

1. **Re-entrancy.** On a cache hit the body never suspends, so `notifyItemChanged` fires from inside `onBindViewHolder`, which is inside a layout pass. `RecyclerView` rejects that with an `IllegalStateException` saying you cannot call the method while it is computing a layout or scrolling. On a cache miss the same code works, so the crash appears only for some rows.
2. **Stale position.** On a cache miss the coroutine suspends, and by the time it resumes the list may have changed or the holder may have been rebound to a different row. The captured `position` can now point at the wrong item, so the write lands on the wrong row (or throws an index error).
3. **Unmanaged lifetime.** The coroutine is not tied to the holder. A recycled holder keeps a running job that outlives its row, holds references to the adapter and views, and can paint an old badge onto a row that now shows something else. A scope that outlives the screen makes this a leak as well.

**Preferred fix: do not mutate the adapter from bind at all.** Resolve badges in the ViewModel, expose the finished list as state, and let `ListAdapter.submitList` diff it. There is no coroutine in `onBindViewHolder`, so there is nothing to race.

**If you must load per row**, tie the job to the holder, touch only that holder’s own views, and cancel on recycle:

```kotlin
class ItemViewHolder(private val binding: ItemBinding) : RecyclerView.ViewHolder(binding.root) {

    private var badgeJob: Job? = null

    fun bind(item: Item, scope: CoroutineScope, loadBadge: suspend (Long) -> Badge?) {
        badgeJob?.cancel()                           // drop any job from the previous row
        binding.title.text = item.title
        binding.badge.isVisible = false

        badgeJob = scope.launch {
            val badge = loadBadge(item.id)           // may or may not suspend
            binding.badge.render(badge)              // only this holder's views; no position, no notify
        }
    }

    fun unbind() {
        badgeJob?.cancel()
        badgeJob = null
    }
}

// In the adapter:
override fun onViewRecycled(holder: ItemViewHolder) {
    holder.unbind()
    super.onViewRecycled(holder)
}
```

Pass in `viewLifecycleOwner.lifecycleScope` from a Fragment so every pending job is also cancelled when the view is destroyed. This version is safe whether `loadBadge` suspends or not, because it never calls an adapter notification and never relies on a captured position. If you only need a one-off deferral, `Dispatchers.Main` (the non-immediate one) postpones the body until the current pass is over, which is one of the few legitimate reasons to pick it.

## Failure mode 3: nested launches queue behind the outer body

Immediate dispatch is not a free-for-all. While an immediate coroutine body is running, further immediate starts on that thread are queued rather than nested on the stack, which keeps stack depth bounded. This is the same event-loop mechanism the docs describe for `Dispatchers.Unconfined`.

```kotlin
viewModelScope.launch {
    Log.d(TAG, "outer start")
    launch { Log.d(TAG, "inner") }
    Log.d(TAG, "outer end")
}
```

Expect the order *outer start, outer end, inner*. The inner `launch` was accepted but not started; it runs once the outer body suspends or finishes.

![nested-launch-queue](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/45802b4b-7906-4511-b42e-d7d937ca1d5d.png align="center")

I would reproduce this once in a scratch screen so it stops being a surprise. The practical lesson is that ordering between *sibling or nested* coroutines is an implementation detail. If order matters, encode it: `join()`, `async/await`, a `Channel`, or simply sequential code in one coroutine.

## Failure mode 4: tests that don’t match production

Unit tests replace the main dispatcher with `Dispatchers.setMain(...)`, and the thing you install decides what "immediate" means in that test:

```kotlin
@Before
fun setUp() {
    Dispatchers.setMain(StandardTestDispatcher())
}
```

With a `StandardTestDispatcher`, coroutines launched in `viewModelScope` wait until you advance the scheduler, so a test can observe states in an order production never produces. With an `UnconfinedTestDispatcher` they start eagerly, which is closer to production but is still a different animal from a real looper. There is no real "already on the main thread" in a JVM test.

Three habits help:

1. Assert on **final states** after `advanceUntilIdle()`.
2. Avoid logic whose correctness depends on whether the first lines of a coroutine ran before or after the caller’s next line (see failure mode 1).
3. Run each state-ordering test against both an immediate fake and a suspended fake, under both queued and eager starts.

**Pinning the stuck spinner with a test matrix**

A network-like fake always suspends, so it hides the cache-hit path. To expose it, you need two repository fakes: one that returns immediately, and one that waits behind a gate your test controls. Pair each with both kinds of start: queued (`StandardTestDispatcher`) and eager (`UnconfinedTestDispatcher`). That gives four cases, and in every one `refresh()` should end in `Content` once the result has arrived, never `Loading`.

```kotlin
class FakeFeedRepository(private val gate: CompletableDeferred<Unit>?) : FeedRepository {
    override suspend fun latest(): List<Item> {
        gate?.await()               // null = returns immediately (cache-hit path)
        return listOf(Item(1))
    }
}

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(Parameterized::class)
class FeedViewModelTest(
    private val eagerStart: Boolean,
    private val gated: Boolean,
) {
    companion object {
        @JvmStatic @Parameterized.Parameters
        fun cases() = listOf(
            arrayOf(false, false), arrayOf(false, true),
            arrayOf(true, false), arrayOf(true, true),
        )
    }

    @Test
    fun refresh_endsInContent() {
        val dispatcher = if (eagerStart) UnconfinedTestDispatcher() else StandardTestDispatcher()
        Dispatchers.setMain(dispatcher)   // must run before the ViewModel is created
        try {
            val gate = if (gated) CompletableDeferred<Unit>() else null
            val vm = FeedViewModel(FakeFeedRepository(gate))

            vm.refresh()
            dispatcher.scheduler.runCurrent()   // let a queued coroutine start and hit the gate
            gate?.complete(Unit)
            dispatcher.scheduler.advanceUntilIdle()

            assertTrue(vm.state.value is FeedState.Content)
        } finally {
            Dispatchers.resetMain()
        }
    }
}
```

Install the test dispatcher before constructing the ViewModel, so `Dispatchers.Main` is already resolvable when `viewModelScope` is first used.

Here is what each case does against the original `refresh()` from Failure mode 1 and the fixed one:

![Four test cases. Only immediate fake with eager start leaves the original refresh() stuck in Loading.](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/f98c0b9a-468f-407d-8543-3b320305ec14.png align="center")

*stuck-spinner-test-matrix*

Only one cell fails: the immediate fake with an eager start. That is exactly why the bug looks intermittent in production. It needs a cache hit, and the cache hit is the only time the body finishes before the line after `launch`. Against the fixed `refresh()`, all four cases end in `Content`.

## Failure mode 5: heavy work before the first suspension

Inline execution also means the body borrows the caller’s time. If a click handler launches an immediate coroutine and the first thing it does is blocking work, the click handler pays for it:

```kotlin
saveButton.setOnClickListener {
    lifecycleScope.launch {
        val bytes = file.readBytes()            // blocks the main thread, inside the click
        preview.setImageBitmap(decode(bytes))
    }
}
```

Nothing about `launch` makes blocking calls safe. Move the blocking part behind a dispatcher that fits it:

```kotlin
lifecycleScope.launch {
    val bitmap = withContext(Dispatchers.Default) { decode(file.readBytes()) }
    preview.setImageBitmap(bitmap)
}
```

(Reading files is I/O, so `Dispatchers.IO` is the better fit for the read itself; the point here is only that blocking work must leave the main thread.)

## Cheat sheet

![main-vs-immediate-timeline](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/0065aa03-8910-49f8-a1e3-f0dc00d30461.png align="center")

## Practical guidance

- **Keep the defaults.** `viewModelScope` and `lifecycleScope` already use `Main.immediate`, and for most UI-state work that is what you want: no needless one-frame delay.
- **Never rely on “code after** `launch` **runs first."** With immediate dispatch it may not. Set pre-conditions before launching.
- **Choose** `Dispatchers.Main` **deliberately** when you want deferral, for example to let a bind, layout, or transaction finish before touching the same state.
- **Never launch unmanaged coroutines from** `onBindViewHolder`**.** If a row needs async work, keep the `Job` on the holder and cancel it in `onViewRecycled()`, and never capture a raw adapter position across a suspension point.
- **Treat ordering between coroutines as unspecified** unless you enforce it with structure (`join`, `await`, channels, sequential code).
- **Never put blocking calls ahead of the first suspension** of a coroutine started from a UI callback.
- **Test outcomes, not interleavings, and cover both the immediate and the suspending path.**

## 🔚 Closing thought

`Main` versus `Main.immediate` is a one-word difference with a surprisingly large blast radius. Neither makes your code faster in the parallel sense, and neither moves work off the main thread. What changes is a single decision: queue the work, or keep running. Once that decision is visible to you, a whole family of "it only fails sometimes" bugs turns into something you can reason about from the code alone.

---

## 🧵 Stop Treating Coroutines Like Magic Keywords

If “just add launch and hope” describes how your team handles concurrency, I’d recommend **“Kotlin Coroutines Under the Hood”** — a 25-chapter deep dive that goes past the syntax and down to the bytecode: the Continuation-Passing Style transform, the compiler-generated state machine, and exactly why a suspended coroutine costs a few dozen bytes while a blocked thread costs a megabyte.

It walks through structured concurrency and Job hierarchies, dispatcher internals (Default vs IO vs Unconfined), cooperative cancellation and exception aggregation, Flow’s cold-stream semantics and the full operator vocabulary, StateFlow/SharedFlow’s hot-broadcast model, Channels and fan-in/fan-out patterns, Mutex/Semaphore/actors for shared state, and a complete Android case study retrofitting a legacy callback-driven app onto coroutines and Flow — including a 100,000-coroutines-vs-100,000-threads benchmark lab you can run yourself.

- **E-book:** [**Available on Google Play**](https://play.google.com/store/books/details?id=CMEIEgAAQBAJ)
- **Kindle Edition:** [**Available on Amazon**](https://www.amazon.com/dp/B0HHK4MHMS)
- **Topmate:** [**Available on Topmate**](https://topmate.io/sivavishnu_r/2281589)
- Also available in **Paperback & Hardcover.**
