# Performance und Bedienungsgefühl Dieses Dokument ist verbindlich. Die Anwendung läuft auf einem Homeserver hinter einem Tunnel, also mit spürbarer Grundlatenz. Das Gefühl von Geschwindigkeit entsteht deshalb nicht durch schnelle Server, sondern durch die richtige Reaktion im Client. ## Latenzbudgets | Aktion | Sichtbare Reaktion nach | Fertig nach | |---|---|---| | Klick auf einen Knopf | 100 ms (Zustandswechsel) | egal | | Navigation zu einer Akte | 200 ms (Skelett oder Inhalt) | 1 s | | Chatnachricht senden | 0 ms (optimistisch) | Hintergrund | | Vermerk speichern | 0 ms (optimistisch) | Hintergrund | | Suche tippen | 150 ms (alte Treffer gedimmt) | 600 ms | | Datei-Upload | 0 ms (Vorschau lokal) | Hintergrund mit Balken | | PDF-Export | 200 ms (Fortschritt) | Benachrichtigung | Wenn eine Aktion diese Budgets reißt, ist nicht der Server das Problem, sondern die fehlende Zwischenreaktion. ## Grundhaltung 1. **Nie eine leere Seite mit Spinner.** Jede Route hat eine `loading.tsx` mit einem Skelett, das die spätere Struktur exakt nachbildet (gleiche Höhen, gleiche Spaltenbreiten). Damit springt nichts, wenn die Daten kommen. 2. **Spinner nur für Vorgänge unbekannter Länge über 400 ms.** Vorher gar nichts anzeigen, sonst blitzt es bei schnellen Antworten unangenehm auf. 3. **Server Components sind der Standard.** `'use client'` nur dort, wo wirklich Interaktion stattfindet: Editoren, Chat, Uploads, Filterleisten. 4. **Streamen statt sammeln.** Kopfdaten sofort, Verlauf und Chat in eigenen ``- Grenzen. Der Nutzer liest schon, während der Rest lädt. ## Optimistische Updates React 19 `useOptimistic` plus Server Actions. Die Regel: **Was der Nutzer selbst auslöst und was fast immer klappt, wird sofort angezeigt.** Optimistisch behandeln: | Aktion | Optimistische Darstellung | Bei Fehler | |---|---|---| | Chatnachricht senden | Nachricht erscheint, leicht gedimmt bis bestätigt | Roter Rahmen, „Erneut senden" | | Reaktion setzen | Sofort umschalten | Zurücksetzen, dezenter Hinweis | | Vermerk anlegen | Eintrag oben im Verlauf | Eintrag bleibt, markiert als „nicht gespeichert" | | Eigenen Eintrag in der Karenzzeit ändern | Text sofort ersetzt | Alter Text zurück plus Toast | | Anhang löschen | Verschwindet sofort | Kommt zurück plus Toast | | Wiedervorlage ändern | Neues Datum sofort | Zurücksetzen | | Beobachten an/aus | Sofort umschalten | Zurücksetzen | | Als gelesen markieren | Sofort | Still ignorieren, unkritisch | **Nicht** optimistisch behandeln, weil folgenreich oder nicht umkehrbar: - Ändern eines fremden Eintrags oder eines Eintrags nach Ablauf der Karenzzeit. Hier gehört die Begründung erst abgeschickt und bestätigt, bevor der neue Text steht. - Zurückziehen eines Eintrags - Statuswechsel eines Falls, besonders `CLOSED` - Änderung der Klassifizierung - Änderung des Rollen-Mappings - Freigabe eines Exports - Alles im Adminbereich Diese bekommen stattdessen einen sofortigen Pending-Zustand am Knopf (`useTransition`, Beschriftung wechselt, Knopf bleibt klickbar-aussehend, aber gesperrt) und eine klare Bestätigung danach. ### Rückabwicklung sauber machen ```ts const [optimistic, addOptimistic] = useOptimistic(entries, applyEntry) async function submit(text: string) { const id = uuidv7() addOptimistic({ id, body: text, pending: true }) const res = await createEntry({ id, caseId, body: text }) if (!res.ok) toast.error('Vermerk nicht gespeichert', { action: retry(id) }) } ``` Die clientseitig erzeugte `id` ist zugleich Idempotenzschlüssel. Ein Wiederholungsversuch nach Netzfehler erzeugt keinen zweiten Eintrag. ## Löschen: rückgängig statt nachfragen Für umkehrbare Löschungen (Anhang, Chatnachricht, Verknüpfung) kein Bestätigungsdialog. Stattdessen: Element verschwindet sofort, Toast „Gelöscht · Rückgängig" für 6 Sekunden, erst danach geht der endgültige Aufruf raus. Das ist schneller **und** sicherer als ein Dialog, den ohnehin niemand liest. Bestätigungsdialoge bleiben für: Fall schließen, Fall versiegeln, Rollen-Mapping ändern, Sammelexport. ## Navigation - `` auf allen Falllisten-Zeilen, zusätzlich `router.prefetch` bei `mouseenter` mit 80 ms Verzögerung, damit nicht beim bloßen Überfahren geladen wird - Route-Cache über `revalidateTag('case:')` statt `router.refresh()`, damit nicht die ganze Seite neu berechnet wird - Oben ein dünner Fortschrittsbalken für Routenwechsel, aber **erst nach 200 ms** einblenden. Schnelle Wechsel sollen nicht flackern. - Zurück-Navigation nutzt den Browser-Cache, keine Neuabfrage ## Listen und Verlauf - **Cursorbasierte Paginierung**, kein `OFFSET`. Sortierschlüssel ist `(createdAt, id)`. Stabil auch dann, wenn währenddessen neue Einträge dazukommen. - Beim Nachladen bleiben die alten Zeilen stehen (`keepPreviousData`), keine leere Tabelle. - Verlauf und Chat ab 200 Einträgen virtualisiert (`@tanstack/react-virtual`). - Chat lädt initial die letzten 50 Nachrichten und beim Hochscrollen weitere 50, mit Erhalt der Scrollposition (Höhe vor dem Einfügen merken, danach korrigieren). ## Suche - Eingabe mit 250 ms Debounce - Laufende Anfrage bei neuer Eingabe per `AbortController` abbrechen - Während der Suche die alten Treffer auf 50 % Deckkraft setzen statt sie zu entfernen - Erste Filterung lokal auf bereits geladenen Fällen, damit bei kurzen Listen gar keine Anfrage nötig ist - Ergebnisse werden pro Suchbegriff kurz gecacht, damit Zurücknavigieren sofort geht ## Uploads 1. Datei wird sofort im Verlauf gezeigt, Vorschau aus lokalem `URL.createObjectURL` 2. Fortschrittsbalken aus `XMLHttpRequest.upload.onprogress` (fetch kann das nicht) 3. Nach dem Upload Status „Wird geprüft" mit dezenter Animation 4. `attachment.scanned` über SSE tauscht den Zustand auf „Geprüft" oder „Blockiert" 5. Mehrere Dateien laufen parallel, aber höchstens drei gleichzeitig 6. Bilder werden vor dem Upload clientseitig auf max. 2560 px verkleinert, wenn sie größer sind ## Bilder - `next/image` mit festen Dimensionen, damit nichts springt - Platzhalter aus dem gespeicherten Blurhash, nicht grau - Vorschaubilder serverseitig erzeugt, das Original wird nie in einer Liste geladen - Anhänge in der Akte laden erst beim Scrollen (`loading="lazy"`) ## Datenbank - Indizes wie in `docs/04-datenmodell.md`, besonders `(caseId, createdAt DESC)` - Kein N+1: Beteiligte, Autoren und Anhänge immer per `include` mitladen - Autoren-Daten (Name, Avatar) in einer Map im Speicher halten, TTL 5 Minuten - Verbindungspool über pgbouncer im Transaktionsmodus, Prisma mit `pgbouncer=true` - Das Rollen-Mapping wird prozesslokal gecacht und nur bei `pg_notify` verworfen - **Der Audit-Eintrag darf die Antwort nicht blockieren.** Schreiben in eine Puffertabelle und alle 2 Sekunden im Bündel wegschreiben, aber innerhalb derselben Transaktion für schreibende Aktionen, damit nichts verloren geht. ## Auslieferung - Brotli und HTTP/2 kommen von Cloudflare, im Stack ist dafür nichts zu tun - Schriften selbst gehostet, als `woff2` untergesetzt, `font-display: swap` - Statische Assets mit Hash im Namen und `immutable`-Cache - Keine Komponentenbibliothek im Bundle, das spart mehr als jede Optimierung danach - Bundle-Budget: erste Route unter 120 kB JavaScript nach Kompression. `pnpm analyze` gehört in die CI. ## Wahrnehmung schlägt Messung Ein paar Kleinigkeiten mit unverhältnismäßig großer Wirkung: - Der Fokus springt nach dem Absenden eines Vermerks sofort zurück ins leere Eingabefeld - Enter sendet, Shift+Enter macht einen Zeilenumbruch, überall gleich - Strg+K öffnet die Kommandopalette: Aktenzeichen eintippen und direkt hinspringen - Beim Öffnen einer Akte steht der Fokus auf dem Inhalt, nicht auf der Navigation - Entwürfe werden alle 2 Sekunden lokal gespeichert (`localStorage` pro Feld), damit ein versehentlicher Tab-Wechsel keinen Text kostet - Beim Verlassen mit ungespeicherten Änderungen eine Warnung, aber nur bei echten Änderungen - Offline erkannt: Eingaben werden gepuffert, Kopfzeile zeigt „Keine Verbindung, wird nachgesendet" ## Messen `web-vitals` an einen internen Endpunkt, angezeigt im Adminbereich. Zielwerte: LCP unter 1,5 s, INP unter 200 ms, CLS unter 0,05. Dazu die Serverzeiten der fünf häufigsten Abfragen aus `pg_stat_statements`.