zoe@dev:~$PID 1337
zoe@dev:~$ cat ~/blog/shipping-an-ai-tool-de.mdx

Ein KI-Tool shippen: Alles, was kaputtging

2026-07-20·Zoe Lin·5 min read·#nextjs#vercel#seo#security#postmortem
EN中文日本語한국어EspañolFrançaisDeutschPortuguês

~/tools/paper-revision zu bauen war der einfache Teil. Es dazu zu bringen, sich in Produktion genauso zu verhalten wie auf localhost, hat deutlich länger gedauert — und die meisten Bugs steckten gar nicht im KI-Teil, sondern in der Verkabelung drumherum. Hier ist, was tatsächlich kaputtging.

Der stille 502: SSE-Verbindungen brauchen einen Heartbeat

Lange Reviews mit einem eigenen (BYOK) Anthropic-Key schlugen gelegentlich nach ~100 Sekunden fehl — keine Fehlermeldung, einfach eine tote Verbindung. Vercels Logs zeigten einen sauberen 502, ohne jeden Hinweis auf die eigentliche Ursache.

Das eigentliche Problem: Ein Modell kann mitten in der Generierung für eine Weile still werden (eine lange interne „Denkphase", bevor der erste sichtbare Token erscheint), und wenn der Antwort-Stream zu lange keine Bytes schickt, entscheidet irgendein Zwischenglied zwischen Client und Modell, dass die Verbindung im Leerlauf ist, und kappt sie. Die Lösung war ein Heartbeat — ein unsichtbares Zeichen, das in festen Intervallen in den Stream geschrieben wird, damit der Bytestrom nie abreißt:

providers.ts
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // Zero-Width Space, im gerenderten Markdown unsichtbar
 
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
  controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
  continue
}

Bei der Suche danach kam auch ein noch fieserer Bug zum Vorschein, der sich dahinter versteckte: Der Abort-Handler fing jede Exception namens AbortError ab und behandelte sie als bewussten Abbruch durch den Nutzer — auch solche, die schlicht daher kamen, dass das Netzwerk von selbst gestorben war. Echte Fehler wurden dabei stillschweigend verschluckt. Der Fix bestand darin, zunächst festzuhalten, ob der Nutzer tatsächlich auf „abort" geklickt hatte, bevor man sich auf den Namen des Fehlers verließ.

BYOK, ohne sich selbst ins Knie zu schießen

