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.
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#
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:
- No cancellation. The thread runs to completion; the posted callback fires even if the screen is gone.
- No error propagation. An exception on the worker kills the thread silently unless you install an uncaught handler.
- Manual lifecycle. Nothing removes the callback on destroy.
- 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
onPostExecuteruns 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
doInBackgroundis swallowed intoonPostExecutenever 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:
| Property | Fixes |
|---|---|
| Structured concurrency | the "ten threads with no relationship" problem: children live inside a parent scope |
| Cancellation propagates | the AsyncTask "result arrives after destroy" problem |
| Exceptions propagate to the caller | AsyncTask'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#
| Old | New | Notes |
|---|---|---|
AsyncTask | coroutines with viewModelScope | deprecated at API 30 |
Loader | ViewModel + Flow / Room | deprecated at API 28 |
LiveData | StateFlow (still supported) | both are lifecycle-aware |
| RxJava | Flow for new code | Rx still fine, especially with existing investment |
Thread + Handler | HandlerThread or a dispatcher | still valid for serial work |
IntentService | WorkManager | IntentService 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#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 33 | 13 Tiramisu |
|
| 37 | 17 Cinnamon Bun |
|
Each row corresponds to an era above. The direction is consistent: less manual lifecycle management, more structure the compiler can check.
Under the hood#
- AsyncTask.javathe deprecated class, retained for compatibility
- LoaderManager.javathe lifecycle-aware loader that preceded ViewModel
- Handler.javathe primitive every era builds on
- the coroutine library, vendored in the platform build for some components
Curiosity corner#
- AsyncTask's default serial executor was a behaviour change in API 11 that silently throttled apps written against API 10.
IntentServicewas 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#
grep -rn "AsyncTask" --include=.kt --include=.java .
adb shell ps -T -p $(adb shell pidof com.example.notes) | wc -l
Check yourself#
Check yourself
A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?
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.
- docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsAndroid 10 features and APIsdeveloper.android.comaccessed 2026-10-27↩
- docsAndroid for Kotlin developersdeveloper.android.comaccessed 2026-10-27↩
- docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsKotlin documentationkotlinlang.orgaccessed 2026-10-27↩
- docs7 featuresdeveloper.android.comaccessed 2026-10-27↩
- docsFeatures and APIs overview | Android 12developer.android.comaccessed 2026-10-27↩