Auf den Server zu warten, bevor sich der Bildschirm ändert, ist ehrlich, aber langsam: Ein Like, das nach 400 ms erscheint, fühlt sich kaputt an. Ein optimistisches Update ändert die Oberfläche sofort, geht davon aus, dass die Anfrage gelingt, und nimmt die Änderung zurück, wenn nicht.
Die Idee passt in einen Satz. An den Details gehen Apps kaputt: was genau du beim Fehlschlag wiederherstellst, was passiert, wenn zwei Antworten in falscher Reihenfolge ankommen, und wie der Server verhindert, dass er dieselbe Aktion beim Retry zweimal ausführt.
Das Muster: merken, anwenden, bestätigen oder zurücknehmen
Drei Schritte. Den aktuellen Wert merken, ihn lokal ändern, die Anfrage senden. Bei Erfolg ersetzt die Antwort des Servers deine Vermutung — sie kann abweichen (eine ID, ein Zeitstempel, bereinigter Text). Bei einem Fehler stellst du den gemerkten Wert wieder her und sagst es dem Nutzer: Ein Wert, der kommentarlos zurückspringt, sieht aus wie ein Bug.
import { Service, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TodoApi, type Todo } from './todo-api';
@Service()
export class TodoStore {
private readonly api = inject(TodoApi);
readonly todos = signal<readonly Todo[]>([]);
readonly error = signal<string | null>(null);
async toggle(id: string): Promise<void> {
const before = this.todos().find((t) => t.id === id);
if (!before) return;
this.patch(id, { done: !before.done }); // 1. der Bildschirm ändert sich JETZT
try {
const saved = await firstValueFrom(this.api.update(id, { done: !before.done }));
this.patch(id, saved); // 2. die Version des Servers gewinnt (Zeitstempel, normalisierter Text...)
} catch {
this.patch(id, { done: before.done }); // 3. NUR dieses Element zurücknehmen...
this.error.set(`„${before.title}“ konnte nicht gespeichert werden. Rückgängig gemacht.`); // ...und es sagen
}
}
private patch(id: string, change: Partial<Todo>): void {
this.todos.update((list) => list.map((t) => (t.id === id ? { ...t, ...change } : t)));
}
}Die Oberfläche
Auf dem Server gespeichert
- Release Notes schreiben
- Den Pull Request reviewen
- Abhängigkeiten aktualisieren
- Den wackligen Test reparieren
- Wirkung sichtbar nach
- —
- Anfragen
- 0
- fehlgeschlagen
- 0
- Oberfläche ≠ Server
- 0
Nimm zurück, was du geändert hast, nicht die ganze Liste
Einen Snapshot der ganzen Liste zu sichern ist die verlockendste Variante — und falsch, sobald zwei Anfragen unterwegs sind. Der Snapshot der ersten Anfrage enthält die zweite Änderung nicht; scheitert die erste, löscht das Wiederherstellen auch eine Änderung, die der Server bereits gespeichert hat. Das Szenario in der Demo macht genau das: Oberfläche und Server sind sich am Ende uneinig, und nichts bringt sie wieder zusammen. Nimm pro Element zurück oder wende die Gegenoperation an.
// ✗ Ein Snapshot der GANZEN Liste erfasst auch die anderen Änderungen, die noch unterwegs sind
async toggle(id: string) {
const snapshot = this.todos(); // aufgenommen vor dem nächsten Klick des Nutzers...
this.patch(id, { done: !this.find(id).done });
try {
await firstValueFrom(this.api.update(id, ...));
} catch {
this.todos.set(snapshot); // ...also wird auch eine Änderung zurückgenommen, die der Server BEREITS gespeichert hat
}
}Optimistisches Anlegen braucht noch einen Schritt. Das neue Element hat noch keine echte ID, also bekommt es eine temporäre, die du ersetzt, wenn der Server antwortet. Bis dahin müssen Änderungen an diesem Element warten oder die temporäre ID verwenden, die der Server nicht kennt.
async add(title: string): Promise<void> {
const temp: Todo = { id: `tmp-${crypto.randomUUID()}`, title, done: false };
this.todos.update((list) => [...list, temp]);
try {
const saved = await firstValueFrom(this.api.create({ title }));
// die temporäre ID verschwindet: jede spätere Bearbeitung nutzt die echte
this.todos.update((list) => list.map((t) => (t.id === temp.id ? saved : t)));
} catch {
this.todos.update((list) => list.filter((t) => t.id !== temp.id));
this.error.set(`„${title}“ wurde nicht hinzugefügt.`);
}
}Antworten in der falschen Reihenfolge
Schalte eine Einstellung schnell ein, aus und wieder ein. Drei Anfragen gehen raus, und nichts garantiert die Reihenfolge, in der die Antworten zurückkommen. Wenn jede Antwort den Zustand überschreibt, gewinnt die zuletzt angekommene Antwort — nicht die letzte Wahl des Nutzers.
Jeder Klick sendet eine Anfrage. Ihr Balken endet, wenn die Antwort ankommt.
- Deine Wahl
- off
- Letzte Antwort gewinnt
- off …
- Nur die neueste Anfrage
- off …
„Letzte Antwort gewinnt“ lag 0-mal von 0 falsch.
Zwei Lösungen für zwei verschiedene Probleme. Nur die neueste: Jede Anfrage bekommt eine Nummer, und nur die Antwort auf die neueste darf schreiben — das repariert die Oberfläche. Aber auch der Server kann die Anfragen vertauscht erhalten haben. Wenn der Endzustand auf dem Server zählt, serialisiere die Schreibvorgänge mit concatMap (eine Anfrage nach der anderen, in Klickreihenfolge) oder sende eine Version, die der Server prüft. Bei Suchen, wo nur die letzte zählt, bricht switchMap die alten ab; siehe Higher-Order-Operatoren.
private latest = 0;
readonly enabled = signal(false); // was der Nutzer sieht (optimistisch)
private readonly confirmed = signal(false); // was der Server zuletzt bestätigt hat
async setNotifications(on: boolean): Promise<void> {
const id = ++this.latest;
this.enabled.set(on);
try {
const saved = await firstValueFrom(this.api.saveSettings({ notifications: on }));
this.confirmed.set(saved.notifications);
// eine ältere Antwort, die spät ankommt, darf eine neuere Wahl nicht überschreiben
if (id === this.latest) this.enabled.set(saved.notifications);
} catch {
if (id === this.latest) this.enabled.set(this.confirmed());
}
}Idempotenz: eine Aktion, genau einmal angewendet
Optimistische Oberflächen wiederholen Anfragen, und hier liegt die Falle: Die Anfrage kam an, die Bestellung wurde angelegt, nur die Antwort ging verloren. Ein Retry legt eine zweite Bestellung an. Die Lösung ist ein Idempotency-Key: eine UUID, einmal pro Nutzeraktion erzeugt und mit jedem Retry mitgeschickt. Der Server merkt sich verarbeitete Schlüssel und liefert bei einer Wiederholung das ursprüngliche Ergebnis, statt die Arbeit erneut zu tun.
import { HttpClient, HttpContext, HttpContextToken, HttpInterceptorFn } from '@angular/common/http';
import { retry } from 'rxjs';
export const IDEMPOTENCY_KEY = new HttpContextToken<string | null>(() => null);
/** Der Schlüssel reist im Kontext der Anfrage mit und wird zum Header. */
export const idempotencyInterceptor: HttpInterceptorFn = (req, next) => {
const key = req.context.get(IDEMPOTENCY_KEY);
return next(key ? req.clone({ setHeaders: { 'Idempotency-Key': key } }) : req);
};
// Ein Schlüssel pro NUTZERAKTION, wiederverwendet bei jedem Retry dieser Aktion.
placeOrder(order: Order) {
const key = crypto.randomUUID();
return this.http
.post<OrderConfirmation>('/api/orders', order, {
context: new HttpContext().set(IDEMPOTENCY_KEY, key),
})
.pipe(retry({ count: 3, delay: 1000 })); // gleiches Observable → gleicher Kontext → gleicher Schlüssel
}Der Schlüssel gehört zur Aktion des Nutzers, nicht zur HTTP-Anfrage: Erzeuge ihn, wenn der Nutzer den Button drückt, nicht in einem Interceptor, der bei jedem Retry einen neuen machen würde. Retries selbst behandelt HttpClient und Interceptoren.
Wann ja, wann nein
- Häufige, billige Aktionen, die fast immer gelingen: Likes, Häkchen, als gelesen markieren.
- Änderungen, die sich leicht zurücknehmen lassen: Umsortieren, Umbenennen, die Menge im Warenkorb.
- Oberflächen, in denen man die Latenz bei jedem Klick spürt: Listen, Boards, Editoren.
- Zahlungen und alles mit Geld: Der Nutzer braucht eine echte Bestätigung.
- Unumkehrbare Aktionen: eine E-Mail senden, endgültig löschen, veröffentlichen.
- Validierung, die auf dem Server oft scheitert (ein vergebener Benutzername): Du würdest ständig zurücknehmen.
Häufige Stolperfallen
- Stilles Rollback. Das Häkchen verschwindet ohne Meldung; der Nutzer hält die App für kaputt oder merkt es gar nicht.
- Ein Snapshot der ganzen Liste. Er nimmt auch andere laufende Änderungen zurück, darunter solche, die der Server schon gespeichert hat.
- Die letzte Antwort gewinnt. Eine alte, spät ankommende Antwort überschreibt die neueste Wahl. Nummeriere die Anfragen.
- Retry ohne Idempotenzschlüssel. Aus einer verlorenen Antwort wird eine doppelte Bestellung.
- Nie mit dem Server abgleichen. Temporäre IDs, die nie ersetzt werden, normalisierter Text, der nie zurückkommt: Die Oberfläche driftet langsam von den Daten weg.
Spickzettel
optimistisches Update Snapshot → sofort anwenden → senden → Serverversion | Rollback + Meldung
Rollback nur, was diese Anfrage geändert hat (das Element / die Gegenoperation)
nicht die ganze Liste ein Listen-Snapshot nimmt auch andere laufende Anfragen zurück
Antwort des Servers ersetzt die lokale Vermutung (IDs, Zeitstempel, normalisierter Text)
temporäre IDs tmp-uuid, bis „create“ antwortet, dann durch die echte ID ersetzen
vertauschte Antworten ID pro Anfrage: nur die neueste darf schreiben (newest-only)
Schreiben in Reihe concatMap: eine nach der anderen, in der Reihenfolge der Klicks
Lesen switchMap: alte Suche abbrechen, neue behalten
Retries Idempotency-Key = eine UUID pro Nutzeraktion, bei jedem Retry gleich
nie optimistisch Zahlungen, unumkehrbare Aktionen, Validierung, die oft scheitert
Rollback ohne Meldung = eine UI, die kaputt wirkt