Der kostenlose Tarif läuft auf einem gehosteten Modell mit Rate-Limit; BYOK-Nutzer zahlen mit ihrem eigenen Key und umgehen das Limit komplett — nur tat die erste Version genau das nicht, und BYOK-Anfragen fraßen sich ebenfalls durchs Free-Tier-Kontingent. Ans Licht kam das durch einen ehrlichen Bugreport („Aber ich habe doch meinen eigenen Key benutzt und wurde trotzdem gedrosselt").

Die andere BYOK-Lektion war eher proaktiv als reaktiv: die Modell-Allowlist auf vernünftige Preisstufen begrenzen, damit eine Anfrage nicht versehentlich beim teuersten verfügbaren Modell landet, und die Output-Token-Zahl deckeln, unabhängig vom Provider. Nichts davon hindert einen Nutzer daran, sein eigenes Geld schlecht auszugeben, aber es verhindert, dass unser Code der Grund dafür ist.

Ein Favicon, das Google Search partout nicht anzeigen wollte

Das Favicon der Seite wurde in jedem Browser-Tab einwandfrei angezeigt, tauchte aber in den Google-Suchergebnissen neben der URL einfach nie auf. Die Ursache war fast schon peinlich indirekt: Next.js' generische Icon-Konvention hängt einen Content-Hash an die URL der Datei an — /icon.png?d72260d... — und dieser Hash ändert sich jedes Mal, wenn sich die Bytes der Datei ändern. Googles eigene Richtlinien sind bei genau diesem Fehlerbild ganz explizit: „Die Favicon-URL muss stabil bleiben — ändere die URL nicht häufig." Jede Bearbeitung des Icons setzte still und leise das Vertrauen zurück, das Google in die alte URL aufgebaut hatte.

Next.js macht für genau einen Dateinamen eine Ausnahme — favicon.ico überspringt den Content-Hash komplett und bekommt eine dauerhaft stabile URL. Dieselbe Grafik in einen minimalen ICO-Container zu packen und auf diesen Dateinamen umzustellen war der gesamte Fix. Es lohnt sich, dabei gleich auch das Domain-Setup zu prüfen: Google behandelt unterschiedliche Hostnamen als komplett getrennte „Sites" im Sinne des Favicons, weshalb sowohl www. als auch die Standarddomain *.vercel.app dauerhafte Redirects auf den einen kanonischen Hostnamen bekamen.

Cloudflare Turnstile rendert nur einmal

Turnstiles impliziter Rendering-Modus scannt die Seite genau einmal nach .cf-turnstile-Elementen, und zwar wenn sein Script zum ersten Mal lädt. Auf einer klassischen Multi-Page-Site fällt das nicht auf, weil jede Navigation das Script frisch neu lädt — aber hier handelt es sich um eine SPA, und der Zustand des Review-Formulars überlebt bewusst die clientseitige Navigation, damit ein laufender Review nicht abgebrochen wird. Navigiert man weg und wieder zurück, ist der Mount-Point des Widgets ein brandneuer DOM-Knoten, den das bereits geladene Script nie erneut scannt. Der Token wird still und leise zu undefined — was, nachdem eine serverseitige Verifizierung für den Sharing-Endpunkt hinzugekommen war, bedeutete, dass jeder Share-Versuch nach einem Weg- und Zurücknavigieren ohne sichtbare Erklärung fehlschlug.

Die Lösung ist Turnstiles explizite Rendering-API: Das Script einmal laden und dann bei jedem Mount selbst turnstile.render() aufrufen sowie bei jedem Unmount turnstile.remove(), statt sich auf den einmaligen automatischen Scan zu verlassen.

Windows-Entwicklung, Linux-Produktion

Zwei verschiedene Bugs, eine gemeinsame Ursache: Entwicklung unter Windows und Deployment auf Vercels Linux-Runtime sind nicht dieselbe Umgebung, und PDF-/Bildbibliotheken sind genau die Stelle, an der diese Kluft sichtbar wird. pdf-parse stürzte in Produktion mit einem DOMMatrix-Fehler ab, der sich lokal nie reproduzieren ließ — der Wechsel zu einer serverless-tauglichen PDF-Bibliothek (unpdf) behob das Problem restlos. Unabhängig davon stürzte die Bildgenerierung von next/og beim Laden von Schriftarten ab, zurückverfolgt auf einen Windows-Dateipfad mit Leerzeichen, an denen die URL-Parsing-Logik der Bibliothek erstickte. Für das lokale npm run dev existierte keiner der beiden Bugs; auf jedem einzelnen Deploy waren beide zu 100 % reproduzierbar.

Was eine Security-Review wirklich aufdeckt

Ein vollständiger Durchgang durch die Codebase förderte eine Handvoll echter, wenn auch unauffälliger Probleme zutage: einen Snapshot-Sharing-Endpunkt ganz ohne Bot-Verifizierung — technisch gesehen konnte damit jeder beliebigen Text auf einer öffentlichen URL veröffentlichen; ein Rate-Limit, das belastet wurde, bevor überhaupt geprüft wurde, ob sich die hochgeladene Datei parsen ließ, sodass ein fehlerhafter Upload das Kontingent eines Nutzers für einen Review verbrannte, der nie stattfinden würde; einen Zip-Handler, der einen Archiveintrag dekomprimierte, bevor er dessen deklarierte Größe prüfte — genau die Form einer Zip-Bomb-Schwachstelle; und einen Share-Button, der beim erneuten Klicken nach einem Weg- und Zurücknavigieren still und leise einen doppelten Snapshot erzeugte, weil der Zustand „haben wir schon einen Link" in einer Komponente lag, die gerade eben verworfen und neu erstellt worden war.

Keines davon war exotisch. Es waren genau die Art von Bug, die nur auftaucht, wenn jemand den Code tatsächlich mit dem Blick darauf liest, was schiefgehen könnte, statt nur zu prüfen, ob der Happy Path funktioniert.

Schlussgedanke

Jeder Bug auf dieser Liste wurde geshippt, funktionierte und wirkte fertig — bis ausgerechnet ein bestimmter Input, eine bestimmte Navigation oder eine bestimmte Hosting-Umgebung das Gegenteil bewies. Langweilige Infrastruktur, sorgfältig geprüft, schlägt clevere Infrastruktur, die nie in der Produktion erprobt wurde. exit 0.

cd ~/blog ←