Timeout Handling in Android
Timeouts begrenzen lange Arbeit und verbessern Fehlpfade. Du lernst, wie withTimeout UX und Stabilität schützt.
Timeout Handling bedeutet, dass du lang laufende Arbeit bewusst begrenzt und für den Fall planst, dass sie nicht rechtzeitig fertig wird. In Android ist das kein Randthema: Netzwerk, Datenbank, Standort, Bluetooth, Dateizugriffe und externe Dienste können hängen, langsam antworten oder unter schlechten Bedingungen scheitern. Ein guter Timeout schützt nicht nur Ressourcen, sondern auch die UX, weil deine App nicht endlos wartet, sondern einen klaren Zustand zeigt und eine passende nächste Aktion anbietet.
Was ist das?
Timeout Handling ist die Entscheidung: Eine Operation bekommt nur eine bestimmte Zeitspanne. Ist sie bis dahin nicht abgeschlossen, behandelst du das als Failure. Dieser Failure ist nicht automatisch ein Programmierfehler. Er kann ein normaler Laufzeitfall sein, etwa bei instabilem Netz, überlastetem Server oder einem Gerät im Energiesparmodus. Für dich als Android-Entwickler heißt das: Du modellierst Zeit als Teil deines Fehlerverhaltens.
Das mentale Modell ist wichtig. Eine Coroutine ist kein Zauberkanal, der Arbeit garantiert fertigstellt. Sie ist eine strukturierte Ausführungseinheit, die abgebrochen werden kann. withTimeout setzt eine zeitliche Grenze um einen suspendierenden Block. Wenn der Block zu lange braucht, wird er per Cancellation beendet und wirft eine TimeoutCancellationException. Das passt zur Coroutine-Welt, in der Abbruch kooperativ funktioniert: Suspendierende Funktionen reagieren auf Cancellation, CPU-lastige Schleifen müssen regelmäßig abbrechbar bleiben.
Im Android-Kontext liegt Timeout Handling meist nicht direkt in einer Compose-Composable. Die UI zeigt Zustände, aber die fachliche Entscheidung über Timeouts gehört eher in Repository, Use Case oder eine Datenquelle. Compose beobachtet dann einen Zustand wie Loading, Success, Timeout oder Error. So bleibt deine Oberfläche testbar und die Geschäftslogik hängt nicht an einem konkreten Screen.
Wichtig ist auch die Abgrenzung: Ein Timeout ist kein Ersatz für sauberes Lifecycle-Handling. viewModelScope sorgt dafür, dass Arbeit beim Beenden des ViewModels abgebrochen wird. Ein Timeout sorgt zusätzlich dafür, dass eine einzelne Operation nicht beliebig lange läuft. Beides ergänzt sich. Ohne Timeout kann eine Anfrage zwar beim Verlassen des Screens abgebrochen werden, aber während der Screen aktiv ist trotzdem viel zu lange warten.
Wie funktioniert es?
Die zentrale API ist withTimeout(timeMillis) { ... }. Sie führt einen suspendierenden Block aus und bricht ihn ab, wenn die angegebene Zeit überschritten wird. Für Fälle, in denen du keine Exception auswerten willst, gibt es withTimeoutOrNull. Diese Variante liefert bei Zeitüberschreitung null. Beide Varianten nutzen Coroutine-Cancellation. Das bedeutet: finally-Blöcke laufen weiterhin, aber suspendierende Aufräumarbeiten in finally benötigen bei Bedarf einen NonCancellable-Kontext. Das brauchst du selten, solltest es aber kennen, wenn du Ressourcen explizit freigibst.
Ein häufiger Denkfehler ist, Timeout Handling nur als technische Abbruchfunktion zu sehen. Für echte Apps ist der UX-Teil genauso wichtig. Wenn du eine Ladeanzeige nach acht Sekunden abbrichst, muss der Nutzer wissen, was passiert ist. Eine Meldung wie „Etwas ist schiefgelaufen“ ist oft zu ungenau. Besser ist ein Zustand wie „Die Anfrage dauert zu lange. Prüfe deine Verbindung und versuche es erneut.“ Dazu passt eine Wiederholen-Aktion, wenn die Operation idempotent ist oder sicher erneut gestartet werden kann.
In einer modernen Architektur solltest du Timeouts dort setzen, wo du die Erwartung an die Operation kennst. Ein Login kann eine andere Grenze haben als das Synchronisieren vieler Datensätze. Eine Suche braucht oft ein strengeres Zeitlimit als ein optionaler Hintergrundabgleich. Setzt du überall pauschal denselben Wert, bekommst du schnell schlechte UX: Manche Aktionen brechen zu früh ab, andere warten zu lange.
Bei Flow ist die Einordnung ähnlich. Ein Flow beschreibt einen Datenstrom. Wenn du auf den ersten Wert eines Flows wartest, kannst du diese Wartezeit begrenzen. Du solltest aber unterscheiden, ob du den gesamten Stream beenden willst oder nur eine einzelne Quelle absicherst. Besonders bei UI-State-Flows ist ein Timeout im falschen Bereich riskant, weil du dadurch einen dauerhaft beobachteten Datenstrom abbrechen kannst, obwohl nur eine einzelne Initialisierung langsam war.
Coroutine-Best-Practice bedeutet hier außerdem: Exceptions nicht blind verschlucken. TimeoutCancellationException ist eine Form von CancellationException. Wenn du allgemein Exception fängst und alles in einen Fehlerzustand wandelst, kannst du normale Cancellation verfälschen. In vielen Fällen willst du Timeouts gezielt behandeln, andere Abbrüche aber weiterreichen. Dadurch bleibt strukturierte Nebenläufigkeit intakt.
In der Praxis
Angenommen, dein ViewModel lädt Profilinformationen. Die UI soll nicht endlos einen Spinner zeigen. Das Repository begrenzt den Netzwerkaufruf, wandelt den Timeout in ein fachliches Ergebnis um und lässt die UI daraus einen verständlichen Zustand bauen.
sealed interface ProfileResult {
data class Success(val profile: UserProfile) : ProfileResult
data object Timeout : ProfileResult
data class Failure(val cause: Throwable) : ProfileResult
}
class ProfileRepository(
private val api: ProfileApi
) {
suspend fun loadProfile(userId: String): ProfileResult {
return try {
val profile = withTimeout(8_000) {
api.fetchProfile(userId)
}
ProfileResult.Success(profile)
} catch (e: TimeoutCancellationException) {
ProfileResult.Timeout
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
ProfileResult.Failure(e)
}
}
}
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: UserProfile? = null,
val message: String? = null,
val canRetry: Boolean = false
)
class ProfileViewModel(
private val repository: ProfileRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun load(userId: String) {
viewModelScope.launch {
_uiState.value = ProfileUiState(isLoading = true)
_uiState.value = when (val result = repository.loadProfile(userId)) {
is ProfileResult.Success -> ProfileUiState(profile = result.profile)
ProfileResult.Timeout -> ProfileUiState(
message = "Die Anfrage dauert zu lange. Versuche es erneut.",
canRetry = true
)
is ProfileResult.Failure -> ProfileUiState(
message = "Das Profil konnte nicht geladen werden.",
canRetry = true
)
}
}
}
}
Dieses Beispiel zeigt mehrere praktische Regeln. Erstens: Der Timeout sitzt nah an der Operation, die begrenzt werden soll. Das Repository kennt den API-Aufruf und kann daraus ein fachliches Ergebnis machen. Zweitens: Cancellation wird nicht komplett geschluckt. Ein normaler Abbruch, etwa weil das ViewModel beendet wurde, wird weitergeworfen. Drittens: Die UI bekommt keinen rohen Stacktrace, sondern einen Zustand, aus dem sie Text und Retry-Button ableiten kann.
In Compose würdest du uiState sammeln und daraus konkrete UI anzeigen. Während isLoading aktiv ist, zeigst du eine Ladeanzeige. Bei message zeigst du einen kurzen Hinweis. Wenn canRetry wahr ist, bekommt der Nutzer eine Aktion zum erneuten Laden. Die Composable muss nicht wissen, ob intern withTimeout, ein HTTP-Client-Timeout oder eine andere Strategie verwendet wurde. Diese Trennung ist wichtig, weil UI-Code sonst schnell voll mit technischen Details wird.
Eine sinnvolle Entscheidungsregel lautet: Setze einen Timeout nur dann, wenn du auch weißt, was nach Ablauf passieren soll. Wenn dein Code nach dem Timeout nur eine generische Exception protokolliert und die UI weiter im Ladezustand bleibt, hast du das eigentliche Problem nicht gelöst. Timeout Handling besteht aus Grenze, Failure-Modell und Recovery-Pfad.
Typische Stolperfalle Nummer eins ist ein zu aggressiver Timeout. Drei Sekunden können im WLAN bequem wirken, aber in der Bahn oder bei einem älteren Gerät zu kurz sein. Dann erzeugst du künstliche Fehler. Wähle Werte anhand der konkreten Nutzeraktion und beobachte reale Fehlerraten. Für kritische Aktionen kann ein längerer Timeout mit Zwischenfeedback besser sein als ein schneller Abbruch.
Typische Stolperfalle Nummer zwei ist doppeltes Timeout Handling an mehreren Schichten. Ein HTTP-Client kann eigene Connect-, Read- und Call-Timeouts haben. Zusätzlich kann dein Repository withTimeout nutzen. Das ist nicht falsch, aber du musst verstehen, welcher Timeout zuerst greift und welche Fehlermeldung daraus entsteht. Sonst bekommst du inkonsistente Fehlerzustände, die schwer zu testen sind.
Typische Stolperfalle Nummer drei ist withTimeoutOrNull ohne saubere Null-Bedeutung. Wenn null auch ein gültiges fachliches Ergebnis sein kann, wird dein Code unklar. Dann ist ein eigener Result-Typ meist lesbarer. Gerade Lernende unterschätzen, wie stark solche kleinen Modellierungsentscheidungen spätere Bugs beeinflussen.
Du kannst dein Verständnis gut mit Tests prüfen. Verwende in Coroutine-Tests kontrollierte Test-Dispatcher und simuliere verzögerte suspendierende Funktionen. Prüfe, dass nach Überschreiten der Zeit ein Timeout-Zustand entsteht, dass Retry erneut startet und dass normale Cancellation nicht als UI-Fehler angezeigt wird. Im Debugger kannst du außerdem beobachten, ob finally-Blöcke laufen und ob dein Ladezustand nach dem Abbruch wirklich beendet wird.
Im Code-Review solltest du bei Timeout Handling gezielt nach drei Fragen suchen: Ist die Zeitgrenze fachlich begründet? Wird CancellationException korrekt behandelt? Hat der Nutzer nach dem Failure einen verständlichen nächsten Schritt? Wenn eine dieser Fragen offen bleibt, ist der Code noch nicht fertig, auch wenn er technisch kompiliert.
Fazit
Timeout Handling macht deine Android-App robuster, weil lang laufende Arbeit nicht unbegrenzt Ressourcen und Aufmerksamkeit bindet. Mit withTimeout setzt du eine klare Grenze, aber der wichtige Teil liegt im vollständigen Fehlerpfad: Cancellation respektieren, fachliche Zustände modellieren, verständliche UX anbieten und Wiederholung bewusst erlauben. Prüfe das aktiv in einer kleinen Übung: Baue einen suspendierenden Fake-Aufruf mit Verzögerung, begrenze ihn per Timeout, zeige in Compose einen Retry-Zustand und schreibe einen Test, der Timeout, Erfolg und normalen Abbruch getrennt verifiziert.