launch und async in Kotlin-Coroutines
Du lernst, wann launch reicht und wann async passt. Der Fokus liegt auf Ergebnissen, Nebenwirkungen und sauberer Android-Architektur.
Wenn du mit Kotlin-Coroutines in Android arbeitest, triffst du sehr oft dieselbe Entscheidung: Startest du Arbeit, die nur etwas auslösen soll, oder brauchst du ein Ergebnis? Genau hier liegt der Unterschied zwischen launch und async. Beide starten Coroutines, beide laufen in einem CoroutineScope, beide können auf Dispatcher wie Dispatchers.IO wechseln. Der wichtige Punkt ist aber nicht, welcher Builder „besser“ ist, sondern welche Absicht dein Code ausdrückt: launch für fire-and-forget-Aufgaben ohne Rückgabewert, async für Arbeit mit einem später benötigten Resultat.
Was ist das?
launch und async sind Coroutine Builder. Ein Builder erzeugt und startet eine Coroutine innerhalb eines vorhandenen Scopes. In Android begegnet dir das zum Beispiel im viewModelScope, in Repository-Funktionen, bei Tests mit runTest oder in der Datenebene, wenn Netzwerk, Datenbank und Cache zusammenspielen.
launch startet eine Coroutine und gibt ein Job zurück. Ein Job steht für die laufende Arbeit: Du kannst prüfen, ob sie aktiv ist, sie abbrechen oder auf ihr Ende warten. Ein Job enthält aber kein fachliches Ergebnis. Deshalb passt launch, wenn du eine Aktion ausführen willst: einen Button-Klick verarbeiten, eine Datenbank aktualisieren, einen Log-Eintrag schreiben, einen Sync starten oder einen UI-State setzen. Das ist das typische fire-and-forget-Modell. Du startest Arbeit, aber der aufrufende Code erwartet keinen Wert wie User, List<Article> oder Boolean.
async startet ebenfalls eine Coroutine, gibt aber ein Deferred<T> zurück. Deferred ist eine spezielle Form von Job, die zusätzlich ein Ergebnis vom Typ T liefert. Dieses Ergebnis liest du später mit await(). Dadurch eignet sich async, wenn mehrere Werte parallel berechnet oder geladen werden sollen und du diese Werte anschließend zusammenführen musst. Der Begriff results ist hier zentral: Wenn kein Ergebnis gebraucht wird, ist async meist das falsche Signal.
Das klingt zunächst klein, hat in echten Android-Projekten aber große Wirkung auf Lesbarkeit und Fehlerverhalten. Wenn du überall async nutzt, obwohl du keine Werte erwartest, erschwerst du Code-Reviews. Andere Entwickler fragen sich dann, wo das Ergebnis verarbeitet wird. Wenn du dagegen launch nutzt, obwohl eigentlich ein Wert gebraucht wird, landest du schnell bei veränderlichem Shared State, unklaren Nebenwirkungen oder Race Conditions. Die Builder sind also nicht nur technische Werkzeuge, sondern Teil deiner Architektur-Sprache.
Im Roadmap-Kontext passt dieses Thema in den Bereich Coroutines, Flow und Background Work. Du lernst hier nicht jeden Dispatcher, jede Flow-Operation oder WorkManager-Strategie. Die Leitfrage bleibt enger: Muss diese Coroutine ein Ergebnis liefern oder soll sie nur Arbeit ausführen?
Wie funktioniert es?
Das mentale Modell ist einfach: launch sagt „mach das“, async sagt „berechne das und gib mir später den Wert“. Aus dieser Unterscheidung ergeben sich mehrere praktische Regeln.
Eine mit launch gestartete Coroutine läuft im gegebenen Scope. Im ViewModel ist das oft viewModelScope. Dieser Scope wird automatisch abgebrochen, wenn das ViewModel gelöscht wird. Für UI-nahe Arbeit ist das wichtig, weil du keine Arbeit weiterlaufen lassen willst, die zu einem nicht mehr existierenden Screen gehört. Typisch ist ein Button-Klick in Jetpack Compose: Die UI ruft eine Funktion im ViewModel auf, das ViewModel startet mit viewModelScope.launch eine Coroutine, ruft ein Repository auf und aktualisiert den UI-State.
async funktioniert ähnlich, aber du erhältst ein Deferred<T>. Der Wert entsteht erst, wenn die Coroutine fertig ist. Mit await() wartest du auf diesen Wert. Das ist nützlich, wenn zwei unabhängige Operationen parallel laufen können. Beispiel: Du brauchst ein Nutzerprofil und dazu passende Einstellungen. Wenn beide aus unabhängigen Quellen kommen, kannst du beide Operationen mit async starten und danach beide Ergebnisse mit await() einsammeln.
Wichtig ist dabei Structured Concurrency. Coroutines sollen in einer klaren Eltern-Kind-Struktur laufen. Wenn du async in einem coroutineScope startest, gehören diese Kind-Coroutines zum Scope der umgebenden suspendierenden Funktion. Schlägt eine davon fehl, wird der Scope kontrolliert beendet, und die anderen Kind-Coroutines werden abgebrochen. Genau dieses Verhalten ist in Android wertvoll, weil Fehler nicht heimlich verschwinden sollen.
Ein häufiger Denkfehler ist, async als allgemeines Mittel für „parallel“ zu sehen. Parallelität allein reicht nicht als Begründung. Wenn du kein Ergebnis brauchst, ist launch meist klarer. Wenn du doch ein Ergebnis brauchst, aber sofort danach await() aufrufst, gewinnst du keine Parallelität. Dann ist normaler suspendierender Code oft lesbarer.
Beispiel für wenig sinnvollen async-Einsatz:
suspend fun loadProfile(): Profile {
val deferred = coroutineScope {
async { api.fetchProfile() }
}
return deferred.await()
}
Der Code startet zwar async, wartet aber praktisch direkt auf genau diese eine Operation. Eine normale suspendierende Funktion reicht:
suspend fun loadProfile(): Profile {
return api.fetchProfile()
}
async lohnt sich vor allem, wenn mehrere unabhängige Arbeiten zuerst gestartet und danach gemeinsam ausgewertet werden:
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { userRepository.getProfile() }
val messages = async { messageRepository.getUnreadMessages() }
Dashboard(
profile = profile.await(),
unreadMessages = messages.await()
)
}
Hier ist die Absicht klar: Beide Werte werden gebraucht, beide können unabhängig geladen werden, und das Ergebnis entsteht erst aus der Kombination. Das Deferred ist kein Selbstzweck, sondern ein Zwischenträger für ein echtes Resultat.
In der Datenebene solltest du außerdem beachten, wo Coroutine Builder überhaupt stehen. Gute Repository-APIs sind oft suspendierende Funktionen oder liefern einen Flow. Ein Repository muss nicht immer selbst launch starten. Häufig ist es besser, wenn die aufrufende Schicht den Scope besitzt. So bleibt klar, welcher Lebenszyklus die Arbeit kontrolliert. Für länger laufende Hintergrundarbeit, die App-Neustarts überstehen muss, ist wiederum WorkManager ein anderes Thema. launch im ViewModel ist nicht dafür gedacht, verlässliche Arbeit über Prozessgrenzen hinweg zu garantieren.
Auch Flow passt in diese Denkweise. Ein Flow steht für eine Folge von Werten über Zeit, nicht für einen einzelnen späteren Rückgabewert wie Deferred<T>. Wenn dein Repository Änderungen aus einer Datenbank beobachtet, ist Flow oft passender als async. Wenn du dagegen zwei einzelne Werte gleichzeitig laden und kombinieren willst, kann async in einer suspendierenden Funktion sinnvoll sein.
In der Praxis
Stell dir einen Compose-Screen vor, auf dem der Nutzer eine Notiz speichern und danach den aktuellen Detailzustand sehen soll. Das Speichern selbst ist eine Aktion: Der Nutzer tippt auf einen Button, und das ViewModel soll die Daten in der Datenebene aktualisieren. Dafür passt launch. Das Laden mehrerer unabhängiger Zusatzdaten für einen Detailzustand kann dagegen ein Fall für async sein.
class NoteViewModel(
private val noteRepository: NoteRepository,
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(NoteUiState())
val uiState: StateFlow<NoteUiState> = _uiState.asStateFlow()
fun onSaveClicked(note: Note) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null) }
try {
noteRepository.saveNote(note)
_uiState.update { it.copy(isSaving = false, saved = true) }
} catch (exception: IOException) {
_uiState.update {
it.copy(
isSaving = false,
errorMessage = "Speichern fehlgeschlagen"
)
}
}
}
}
fun loadDetails(noteId: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val details = noteRepository.loadNoteDetails(noteId)
val author = userRepository.loadUser(details.authorId)
_uiState.update {
it.copy(
isLoading = false,
noteDetails = details,
author = author
)
}
} catch (exception: IOException) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Details konnten nicht geladen werden"
)
}
}
}
}
}
In diesem ersten Beispiel wird nur launch genutzt. Das ist völlig in Ordnung. onSaveClicked braucht keinen Rückgabewert an den aufrufenden Compose-Code. Die Funktion verändert den UI-State. Auch loadDetails startet eine UI-nahe Aktion. Innerhalb der Coroutine werden die suspendierenden Repository-Funktionen nacheinander aufgerufen. Wenn loadUser vom Ergebnis aus loadNoteDetails abhängt, ist diese Reihenfolge korrekt. async würde hier keinen Nutzen bringen, weil du die authorId erst nach dem Laden der Details kennst.
Jetzt ein Fall, in dem async sinnvoll sein kann. Angenommen, ein Dashboard braucht Profil, lokale Empfehlungen und ungelesene Nachrichten. Die drei Quellen hängen nicht voneinander ab. Dann kann eine suspendierende Repository- oder Use-Case-Funktion die Ergebnisse parallel anfordern:
class DashboardRepository(
private val profileDataSource: ProfileDataSource,
private val recommendationDataSource: RecommendationDataSource,
private val messageDataSource: MessageDataSource
) {
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { profileDataSource.getProfile() }
val recommendations = async { recommendationDataSource.getRecommendations() }
val unreadMessages = async { messageDataSource.getUnreadMessages() }
Dashboard(
profile = profile.await(),
recommendations = recommendations.await(),
unreadMessages = unreadMessages.await()
)
}
}
Hier ist async passend, weil jedes Deferred ein konkretes Ergebnis trägt. Das abschließende Dashboard kann erst gebaut werden, wenn alle Ergebnisse vorhanden sind. Gleichzeitig bleibt die Fehlerbehandlung strukturiert: Wenn eine Quelle fehlschlägt, schlägt die suspendierende Funktion fehl, und der aufrufende Scope kann den Fehler behandeln.
Eine gute Entscheidungsregel lautet: Verwende launch, wenn die Funktion eine Aktion im aktuellen Scope startet und das Ergebnis über State, Datenbank, Cache oder UI-Nebenwirkung sichtbar wird. Verwende async, wenn du innerhalb einer suspendierenden Operation mehrere unabhängige Werte brauchst und diese Werte anschließend direkt zusammensetzt. Frage dich bei jedem async: Wo wird await() aufgerufen, und welcher fachliche Wert kommt dabei heraus? Wenn du darauf keine klare Antwort hast, ist launch oder eine normale suspendierende Funktion wahrscheinlich passender.
Typische Stolperfallen
Die erste Stolperfalle ist ein vergessenes await(). Wenn du async startest, das Deferred aber nie auswertest, ist dein Code irreführend. Je nach Fehlerfall kann das Verhalten schwer zu verstehen sein, weil die fachliche Absicht fehlt. In Code-Reviews solltest du deshalb prüfen, ob jedes Deferred wirklich gebraucht wird.
Die zweite Stolperfalle ist GlobalScope. Für Android-Code ist das fast immer ein Warnsignal. Arbeit ohne klaren Scope lässt sich schlechter abbrechen und passt nicht sauber zum Lebenszyklus von ViewModel, Repository oder App-Komponente. Nutze lieber viewModelScope, lifecycleScope oder einen bewusst injizierten Application-Scope für Arbeit, die wirklich länger leben soll. Auch dann sollte der Scope fachlich begründet sein.
Die dritte Stolperfalle ist paralleles Laden ohne Abhängigkeiten zu prüfen. Nicht jede scheinbar getrennte Operation ist wirklich unabhängig. Wenn ein Request eine ID aus einem anderen Request braucht, ist async nur zusätzliche Komplexität. Wenn zwei Operationen dieselbe lokale Ressource verändern, kann paralleler Code sogar neue Fehler erzeugen. Prüfe daher zuerst die Datenabhängigkeiten, dann den Builder.
Die vierte Stolperfalle betrifft Dispatcher. launch und async entscheiden nicht automatisch, ob Arbeit auf dem Main Thread, IO-Thread oder Default-Dispatcher läuft. In Android sollte blockierende IO-Arbeit nicht auf dem Main Thread landen. Viele moderne APIs sind suspendierend und kümmern sich intern um passende Dispatcher. Wenn du aber selbst blockierende Arbeit kapselst, muss der Wechsel mit withContext(Dispatchers.IO) oder einer passenden Architekturentscheidung erfolgen. Der Builder allein löst dieses Problem nicht.
Für Tests kannst du dein Verständnis gut prüfen. Schreibe einen Test für eine suspendierende Funktion, die zwei Fake-DataSources mit Verzögerung parallel lädt. Mit runTest kannst du kontrollieren, ob beide Ergebnisse korrekt kombiniert werden. Für ViewModels prüfst du eher den beobachtbaren State: Nach einem launch sollte isLoading gesetzt, ein Repository-Aufruf ausgeführt und danach der Erfolgs- oder Fehlerzustand sichtbar sein. Beim Debuggen hilft dir außerdem, Breakpoints an async, await und Fehlerstellen zu setzen. So siehst du, wann Arbeit gestartet wird und wann dein Code wirklich auf ein Ergebnis wartet.
In Compose bleibt die UI selbst möglichst dünn. Der Button ruft etwa viewModel.onSaveClicked(note) auf. Die Entscheidung zwischen launch und async liegt dann im ViewModel oder in der darunterliegenden Use-Case- beziehungsweise Repository-Schicht. Dadurch bleibt die Oberfläche deklarativ, während die Coroutine-Logik dort sitzt, wo auch Fehlerbehandlung, State und Datenabhängigkeiten verstanden werden.
Fazit
launch und async unterscheiden sich weniger durch ihr Aussehen als durch ihre Absicht: launch steht für eine gestartete Aktion ohne direktes Ergebnis, async für ein später benötigtes Resultat über Deferred und await. Wenn du diese Entscheidung sauber triffst, wird dein Android-Code lesbarer, testbarer und besser an Lebenszyklen gebunden. Prüfe in deinem nächsten Code-Review jede Coroutine mit einer einfachen Frage: Erwartet der aufrufende Code einen Wert oder nur eine Wirkung? Setze danach einen Debugger-Breakpoint an Start und Ergebnisstelle, oder schreibe einen kleinen Test mit Fake-Repositories. So merkst du schnell, ob dein Builder wirklich zu der Aufgabe passt.