Zygote
Threads & concurrencyintermediate

AsyncTask, RxJava, coroutines

Twenty years of Android async in one line: every era's tool was a better way to say 'do this off the main thread, then come back'. None of them replaced the Looper.

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

Each generation of Android async tooling solved the previous generation's worst problem and introduced a new one. The sequence is worth learning not as trivia but as a design argument: it explains why coroutines look the way they do.

TL;DR#

  • + Thread (2008): correct, verbose, easy to leak.
  • AsyncTask (API 3, deprecated API 30): convenient; leaked contexts, lost results on rotation, and a serial executor that surprised everyone.1
  • Loaders (API 11, deprecated API 28): cursor-aware background loading tied to the lifecycle.
  • RxJava (2014 onward): composable streams, testable with schedulers, steep curve.
  • Coroutines ( 1.3, 2018): suspend functions with the shape of sequential code, structured concurrency, cancellation that propagates.

Mental model#

Watch how a message is passed in an office over forty years.

First you walked to the colleague's desk and waited (blocking). Then you left a note and hoped (callback). Then you hired a courier company with a tracking number (Future/Promise). Then you got a phone with a shared calendar (reactive streams). Then you got a conversation that pauses and resumes naturally (coroutines).

Where the analogy breaks#

The office analogy implies that the newer tool is simply better. In Android, each one is a different trade-off, and the older ones are still correct in narrow cases: a HandlerThread is still the right answer for serial work on one thread, and an Executor is still the right answer for a fixed pool. Coroutines are a better syntax over the same primitives, not a replacement for them.

The big picture#

Loading diagram…
Text description of this diagram

A timeline of Android asynchronous programming from 2008 to today, in three lanes. The framework lane shows Handler and Looper in 2008, AsyncTask in 2009, Loaders in 2011, and AsyncTask's deprecation at API 30 in 2020. The libraries lane shows RxJava 1.0 in 2014, RxJava 2.0 in 2016, WorkManager in 2018, and Flow integration in the lifecycle libraries in 2020. The language lane shows Kotlin support in 2017, stable coroutines in 2018, Kotlin-first in 2019, and Compose in 2021. The framework APIs come and go; the Looper underneath never changes.

Read the lanes vertically: each new tool in the library or language lane was built on top of the framework lane, not instead of it.

Deep dive#

Era 1: Handler and Thread#

The original pattern, and still the substrate:

val handler = Handler(Looper.getMainLooper())
val worker = Thread {
    val result = compute()
    handler.post { render(result) }
}
worker.start()

Problems, in order of how often they bit:

  1. No cancellation. The thread runs to completion; the posted callback fires even if the screen is gone.
  2. No error propagation. An exception on the worker kills the thread silently unless you install an uncaught handler.
  3. Manual lifecycle. Nothing removes the callback on destroy.
  4. No structure. Ten threads started in ten places have no relationship to each other.

HandlerThread improved the ergonomics — a serial executor with a name — but not the semantics.1

Era 2: AsyncTask (API 3, deprecated API 30)#

AsyncTask wrapped the pattern in a lifecycle-shaped class:

new AsyncTask<String, Integer, User>() {
    @Override protected User doInBackground(String... ids) {
        return api.fetch(ids[0]);          // worker thread
    }
    @Override protected void onProgressUpdate(Integer... p) {
        progress.setProgress(p[0]);         // main thread
    }
    @Override protected void onPostExecute(User user) {
        render(user);                       // main thread
    }
}.execute("42");

The generics are Params, Progress, Result, and the three callbacks genuinely run where the names suggest. It was a real improvement, and it was deprecated in API 30 for reasons that are worth stating precisely:1

  • It holds an implicit reference to the enclosing Activity, so a long task leaks the screen.
  • Results are lost on rotation. The activity is destroyed; the task's onPostExecute runs against a dead instance.
  • .execute() was serial by default on API 11+, which meant one slow task blocked every other AsyncTask in the app. The fix, .executeOnExecutor(THREAD_POOL_EXECUTOR), was a well-kept secret.
  • It cannot be cancelled safely mid-flight; cancel() only sets a flag and skips the result callback.
  • It has no error channel. An exception in doInBackground is swallowed into onPostExecute never being called.

Every one of those is a design lesson that coroutines address directly. Rotation → a lifecycle-aware scope. Serial executor → an explicit dispatcher. No error channel → exceptions propagate to the caller.

Era 3: Loaders (API 11, deprecated API 28)#

Loaders targeted a specific problem: reading a Cursor from a content provider without blocking, and surviving configuration changes. LoaderManager owned the loader across recreations, which made it the first framework API that got rotation right.

class NotesFragment : Fragment() {
    override fun onCreate(savedInstanceState: Bundle?) {
        loaderManager.initLoader(0, null, object : LoaderManager.LoaderCallbacks<Cursor> {
            override fun onCreateLoader(id: Int, args: Bundle?) =
                applicationContext.contentResolver
                    .query(NOTES_URI, null, null, null, null)
                    .let { CursorLoader(requireContext(), NOTES_URI, null, null, null, null) }
 
            override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor) { render(cursor) }
            override fun onLoaderReset(loader: Loader<Cursor>) { render(null) }
        })
    }
}

It was deprecated alongside Fragment's setRetainInstance model, replaced by ViewModel plus LiveData/. The lesson it taught — the data source should outlive the screen — became ViewModel.

Era 4: RxJava#

RxJava arrived from the non-Android world and brought a genuinely new idea: an asynchronous computation is a value you can compose.

