AbortController gegen hängende Requests und Race Conditions
Ein Composable, das Daten lädt, aber laufende Requests nicht abbrechen kann, produziert schleichend zwei Arten von Problemen: veraltete Antworten, die nach dem Unmount noch versuchen, einen längst zerstörten Zustand zu aktualisieren, und Race Conditions, bei denen ein schneller zweiter Request von einem langsameren ersten überschrieben wird. AbortController löst beide Probleme, wenn man ihn konsequent in das eigene useFetch-Composable integriert.
Inhaltsverzeichnis
- 1. Warum ein laufender Request beim Unmount abgebrochen werden sollte
- 2. AbortController Grundlagen
- 3. Ein eigenes useFetch-Composable bauen
- 4. Automatisches Abbrechen bei erneutem Aufruf
- 5. Abbrechen beim Unmount über onUnmounted
- 6. Fehlerbehandlung: AbortError von echten Fehlern unterscheiden
- 7. Abbrechen bei Änderung reaktiver Parameter
- 8. Timeout mit AbortController kombinieren
- 9. Abbrechbare Composables testen
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein laufender Request beim Unmount abgebrochen werden sollte
Wird eine Komponente entfernt, während ein von ihr ausgelöster Fetch-Request noch läuft, läuft der Netzwerkaufruf im Browser trotzdem bis zum Ende weiter, weil ein normales fetch-Promise keine Verbindung zum Lebenszyklus einer Vue-Komponente kennt. Kommt die Antwort schließlich an, versucht der then-Handler, einen ref zu aktualisieren, der zu einer bereits verworfenen Komponenteninstanz gehört. In Vue 3 führt das zwar meist nicht zu einem harten Fehler, weil der ref selbst weiterhin existiert, aber es ist verschwendete Arbeit und in Kombination mit weiteren Seiteneffekten wie Toasts oder Navigationen ein reales Risiko für inkonsistentes Verhalten.
Problematischer wird es bei Listen- und Suchansichten, in denen Nutzer schnell zwischen verschiedenen Filtern oder Detailseiten wechseln. Jeder Wechsel löst einen neuen Request aus, aber ohne Abbruchmechanismus laufen alle bisherigen Requests im Hintergrund weiter und können in beliebiger Reihenfolge antworten. Ein Composable, das nicht abbricht, hat also nicht nur ein Aufräumproblem beim Unmount, sondern auch ein strukturelles Problem bei jeder erneuten Nutzung während der Komponente noch gemountet ist.
2. AbortController Grundlagen
Die Fetch API unterstützt seit Langem ein signal-Argument, das man aus einem AbortController bezieht und an die Optionen von fetch übergibt. Ruft man anschließend controller.abort() auf, wechselt das zugehörige Promise nicht in den Erfolgs-, sondern in den Fehlerzustand, und zwar mit einem Fehler, dessen name-Eigenschaft den Wert AbortError trägt. Der Browser bricht dabei die zugrunde liegende Netzwerkverbindung tatsächlich ab, es handelt sich also nicht nur um ein ignoriertes Promise, sondern um einen echten Verbindungsabbruch, der auch Bandbreite spart.
Ein einzelner AbortController kann für genau einen Abbruchvorgang verwendet werden; nach dem Aufruf von abort() ist er verbraucht und muss für den nächsten Request durch eine frische Instanz ersetzt werden. Diese Einweg-Natur ist wichtig für das Design eines Composables, weil man den Controller nicht wiederverwenden, sondern bei jedem neuen Request neu erzeugen und die Referenz auf den vorherigen Controller entsprechend austauschen muss.
// Reines Beispiel ohne Vue: AbortController mit fetch
const controller = new AbortController()
fetch('/api/products', { signal: controller.signal })
.then((response) => response.json())
.catch((error) => {
if (error.name === 'AbortError') {
console.log('Request wurde abgebrochen')
return
}
throw error
})
// Irgendwo anders im Code, z.B. beim Unmount:
controller.abort()
3. Ein eigenes useFetch-Composable bauen
Ein minimales, abbrechbares useFetch-Composable hält neben data, error und loading auch eine Referenz auf den aktuell aktiven AbortController. Die eigentliche execute-Funktion erzeugt bei jedem Aufruf zunächst einen neuen Controller, übergibt dessen signal an fetch, und speichert den Controller in einer Modul- oder Closure-Variable, damit er später von außen abgebrochen werden kann. Der Vorteil eines eigenen Composables gegenüber einem direkten fetch-Aufruf in der Komponente ist, dass diese gesamte Abbruchlogik an einer Stelle gekapselt bleibt und in jeder Komponente, die Daten lädt, wiederverwendet werden kann.
Wichtig ist, dass das Composable seinen eigenen internen Zustand konsequent zurücksetzt, wenn ein neuer Request gestartet wird, damit loading während eines laufenden Requests korrekt true bleibt und error von einem vorherigen fehlgeschlagenen Versuch nicht fälschlich weiter angezeigt wird. Diese Detailarbeit fällt in der Praxis leicht unter den Tisch, wenn man Fetch-Logik ad hoc in einzelnen Komponenten dupliziert, während sie in einem zentralen Composable nur einmal richtig geschrieben werden muss.
// composables/useFetch.ts
import { ref, shallowRef } from 'vue'
export function useFetch<T>(url: string) {
const data = shallowRef<T | null>(null)
const error = shallowRef<Error | null>(null)
const loading = ref(false)
let controller: AbortController | null = null
async function execute() {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (err) {
if ((err as Error).name === 'AbortError') return
error.value = err as Error
} finally {
loading.value = false
}
}
function cancel() {
controller?.abort()
}
return { data, error, loading, execute, cancel }
}
4. Automatisches Abbrechen bei erneutem Aufruf
Der entscheidende Satz im Composable oben ist controller?.abort() ganz am Anfang von execute. Wird execute während eines noch laufenden Requests erneut aufgerufen, etwa weil der Nutzer schnell zwischen zwei Suchbegriffen wechselt, bricht diese Zeile den vorherigen Request sofort ab, bevor der neue gestartet wird. Dadurch kann die Antwort des alten Requests niemals mehr die Antwort des neuen überschreiben, selbst wenn der Server aus irgendeinem Grund für die ältere Anfrage länger braucht als für die neuere.
Ohne diesen Mechanismus entsteht eine klassische Race Condition: Request A startet, kurz danach startet Request B, aber Request A antwortet aus welchen Gründen auch immer später als Request B. Ohne Abbruch überschreibt die später ankommende, aber eigentlich veraltete Antwort von Request A den bereits korrekt gesetzten Zustand von Request B, und der Nutzer sieht kurzzeitig oder dauerhaft falsche Daten. Der abort()-Aufruf zu Beginn jedes execute macht dieses Szenario strukturell unmöglich, weil eine abgebrochene Anfrage niemals mehr einen then-Zweig erreicht.
5. Abbrechen beim Unmount über onUnmounted
Damit ein Composable auch beim Verlassen der aufrufenden Komponente sauber aufräumt, registriert es intern einen onUnmounted-Hook, der die cancel-Funktion aufruft. Wichtig dabei ist, dass onUnmounted innerhalb des Composables selbst aufgerufen wird und nicht erst in der Komponente, die das Composable verwendet, denn nur so ist garantiert, dass die Abbruchlogik automatisch mitkommt, ganz ohne dass jede aufrufende Komponente daran denken muss, sie manuell hinzuzufügen.
Für Composables, die auch außerhalb einer Komponenteninstanz laufen können, etwa innerhalb eines Pinia-Stores, ist onScopeDispose die passendere Wahl, weil es an den effectScope statt an eine konkrete Komponente gebunden ist. In den meisten Anwendungsfällen innerhalb von script setup verhalten sich beide praktisch gleich, aber onScopeDispose funktioniert auch dort noch, wo onUnmounted mangels umgebender Komponente eine Warnung ausgeben würde.
6. Fehlerbehandlung: AbortError von echten Fehlern unterscheiden
Ein häufiger Fehler ist, im catch-Block jeden gefangenen Fehler gleich zu behandeln und dem Nutzer eine Fehlermeldung anzuzeigen, selbst wenn der Request nur bewusst abgebrochen wurde. Ein abgebrochener Request ist kein echter Fehlerfall im fachlichen Sinn, sondern ein gewolltes Verhalten des eigenen Codes, und sollte deshalb im catch-Block explizit erkannt und stillschweigend ignoriert werden, indem man die name-Eigenschaft des gefangenen Fehlers auf den Wert AbortError prüft.
Erst nach dieser Prüfung sollte der error-Ref des Composables gesetzt und damit eine UI-Fehlermeldung ausgelöst werden. Ohne diese Unterscheidung sehen Nutzer bei schnellem Tippen in einem Suchfeld ständig kurz aufblitzende Fehlermeldungen für Requests, die technisch gar nicht fehlgeschlagen sind, sondern lediglich vom nächsten, aktuelleren Request verdrängt wurden.
7. Abbrechen bei Änderung reaktiver Parameter
Hängt die URL oder ein Suchparameter von einem reaktiven Wert ab, kombiniert man das Composable typischerweise mit einem watch, der execute bei jeder Änderung erneut aufruft. Weil execute intern bereits den vorherigen Controller abbricht, muss der watch selbst keine zusätzliche Abbruchlogik enthalten, er ruft einfach bei jeder Änderung erneut execute auf, und das Composable sorgt dafür, dass immer nur der jeweils letzte Request tatsächlich zu einer sichtbaren Zustandsänderung führt.
Ein watch mit der Option immediate true führt dabei den ersten Aufruf sofort beim Setup der Komponente aus, während jede weitere Änderung des beobachteten Werts automatisch einen neuen, den vorherigen ersetzenden Request auslöst. Dieses Muster lässt sich noch weiter verfeinern, indem man den watch mit einem kurzen Debounce kombiniert, sodass bei sehr schnellen aufeinanderfolgenden Änderungen, etwa bei jedem Tastendruck in einem Suchfeld, nicht bei jedem einzelnen Zeichen ein neuer Request gestartet wird.
8. Timeout mit AbortController kombinieren
Neben dem manuellen Abbrechen bei Unmount oder erneutem Aufruf lässt sich derselbe AbortController auch für ein Timeout nutzen, ohne dass man dafür eine zweite, parallele Abbruchmechanik einführen müsste. Moderne Browser bieten dafür AbortSignal.timeout(ms) an, das ein fertiges Signal zurückgibt, das nach der angegebenen Zeit automatisch abbricht, oder man kombiniert mehrere Signale über AbortSignal.any() mit dem eigenen, manuell steuerbaren Controller-Signal.
Fehlt AbortSignal.any() im Zielbrowser-Support, lässt sich derselbe Effekt auch mit einem einfachen setTimeout erreichen, das nach Ablauf der Zeit controller.abort() auf demselben Controller aufruft, der auch für manuelle Abbrüche verwendet wird. In beiden Fällen bleibt die Fehlerbehandlung im catch-Block unverändert, weil ein durch Timeout ausgelöster Abbruch denselben AbortError erzeugt wie ein manueller Abbruch, und beide Fälle können bei Bedarf über die reason-Eigenschaft des Signals weiter unterschieden werden.
9. Abbrechbare Composables testen
Beim Testen eines abbrechbaren Composables lohnt es sich, gezielt drei Szenarien abzudecken: einen normal durchlaufenden Request, einen Request, der durch einen zweiten Aufruf während des Laufs abgebrochen wird, und einen Request, dessen Komponente vor der Antwort unmountet wird. Mit einer gemockten fetch-Implementierung, die auf das signal-Argument reagiert und bei einem Abort das Promise mit einem AbortError ablehnt, lassen sich alle drei Fälle deterministisch in Unit-Tests nachstellen, ohne auf echte Netzwerk-Latenz angewiesen zu sein.
Besonders wertvoll ist ein Test, der explizit prüft, dass nach einem erneuten Aufruf von execute während eines laufenden Requests am Ende genau der Zustand des zweiten, nicht des ersten Requests im data-Ref landet. Dieser Test bildet die eigentliche Race-Condition-Vermeidung ab und fängt Regressionsfehler zuverlässig ab, falls jemand die abort()-Zeile am Anfang von execute versehentlich entfernt oder falsch platziert.
| Szenario | Ohne AbortController | Mit AbortController im Composable | Effekt |
|---|---|---|---|
| Komponente wird unmounted | Request läuft im Hintergrund weiter | onUnmounted ruft cancel() auf | Kein Update auf verworfenem Zustand |
| Zweiter Aufruf während erster läuft | Race Condition möglich | Vorheriger Controller wird zuerst abgebrochen | Nur die neueste Antwort zählt |
| Nutzer tippt schnell in Suchfeld | Viele parallele, unnötige Requests | Jeder neue Tastendruck bricht den vorherigen ab | Weniger Netzwerklast, konsistenter Zustand |
| Server antwortet sehr langsam | UI wartet unbegrenzt | AbortSignal.timeout() bricht nach Frist ab | Vorhersehbares Fehlerverhalten |
Mironsoft
Vue-Architektur, Composition API und Nuxt-Performance
Vue-Anwendungen, die mit jedem Feature nicht komplizierter werden?
Wir prüfen bestehende Vue- und Nuxt-Projekte auf unstrukturierte Composables, ungenutzte Reaktivität und aufgeblähte Bundles und bauen daraus eine Architektur, die neue Features aufnimmt, ohne die Codebasis unübersichtlicher zu machen.
Architektur-Review
Composables, State-Management und Komponentenstruktur auf Wartbarkeit prüfen.
Performance-Audit
Reaktivitäts-Overhead, Bundle-Größe und Nuxt-Rendering-Strategie systematisch optimieren.
Nuxt-Integration
SSR/SSG-Setup und API-Anbindung robust und typsicher aufbauen.
10. Zusammenfassung
Abbrechbare Vue-Composables mit AbortController: Das Wichtigste auf einen Blick
Kernproblem
Laufende Requests überleben Unmount und überschreiben neuere Antworten
Lösung
AbortController pro Request, alter Controller wird vor neuem Aufruf abgebrochen
Cleanup
onUnmounted beziehungsweise onScopeDispose ruft cancel() automatisch auf
Fehlerbehandlung
AbortError explizit erkennen und von echten Fehlern trennen