repository.users()
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .debounce(300, TimeUnit.MILLISECONDS)
    .distinctUntilChanged()
    .filter { it.isActive }
    .map { it.toUiModel() }
    .subscribe(
        { rows -> render(rows) },
        { error -> showError(error) },
    )

What it fixed: cancellation (Disposable), composition (operators), testing (schedulers can be replaced with a TestScheduler), and error handling (a real onError channel).

What it cost: a very large method count before R8, a steep learning curve, and a style in which a single expression could hide a dozen subscriptions with different lifecycles. The AutoDispose library existed solely to fix the leak problem RxJava created.

RxJava 2.0 (2016) rewrote it around the Reactive Streams specification with explicit backpressure.2 RxJava 3 is still in use, and androidx.compose.runtime:runtime-rxjava3 exists so Compose can consume it. It is no longer the default recommendation, but it is not dead.

Era 5: Coroutines#

Kotlin coroutines became stable with Kotlin 1.3 in 2018, and Google made Kotlin the recommended language for Android in 2019.3 The key design decision is that a suspend function looks like a normal function:

suspend fun loadUser(id: String): User = coroutineScope {
    val cached = async(Dispatchers.IO) { dao.find(id) }
    val fresh = async(Dispatchers.IO) { api.fetch(id) }
    cached.await() ?: fresh.await()
}

No callbacks, no Disposable, no operator chain. The compiler transforms the function into a state machine that can suspend at each call site and resume later — a transformation the Kotlin compiler performs, not the runtime.

Three properties follow, and each maps to an earlier era's failure:

PropertyFixes
Structured concurrencythe "ten threads with no relationship" problem: children live inside a parent scope
Cancellation propagatesthe AsyncTask "result arrives after destroy" problem
Exceptions propagate to the callerAsyncTask's missing error channel
class NotesViewModel(
    private val repository: NotesRepository,
) : ViewModel() {
 
    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()
 
    fun refresh() {
        viewModelScope.launch {
            _state.value = UiState.Loading
            _state.value = runCatching { repository.load() }
                .fold(
                    onSuccess = { UiState.Ready(it) },
                    onFailure = { UiState.Error(it) },
                )
        }
    }
}

viewModelScope is cancelled when the ViewModel is cleared. runCatching handles the error channel explicitly. StateFlow gives the UI an observable value that survives rotation, because the ViewModel does.

Flow: the reactive idea, kept#

Coroutines did not discard Rx's central insight. Flow is a cold asynchronous stream with composable operators:

val results: Flow<SearchResult> = query
    .debounce(300)
    .distinctUntilChanged()
    .flatMapLatest { q -> repository.search(q) }

flatMapLatest cancels the previous search when a new query arrives — the same behaviour as Rx's switchMap, expressed with suspension instead of a scheduler. The difference is that a Flow is consumed inside a coroutine, so cancellation and lifecycle come from the surrounding scope rather than from a separate subscription object.

Why each era's API looked the way it did#

The shape of each tool is a response to the constraints of its moment.

AsyncTask had generics for Params, Progress, Result because Java had no lambdas. Its three callbacks exist because there was no way to write a suspend function — the class had to name the before, during and after phases.

RxJava's operator chain exists because Java's streams were not available on old Android versions, and because a callback needs a composable representation to avoid nesting.

Coroutines' suspend keyword exists because Kotlin's compiler can transform a function into a state machine. None of the earlier tools could offer that, because the language could not express it.

That is the useful lens for the next era: whatever replaces coroutines will be enabled by a language or runtime feature that does not exist yet, not by a better library.

What replaced what, and what did not#

OldNewNotes
AsyncTaskcoroutines with viewModelScopedeprecated at API 30
LoaderViewModel + Flow / Roomdeprecated at API 28
LiveDataStateFlow (still supported)both are lifecycle-aware
RxJavaFlow for new codeRx still fine, especially with existing investment
Thread + HandlerHandlerThread or a dispatcherstill valid for serial work
IntentServiceWorkManagerIntentService was deprecated in API 30

WorkManager deserves its own note. It is not a coroutine replacement; it is for deferrable work that must run even if the app exits, with constraints and a guaranteed execution. Coroutines are for work whose result someone is waiting for now.

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

Each row corresponds to an era above. The direction is consistent: less manual lifecycle management, more structure the compiler can check.

Under the hood#

Curiosity corner#

  • AsyncTask's default serial executor was a behaviour change in API 11 that silently throttled apps written against API 10.
  • IntentService was a Service with a HandlerThread inside. It was convenient precisely because it bundled the two primitives this module is about.
  • RxJava's method count was a real problem before R8: the library plus its operators could add tens of thousands of methods, which mattered when the 64K limit was a hard wall.

Try it#

Find remaining AsyncTask uses in a codebase

grep -rn "AsyncTask" --include=.kt --include=.java .

See how many threads a coroutine-heavy app uses

adb shell ps -T -p $(adb shell pidof com.example.notes) | wc -l

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. docsAndroid 10 features and APIsdeveloper.android.comaccessed 2026-10-27↩
  3. docsAndroid for Kotlin developersdeveloper.android.comaccessed 2026-10-27↩
  4. docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
  5. docsKotlin documentationkotlinlang.orgaccessed 2026-10-27↩
  6. docs7 featuresdeveloper.android.comaccessed 2026-10-27↩
  7. docsFeatures and APIs overview | Android 12developer.android.comaccessed 2026-10-27↩