P

Realtime-React auf Champions-League-Niveau: Kafka, Socket.io und Zustand

Realtime React
Event-driven architecture
Kafka
Socket.io
Zustand
React
Next.js
·44 Min. Lesezeit
Prasath Soosaithasan
von Prasath Soosaithasan
Realtime-React auf Champions-League-Niveau: Kafka, Socket.io und Zustand

Vor zehn Jahren sass ich in einem Saal in Paris, auf der ReactEurope, und schaute dem Engineering-Team von Facebook dabei zu, wie es GraphQL Subscriptions vorstellte. Es war das erste Mal, dass ich Realtime-Features in einer React-Anwendung laufen sah, und ich erinnere mich, dass ich nach diesem Vortrag aufrichtig begeistert hinausging — die Demo war ein Chat, der sich über zwei Browser-Fenster hinweg ohne Refresh aktualisierte, und der Vertrag fühlte sich sauber an: Man schrieb eine Subscription, wie man eine Query schrieb, der Server hielt die Verbindung offen, der Resolver pushte Updates zurück. Es war ein grossartiges Erlebnis. Und es war, im Rückblick, der Beginn einer bestimmten Form von Realtime-React, die nie ganz zum Industriestandard wurde, den damals alle erwartet hatten.

GraphQL Subscriptions waren ein Wegbereiter. Sie zeigten, was möglich war: Realtime in React, nicht als nachträglich angeklebter Polling-Hack, sondern als Bürger erster Klasse im Schema, der genauso natürlich aus dem Resolver fliesst wie jede Query. Es hat funktioniert. Es hat begeistert. Konferenzen waren voll davon, Tutorials erklärten das Modell von Grund auf, Teams stellten ihre Stacks darauf um. Doch im Produktionsbetrieb konnte es nicht alle Anforderungen erfüllen. Apollo Client auf dem Frontend, Apollo Server (oder eine Handvoll Alternativen) auf dem Backend, ein WebSocket-über-GraphQL-Protokoll-Transport dazwischen, jeder Consumer jedes Events sprach dasselbe Schema, jede Nachrüstung in einen polyglotten Stack — also in ein Backend, das Node, Python und vielleicht Rust nebeneinander einsetzt — teuer genug, um einen Sprint lang darüber zu diskutieren. Das Modell war elegant, wenn Ihr Backend ein Node-Service und Ihr Frontend eine React-App war; die Nähte wurden sichtbar, sobald eines dieser beiden Dinge nicht mehr stimmte. Beim zweiten oder dritten kommerziellen Projekt hatte ich dasselbe Muster gesehen: Teams entschieden sich für GraphQL Subscriptions, bekamen echten Mehrwert und schraubten dann nach und nach Redis Pub/Sub, Kafka oder eine selbstgebaute Socket-Schicht obendrauf, sobald ein Nicht-GraphQL-Consumer Zugriff auf denselben Event-Strom brauchte. Das Protokoll war ein gutes lokales Optimum. Aber sobald das Backend mehr als eine Runtime umfasste, reichte es nicht mehr für die Champions League.

Wer in der Champions League spielt — in Produktion, bei Fintech-Plattformen, im Trading, in der Telemetrie und bei Realtime-Dashboards in der Logistik —, setzt auf den Stack, um den es in diesem Artikel geht. Kafka steht hier als Backend-Komponente konkurrenzlos da; nichts anderes im Open-Source-Ökosystem reicht an es heran, wenn es um Durability, Durchsatz, Multi-Consumer-Fan-Out und Replay geht. Ein dünnes Socket-Gateway sitzt in der Mitte. Eine unopinionated State-Library sitzt vorne. Kein Protokoll-Lock-In. Kein Vendor-Lock-In. Keine Bindung an eine bestimmte Programmiersprache. Von jener Pariser Demo vor zehn Jahren zu den Deployments, auf die ich heute setze — darum geht es in diesem Artikel.

Mein Lieblings-Stack im Diagramm

1┌──────────────────────────────────┐
2│ Next.js App │
3│ Nutzer-Klick ──▶ Server Action │
4└────────────────┬─────────────────┘
5 │ Business-Zeile + Outbox-Zeile
6 │ in einer Postgres-Transaktion
7
8╔══════════════════════════════════╗
9║ [1] POSTGRES-OUTBOX ║
10╚════════════════╤═════════════════╝
11 │ Direct-Publish nach Commit
12 │ (5-s-Poller als Fallback)
13
14╔══════════════════════════════════╗
15║ [2] KAFKA auf STRIMZI ║
16║ ║
17║ fintech.bank.* ║
18║ fintech.journal.* ║
19║ fintech.tax.* ║
20╚════╤═════════════════════════════╝
21
22 ├──▶ Reactor (Node) : Buchungen, Steuer
23 ├──▶ Notifier (Node) : E-Mail, Push, WhatsApp
24 ├──▶ Categoriser (Python) : ML-Kontierung
25
26 ▼ (einer der Consumer)
27╔══════════════════════════════════╗
28║ [3] SOCKET.IO-GATEWAY ║
29║ Kafka-Topic ──▶ Raum ║
30║ pro Entity + Nutzer ║
31╚════════════════╤═════════════════╝
32 │ WebSocket
33
34┌──────────────────────────────────┐
35│ BROWSER │
36│ │
37│ socket.io-client │
38│ │ │
39│ ▼ │
40│ ╔══════════════════════════╗ │
41│ ║ [4] ZUSTAND-STORE ║ │
42│ ╚═════════════╤════════════╝ │
43│ │ │
44│ ▼ │
45│ React-Komponenten │
46└──────────────────────────────────┘

Was Sie hier sehen, lässt sich in vier Schritten beschreiben — einer pro nummerierter Box — und in den nächsten Abschnitten gehen wir jeden davon im Detail durch. Eine Nutzerin klickt im Frontend; eine Next.js-Server-Action wendet die Business-Logik an und schreibt das Ergebnis zusammen mit einem Outbox-Eintrag in derselben Postgres-Transaktion (①). Direkt nach dem Commit published die Action die frisch geschriebene Outbox-Zeile nach Kafka (②) — im Happy Path wenige Millisekunden, mit einem 5-s-Poller als Sicherheitsnetz, falls dieser Direct-Publish fehlschlägt; von dort aus reagieren beliebig viele Consumer in beliebig vielen Sprachen — Node, Python, in Zukunft auch Rust — parallel auf dieselben Events. Einer dieser Consumer ist ein Socket.io-Gateway (③), das die für die jeweilige Nutzerin relevanten Events über WebSocket an den Browser durchreicht. Im Browser landet jedes Event in einem Zustand-Store (④), der die React-Komponenten transparent aktualisiert.

Jede Box ist isoliert austauschbar. Jeder Pfeil hat einen Vertrag. So sieht der Stack aus, den ich heute betreibe — und so wird er von einem Fintech-Kunden von mir produktiv für ein Finanz-Dashboard genutzt, auf dem jede einzelne Zelle Geld bedeutet.

Eine Bemerkung zum Wort Realtime vorab. Es wird in zwei verschiedenen Bedeutungen verwendet. Es gibt die Marketing-Definition — "Ihre Aktualisierung kommt in einigen Sekunden an, meistens" — und es gibt die Engineering-Definition — "Ihre Aktualisierung kommt zuverlässig an, in der richtigen Reihenfolge, mit Audit-Trail, und ohne Producer und Consumer aneinander zu koppeln." Die erste ist einfach. Die zweite ist der gesamte Inhalt dieses Artikels. Der Stack, den ich gleich beschreibe, ist derjenige, den Sie bauen, wenn die erste Definition nicht mehr reicht — wenn es sich um Finanzströme handelt, um eine Steuerposition oder den laufenden Abstand zu Ihrer Abgabefrist, und ein veralteter oder falsch sortierter Wert kein UX-Problem mehr ist, sondern ein finanzielles.

Das Fintech, das ich als laufendes Beispiel nehme, ist im Bereich Finanzen für Kapitalgesellschaften unterwegs: doppelte Buchführung, automatisierte Kontierung von Bankbewegungen, quartalsweise Umsatzsteuer-Voranmeldung (UStVA), Gewerbesteuer-Erklärung, jährliche Körperschaftsteuer-Erklärung — das ganze Programm. Die Produktpositionierung ist das, was der deutsche Markt einen "CFO in der Tasche" nennt: ein einziger Bildschirm, der Ihnen live zeigt, wie Ihre Liquidität steht, wie hoch Ihre laufende Steuerschuld bereits ist, wie Ihre GuV im Quartal aussieht, welche Abgaben wann fällig sind, und was Ihre Steuerberaterin gerade in einer Sitzung angepasst hat, an der Sie nicht teilgenommen haben.

Ein Wort zur Multi-Tenancy vorab, weil sie weiter unten im Code auftaucht. Eine eingeloggte Nutzerin verwaltet in diesem Produkt typischerweise mehrere Kapitalgesellschaften nebeneinander — die eigene GmbH, eventuell eine Holding, vielleicht eine UG für ein Side-Projekt, oder als Steuerberaterin die Mandantengesellschaften ihrer Klientel. Im Produkt heisst eine solche Gesellschaft eine Entity. Eine Nutzerin kann zwischen den Entities wechseln, auf die sie Zugriff hat, und entityId ist damit der Tenant-Scope, der durch das ganze System läuft: in den Event-Payloads, in den Outbox-Zeilen, in den Socket.io-Räumen, im Zustand-Store. Den Begriff Fintech verwende ich austauschbar mit "der Anwendung selbst" — davon gibt es nur eine.

Die Nutzer sind Geschäftsführer, Buchhalter, Steuerberater; die Entities, die sie verwalten, sind GmbHs und AGs. Für diesen Artikel zählt nicht das Produkt selbst, sondern die Anforderungen, die es an seinen Stack stellt: Was auf dem Bildschirm steht, muss in jedem Moment stimmen, jede Zahl eine finanzielle Aussage, jede Verzögerung potenziell eine Fehlentscheidung, jeder Zustandsübergang Teil eines Audit-Trails. Ein Stack, der das unter Last leistet, ist genau der Stack, um den es hier geht.

Ich arbeite die vier nummerierten Boxen von oben nach unten ab. Jede hat eine konkrete Aufgabe, jede hat einen Vertrag mit ihren Nachbarn, und die Wahl der Technologie in jeder Box ist begründet — nicht "wir haben Kafka genommen, weil das professionell klingt", sondern "wir haben Kafka genommen, weil wir ein durables Log mit mehreren Consumern brauchten, die einfacheren Alternativen an klar benennbare Grenzen stoßen, und wir wissen genau, wo diese Grenzen liegen." Ich zeige Ihnen meine konkrete Vorgehensweise — Drizzle-Schemata, Strimzi-YAML, kafkajs-Handler, ein Socket.io-Server-Skelett, einen Zustand-Slice — und erkläre, warum sie jeweils so aussehen, wie sie aussehen.

Wo der einfache Stack zerbricht

Das Fintech, das ich beschreibe, ist dort gestartet, wo jedes moderne Dashboard startet: Supabase Realtime vor einer Postgres-Datenbank, ein paar useEffect-Hooks, die sich auf Row-Level-Changes abonnieren, eine optimistische UI obendrauf, ausgeliefert. Das Setup war elegant. Sechs Zeilen Code im Client abonnierten die Tabelle journal_entries, die Werte aktualisierten sich, sobald Postgres-WAL-Einträge durch die Logical-Replication-Pipeline von Supabase flossen, das Dashboard fühlte sich lebendig an, das Produkt funktionierte. In den ersten sechs Monaten war es eindeutig die richtige Wahl — das Team erreichte Product-Market-Fit auf einer Architektur, deren Aufsetzen einen halben Sprint dauerte. Ich will hier klar sein: An diesem Stack ist nichts falsch. Er ist die richtige Antwort für sehr viele Produkte, und er ist die falsche Antwort für eine kleine, aber wichtige Teilmenge davon. In diesem Artikel geht es darum zu verstehen, auf welcher Seite dieser Linie Ihr Produkt steht.

Für dieses Fintech landeten drei Druckpunkte im selben Quartal und schoben sie über die Linie.

Der erste: Das Produkt bekam einen zweiten Consumer. Dieselbe Tabelle bank_transactions, auf die das Dashboard abonniert war, sollte auch einen Machine-Learning-Kontierer füttern — einen Python-Prozess, der jede importierte Bankbewegung durch einen trainierten Klassifizierer schickte und ein Konto-Vorschlag emittierte (Umsatz, Material, Reise, Honorar usw.), den die Nutzerin mit einem Klick bestätigen konnte. Supabase Realtime fächert prinzipiell von Postgres an beliebig viele Subscriber aus, aber der Kontierer war kein Browser. Er war ein langlaufender Python-Service in einem Kubernetes-Pod, der at-least-once-Zustellung brauchte, restart-fest, mit der Möglichkeit, ab einem bekannten Offset zu replayen, wann immer das Team das Modell nachtrainierte oder einen Bug in der Kontierungs-Logik fixte. Die Listening-Semantik von Supabase Realtime ist für Browser pragmatisch — Best-Effort, keine Garantien auf Events, die verloren gehen, während ein Client offline ist — und genau diese Pragmatik macht sie für einen Backend-Consumer ungeeignet, bei dem eine verpasste Transaktion eine falsch kontierte Buchung auf dem Buchungssatz der Nutzerin bedeutet, was eine falsche GuV-Zahl bedeutet, was am Ende der Kette eine falsche Steuererklärung bedeutet. Die Compliance verhandelt nicht mit "Best-Effort."

Der zweite: Das Datenvolumen überschritt eine Schwelle. Ein ruhiger Vormittag erzeugte ein paar hundert Row-Level-Changes pro Sekunde über die Tabellen, auf die das Dashboard abonniert war — ein Bank-Feed-Sync brachte tausend Transaktionen in einem Batch herein, jede einzelne kaskadierte durch Buchungssätze, Steuerschuld-Neuberechnung, GuV-Aggregation. Supabase Realtime hielt nach den Messungen des Teams ein paar tausend Events pro Sekunde pro Replication Slot, bevor er sichtbar zu hinken begann — und das Hinken war von jener Sorte, die eine Finanz-UI nicht haben darf. Eine Steuerschuld-Zahl, die acht Sekunden zu spät im Browser ankommt, nachdem zwanzig neuere Aktualisierungen bereits dahinter in der Warteschlange stehen, ist schlimmer als gar keine Zahl. Sie sehen nicht verspätete Daten. Sie sehen eine andere Zahl als die, die wahr ist, und Sie treffen gerade eine Entscheidung auf dieser Grundlage.

Der dritte: Die Compliance stellte eine Frage, die das ganze Modell zerbrach. "Können Sie die exakte Reihenfolge der Zustandsänderungen rekonstruieren, die zu der am 14. eingereichten Voranmeldung geführt haben, in der Reihenfolge, in der die Nutzerin sie auf dem Bildschirm gesehen hat?" Die Antwort mit dem Supabase-Realtime-plus-optimistic-UI-Stack lautete, leicht verlegen, nein. Es gab kein Log davon, was der Browser tatsächlich gerendert hatte. Es gab ein Log davon, was am Ende jeder Sekunde in der Datenbank stand — der finale Zustand jeder Zeile — aber die Events, die zu diesem Zustand geführt hatten, waren nicht als Events erhalten worden. Sie waren nur als ihre letzte Wirkung auf die Zeile erhalten worden. Der Audit-Trail, den die Compliance wollte, existierte nicht und liess sich nachträglich nicht rekonstruieren. Für ein Produkt, dessen ganze Geschäftsgrundlage rechtsverbindliche Steuererklärungen sind, ist das keine Unbequemlichkeit. Es ist ein Existenzproblem.

Drei Druckpunkte, eine Ursache: Die Datenbank wurde sowohl als Source of Truth für den Zustand als auch als Transport für Zustandsänderungen verwendet, und das sind zwei verschiedene Aufgaben. Manchmal kann eine Technologie beides ausreichend gut leisten. Manchmal divergieren die Lasten, bis sie es nicht mehr kann. Wenn das passiert, trennt man die beiden — und die Trennung hat in der Architektur-Literatur einen Namen, das Transactional Outbox Pattern, und einen Transport, ein Event-Log, auf das sich die Literatur vor über einem Jahrzehnt geeinigt hat: ein durables, partitioniertes, replaybares Log. Kafka.

Der Rest dieses Artikels ist ein gemächlicher Spaziergang durch das, was es konkret bedeutet, den einfachen Stack durch dieses durable Rückgrat zu ersetzen — in Code, in Kubernetes-Manifesten, im Netzwerkverkehr und auf dem Bildschirm.

Warum Event-Driven dem klassischen Next.js-Setup überlegen ist

Noch ein Stück Verankerung, bevor wir die erste Box aufmachen, denn die häufigste Alternative zum hier beschriebenen Stack ist nicht Supabase Realtime gegen Kafka. Es ist der weit populärere Default "packen Sie einfach alles in einen Next.js-Route-Handler oder in eine Server-Action." Ein ernsthafter Abschnitt darüber, warum dieser Default in einer Grössenordnung funktioniert und in einer anderen aufhört zu funktionieren, ist das fehlende Bindeglied zwischen "mein Produkt fühlt sich kompliziert an" und "ich sollte mir Event-Driven-Architecture anschauen."

Die klassische Next.js-Form braucht keine Einführung. Eine Nutzerin klickt auf "Bankbewegungen importieren"; eine Server-Action läuft; diese Server-Action ruft den Open-Banking-Provider auf (FinAPI, Tink, GoCardless, Plaid — was auch immer das Team gewählt hat), parst die Antwort, fügt Zeilen in die Datenbank ein, berechnet die neue Steuerschuld, aktualisiert den GuV-Cache, schickt der Nutzerin eine Push-Benachrichtigung, feuert eine E-Mail an die Steuerberaterin ab und kehrt zurück. Eine Funktion. Eine Datei. 'use server' oben. Das mentale Modell ist trivial: Der Call kommt herein, die Arbeit passiert, der Call kehrt zurück. Junior-Entwicklerinnen lesen es an einem Nachmittag. Die gesamte Pipeline ist einen Stacktrace tief. Es gibt keinen Broker, keine Schema-Registry, keine Consumer-Group, kein Offset, keine Dead-Letter-Queue. Der Infrastruktur-Fussabdruck besteht aus dem Next.js-Prozess und der Postgres daneben. Jeder Test ist einen Import entfernt. Für die meisten Produkte, in den meisten Phasen ihres Lebens, ist das korrekt.

Die Probleme dieser Form zeigen sich genau dann, wenn das Produkt in zwei Richtungen gleichzeitig wächst: die Oberfläche dessen, was jede Action tun muss, wächst — und die Grösse des Teams, das diese Actions schreibt, wächst. Diese beiden Wachstümer sind nicht dasselbe Wachstum, und sie zerren die Codebasis in unterschiedliche Richtungen auseinander.

Die erste Achse ist funktionale Kopplung. Die Action "Bankbewegungen importieren" begann als eine Sache — Transaktionen holen, abspeichern. Sechs Monate später tut sie sieben Dinge. Jedes dieser sieben gehört, konzeptionell, einem anderen Team: Das Integrations-Team besitzt den Open-Banking-Call; das Buchhaltungs-Team besitzt die Generierung der Buchungssätze; das Steuer-Team besitzt die Neuberechnung der Steuerschuld; das Reporting-Team besitzt den GuV-Cache; das Notifications-Team besitzt Push und E-Mail; das ML-Team besitzt die Kontierung; das Audit-Team besitzt die unveränderbare Quittung, die die Compliance verlangt hat. Sie alle leben in derselben Server-Action, weil dort der Klick der Nutzerin landet, und jedes Mal, wenn eines dieser sieben Teams etwas ausliefert, müssen die anderen sechs auf Brüche reviewed werden. Jede Änderung ist eine teamübergreifende Änderung. Die PR-Queue wächst. Die Deploy-Frequenz fällt. Das Team, das einen Tippfehler in einer Notification-Vorlage korrigiert, muss auf das Team warten, das mitten in einer Open-Banking-Migration steckt. Nichts davon ist ein Next.js-Problem — in jedem monolithischen Web-Framework wäre es dasselbe. Es ist ein Problem der synchronen Kopplung, die sich als Code-Kolokation tarnt.

Die zweite Achse ist operatives Risiko. Solange die sieben Dinge in einer Server-Action sitzen, teilen sie sich eine einzige Fehler-Oberfläche. Der Open-Banking-Provider hat einen schlechten Nachmittag, seine Antwortzeit steigt von zweihundert Millisekunden auf acht Sekunden? Die Action dauert acht Sekunden. Die Buchungssatz-Inserts blocken darauf. Das Quota des Push-Anbieters wird kurz überschritten? Die ganze Action schlägt fehl, die geholten Transaktionen fallen auf den Boden, die Nutzerin klickt die Schaltfläche noch einmal und bekommt Duplikate. Das ML-Modell des Kontierers läuft in einem Edge-Case in einen Out-of-Memory? Die Nutzerin sieht einen 500er-Fehler und schliesst, der Import-Button sei kaputt. Keiner dieser Fälle ist ein grosser Bug. Alle drei werden zu Ausfällen für ein Feature, das nur teilweise hätte betroffen sein sollen, weil die Action die grösste Atomizitäts-Einheit im Design ist. Eine Sache geht schief und reisst alles mit ist der architektonische Preis für eine Funktion macht alles.

Die dritte Achse ist Sprach- und Runtime-Lock-In. Der ML-Kontierer will in Python leben — nicht weil Python in Mode ist, sondern weil das Modellierungs-Ökosystem dort liegt: PyTorch, scikit-learn, der Hugging-Face-Stack, jede Referenz-Implementierung aus jedem Paper, die Bibliotheken, die jede ML-Einstellung dieses Planeten bereits kennt. Wollen Sie einen agentischen Workflow ergänzen, der ein paar LLM-Calls orchestriert, um eine Quartals-Trendanalyse oder einen Erst-Review eines komplexen Buchungsfalls zu fahren? Das gehört auch nach Python — LangChain, LangGraph, das OpenAI Python-SDK, das Anthropic Python-SDK, die Agent-Tool-Ökosysteme sind dort am reichhaltigsten. Brauchen Sie einen performance-kritischen PDF-Generator für monatliche Auszüge, oder einen numerischen Aggregator für Portfolio-Berechnungen, der ein hartes Latenz-Budget einhalten muss? Das gehört wahrscheinlich nach Rust, hinter einen kleinen HTTP-Server, wo die Vorhersagbarkeit und der Durchsatz die kleinen ergonomischen Kosten wert sind. In der klassischen Next.js-Form kann nichts davon irgendwo anders leben als innerhalb des Next.js-Prozesses — denn die einzige Möglichkeit, wie ein anderer Service davon erfährt, dass etwas passiert ist, besteht entweder darin, von der Server-Action synchron aufgerufen zu werden (was ihn Teil der Fehler-Oberfläche der Action macht), oder darin, die Datenbank zu pollen (was verschwenderisch und fragil ist und genau das Problem zurückbringt, das Supabase Realtime gelöst hatte). Sie sind technologisch an die Sprache angekettet, in der zufällig Ihr Web-Framework geschrieben ist. Für eine Anwendung, deren Wettbewerbsvorteil von ML- und agentischen Workflows abhängt — und nahezu jedes ernsthafte 2026er-Fintech sitzt mittlerweile in diesem Lager —, ist diese Kette teuer.

Event-Driven-Architecture löst alle drei Druckpunkte mit einem einzigen Schritt: Die einzige Aufgabe der Server-Action wird zu "tu das Minimum, um festzuhalten, dass etwas passiert ist, und emittiere ein Event, das das aussagt." Die Action schreibt die importierten Transaktionen in die Datenbank und schreibt eine einzige Zeile in eine Outbox-Tabelle — fintech.bank.transactions-imported mit dem Batch der Transaktionen im Payload — und kehrt zurück. Das ist alles. Gesamtarbeit: zwei Datenbank-Inserts in einer Transaktion, ein paar hundert Millisekunden. Die Nutzerin bekommt eine schnelle Antwort, was sie interessiert.

Alles andere wird zu einem unabhängigen Consumer des Events. Der Buchhaltungs-Service konsumiert fintech.bank.transactions-imported und erzeugt die Buchungssätze. Der Kontierer — geschrieben in Python, mit eigener Deploy-Frequenz, vom ML-Team verantwortet, in seinem eigenen Pod skalierend — konsumiert dasselbe Event und emittiert Konto-Vorschläge. Der Notifications-Service konsumiert seine eigene Scheibe und schickt Push und E-Mail. Der Steuer-Service konsumiert downstream-Buchhaltungs-Events und rechnet die laufende Summe neu. Der Audit-Service konsumiert alles und persistiert eine unveränderbare Quittung. Jeder Consumer ist sein eigener Prozess, sein eigenes Repository (oder Verzeichnis), sein eigener Owner, sein eigener Deploy. Ein Bug im Kontierer macht den Import-Button nicht kaputt. Ein Push-Provider-Ausfall reisst das Buchhaltungs-Modul nicht mit. Die PR-Queue ist nicht mehr teamübergreifend. Die Teams sind kein einziger Organismus mehr. Die Runtime ist keine einzige Runtime mehr.

Das ist der entscheidende Schritt. Es ist nicht der getarnte Kafka-Pitch — Kafka ist der Transport, auf den wir uns in ein paar Abschnitten einigen, aber der Schritt selbst ist fundamentaler. Es ist die Beobachtung, dass für eine Anwendung von ernsthafter Grössenordnung der Wert von "tu eine Sache in einer Funktion und kehre zurück" von den Kosten von "jede Änderung ist eine teamübergreifende Änderung und jeder Fehler ist jedermanns Fehler" überholt wird. Der richtige Zeitpunkt für diesen Schritt liegt später als die meisten denken — die klassische Next.js-Form hat echten Wert, und das asynchrone Muster bringt echte Komplexitätskosten mit, die ein kleines Team nicht zahlen sollte, bevor es muss. Aber er liegt auch früher als die meisten fürchten. Sobald Sie drei Teams und sieben Downstream-Effekte tief in Ihrer meistbesuchten Action stecken, zahlen Sie bereits die Kosten der Kopplung, ohne die Vorteile der Entkopplung schon einzustreichen. Der Vier-Boxen-Stack ist das, wonach die zweite Hälfte dieser Reise in produktivem Code tatsächlich aussieht.

Eine Sache an diesem Schritt, die gerne übersehen wird: das Frontend erbt die Vorteile. Wenn das Backend event-driven ist, hört die Aufgabe des Frontends auf zu lauten "sag mir, wann das optimistische Update dieser spezifischen Server-Action als bestätigter Write zurückkommt", und fängt an zu lauten "sag mir, welche Events passiert sind, und lass mich meine Sicht damit abgleichen." Diese zweite Formulierung ist die, die skaliert — auf zehn Nutzer gleichzeitig auf demselben Dashboard, auf eine Steuerberaterin, die parallel mit der Kundin editiert, auf ein drittes Gerät, das eine Benachrichtigung pusht, auf ein zukünftiges Feature, das Sie noch nicht geschrieben haben. Der Zustand-Slice am Ende dieses Artikels ist die konkrete Form dieses Abgleichs, und er funktioniert nur, weil das event-driven Rückgrat existiert.

Box 1 — die Postgres-Outbox

Box 1 ist die Brücke von der synchronen Welt einer Server-Action zur asynchronen Welt eines Kafka-Topics. Es ist eine einzige Postgres-Tabelle, und sie löst eine konkrete Bug-Klasse, in die jedes Team irgendwann hineinläuft, wenn es versucht, gleichzeitig in eine Datenbank und in einen Message-Broker zu schreiben.

Die Bug-Klasse heisst Dual-Write-Problem. Naiv schreibt die Import-Action die neuen Transaktionen in Postgres und published anschliessend ein Event in Kafka. Zwei Writes, zwei Systeme. Denken Sie jetzt jeden möglichen Fehlerpunkt durch: Der Postgres-Write gelingt, aber das Kafka-Publish läuft in einen Timeout — die Datenbank ist weitergezogen, das Event ist verloren, jeder Downstream-Consumer hat ab jetzt falsche Daten. Umgekehrt: Das Kafka-Publish gelingt, aber der Postgres-Commit schlägt fehl — es existiert ein Event, das aussagt, die Transaktionen seien importiert worden, sind es aber nicht, und jeder Downstream-Consumer hat erneut falsche Daten, in einer noch gefährlicheren Richtung. Ergänzen Sie eine Retry-Schleife, und Sie produzieren Duplikate. Ergänzen Sie einen Idempotenz-Schlüssel, und Sie müssen ihn über beide Systeme hinweg koordinieren. Es gibt keinen Weg, einen Write in zwei unabhängige Systeme atomar zu machen, ohne ein verteiltes Transaktions-Protokoll, und verteilte Transaktions-Protokolle sind betrieblich gesehen Gift. Das Dual-Write-Problem ist in der Form, in der es meist gestellt wird, unlösbar.

Die Transactional Outbox löst das Problem auf, indem sie die beiden Writes in einen einzigen kollabiert. Sie schreiben nicht nach Postgres und dann nach Kafka. Sie schreiben zweimal nach Postgres in derselben Transaktion — einmal in die Business-Tabelle (die neuen Transaktionen), einmal in eine Tabelle event_outbox (das Event, das Sie publishen wollten). Eine Transaktion. Ein Commit. Entweder landen beide Writes, oder keiner von beiden. Die Atomizitäts-Garantie kommt von Postgres, dem System, das vier Jahrzehnte Zeit hatte, Atomizität korrekt umzusetzen. Ab hier laufen zwei Wege parallel. Die Server-Action selbst published die frisch geschriebene Outbox-Zeile direkt nach dem Commit nach Kafka und markiert sie als sentAt — der Happy Path, wenige Millisekunden Ende-zu-Ende, von einem naiven Dual-Write nicht zu unterscheiden. Und ein separater Hintergrund-Prozess — der Outbox-Poller — durchsucht die Tabelle alle paar Sekunden und schickt jede Zeile nach, deren Direct-Publish fehlgeschlagen ist, und markiert sie als gesendet, sobald Kafka bestätigt. Im Happy Path ist die Outbox unsichtbar; im Fehlerfall ist sie das Sicherheitsnetz, das das System konsistent hält. Consumer deduplizieren auf einer stabilen Event-ID. Das harte Problem wird zu einem einfachen, zum Preis einer zusätzlichen Tabelle und einer günstigen Poll-Schleife.

Das tatsächliche Drizzle-Schema der Outbox-Tabelle, entnommen einer unserer Anwendungen, die dieses Muster betreiben:

1// features/core/server/db/eventOutbox.ts
2import { sql } from 'drizzle-orm'
3import { index, integer, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
4
5export const eventOutbox = pgTable(
6 'event_outbox',
7 {
8 id: uuid('id').primaryKey().defaultRandom(),
9 topic: text('topic').notNull(),
10 key: text('key').notNull(),
11 payload: jsonb('payload').notNull(),
12 createdAt: timestamp('created_at').notNull().defaultNow(),
13 sentAt: timestamp('sent_at'),
14 attempts: integer('attempts').notNull().default(0),
15 lastError: text('last_error'),
16 },
17 (table) => ({
18 pendingIdx: index('event_outbox_pending_idx').on(table.sentAt, table.createdAt),
19 }),
20)

Acht Spalten und ein Index — jedes davon tragend. Die id ist der kanonische Identifikator des Events, client-seitig beim Insert generiert, und ist das, wogegen Consumer deduplizieren, falls ein Retry zu einem Doppel-Publish führt. Das topic ist das voll qualifizierte Kafka-Topic, auf dem das Event landen soll. Der key ist der Partitions-Schlüssel, den Kafka verwendet, um das Event einer Partition zuzuweisen (und damit einem stabilen Consumer innerhalb einer Consumer-Group). Der payload ist der Event-Body — eine JSONB-Spalte, Schema-on-Read, schnell einzufügen, beliebig in der Form. createdAt ordnet die Drain-Reihenfolge. sentAt ist null, solange die Zeile aussteht, und wird beim erfolgreichen Publish auf den Zeitstempel gesetzt. attempts und lastError sind betriebliche Diagnose-Werkzeuge — sie erlauben dem Poller, zu scheitern, ohne den Batch zu blockieren, und einer Operatorin, sich Wochen später anzusehen, was bei einer bestimmten Zeile schiefgelaufen ist. Der zusammengesetzte Index auf (sentAt, createdAt) ist das, was den WHERE sent_at IS NULL ORDER BY created_at ASC LIMIT 100-Scan des Pollers im Sub-Millisekunden-Bereich hält, selbst wenn die Tabelle Millionen historische Zeilen angesammelt hat.

Die Server-Action bekommt zwei neue Verantwortungen: eine Outbox-Zeile in derselben Transaktion wie die Business-Änderung einfügen und — direkt nach dem Commit — einen Direct-Publish dieser Zeile nach Kafka auslösen. Gekürztes Beispiel:

1// features/banking/server/actions/importBankTransactions/importBankTransactions.ts
2'use server'
3
4import { eq } from 'drizzle-orm'
5import { entityActionClient } from '@/services/action'
6import { kafkaProducer } from '@/services/kafka'
7import { bankTransactions } from '@/drizzle/schema'
8import { eventOutbox } from '@/features/core/server/db/eventOutbox'
9import { ImportBankTransactionsRequest, ImportBankTransactionsResponse } from './schema'
10
11export const importBankTransactions = entityActionClient
12 .metadata({ actionName: 'importBankTransactions' })
13 .inputSchema(ImportBankTransactionsRequest)
14 .outputSchema(ImportBankTransactionsResponse)
15 .action(async ({ parsedInput, ctx: { db, currentEntity, currentUser } }) => {
16 const txns = await openBanking.fetchTransactions(parsedInput.accountId)
17
18 // (1) Postgres-Write — Business-Zeile + Outbox-Zeile in einer Transaktion
19 const outboxRow = await db.transaction(async (tx) => {
20 await tx.insert(bankTransactions).values(
21 txns.map((t) => ({
22 entityId: currentEntity.id,
23 accountId: parsedInput.accountId,
24 externalId: t.id,
25 amount: t.amount,
26 currency: t.currency,
27 bookingDate: t.bookingDate,
28 counterparty: t.counterparty,
29 memo: t.memo,
30 })),
31 )
32
33 const [row] = await tx
34 .insert(eventOutbox)
35 .values({
36 topic: 'fintech.bank.transactions-imported',
37 key: currentEntity.id,
38 payload: {
39 entityId: currentEntity.id,
40 userId: currentUser.id,
41 accountId: parsedInput.accountId,
42 transactionIds: txns.map((t) => t.id),
43 importedAt: new Date().toISOString(),
44 },
45 })
46 .returning()
47
48 return row
49 })
50
51 // (2) Kafka-Publish — Fire-and-Forget; Happy Path wenige Millisekunden,
52 // der 5-s-Poller ist das Sicherheitsnetz, falls es fehlschlägt.
53 void kafkaProducer
54 .send({
55 topic: outboxRow.topic,
56 messages: [
57 { key: outboxRow.key, value: JSON.stringify(outboxRow.payload) },
58 ],
59 })
60 .then(() =>
61 db
62 .update(eventOutbox)
63 .set({ sentAt: new Date() })
64 .where(eq(eventOutbox.id, outboxRow.id)),
65 )
66 .catch(() => {
67 /* Poller retried */
68 })
69
70 return { imported: txns.length }
71 })

Das ist die ganze Action. Sie ruft den Kontierer nicht auf. Sie rechnet die Steuerschuld nicht neu. Sie schickt keine Benachrichtigung. Sie schreibt die Transaktionen, schreibt eine Outbox-Zeile, feuert den Direct-Publish ab, kehrt zurück. Jeder Downstream-Effekt ist ab jetzt ein unabhängiger Consumer, der das Event abonniert hat, das die Outbox-Zeile transportiert — und die Failure-Modes der Action schrumpfen von sieben auf zwei: der Open-Banking-Call ist gescheitert, oder der Datenbank-Commit ist gescheitert. Ein Kafka-Hänger ist kein dritter Failure-Mode der Action, denn der Publish ist Fire-and-Forget mit dem Outbox-Poller als Sicherheitsnetz. Beide echten Failure-Modes sind wiederherstellbar, beide sind auditierbar, keiner kaskadiert.

Beide Writes — die Postgres-Transaktion und der Kafka-Publish — liegen bewusst inline in der Action, nicht hinter einer Helper-Datei versteckt. Eine eigene ESLint-Regel erzwingt das für jede Server-Action im Codebase: schreibt eine Action in eine Drizzle-Business-Tabelle, muss sie auch (a) einen Eintrag in event_outbox in derselben Transaktion erzeugen und (b) direkt nach dem Commit kafkaProducer.send für diese Outbox-Zeile aufrufen. Das Dual-Write-Muster bleibt damit an der Aufrufstelle sichtbar, wo die nächste Entwicklerin, die die Action liest, sofort sieht, was committet und was published wird — und niemand kann eine der beiden Hälften stillschweigend weglassen.

In Produktion wird die Kafka-Hälfte typischerweise in einen kleinen Helper extrahiert — publishOutboxRow(outboxRow) —, der den send-Aufruf, das sentAt-Update und das Fehler-Handling kapselt. Die ESLint-Regel ist mit beiden Varianten zufrieden, solange der Publish-Aufruf in der Action sichtbar bleibt. Hier im Artikel habe ich beides bewusst inline ausgeschrieben, damit beide Seiten des Dual-Writes nebeneinander lesbar sind.

Der Poller ist ein kleiner Cron-Job, der bewusst die langweilige Hälfte der Arbeit übernimmt. Alle fünf Sekunden scannt er die Outbox nach Zeilen, denen noch ein sentAt fehlt — also Zeilen, deren Direct-Publish nach dem Commit nicht durchgekommen ist — published jede davon auf ihr benanntes Topic, setzt bei Erfolg sentAt oder erhöht bei Fehler attempts und speichert lastError. Ein Pruning-Durchlauf im selben Job löscht Zeilen, deren sentAt älter als dreissig Tage ist. Der ganze Worker passt locker in unter hundert Zeilen TypeScript, läuft in einem eigenen cron-Kubernetes-Deployment, und ist das einzige Stück der Pipeline, das unter allen Umständen sowohl Postgres als auch Kafka berührt. Schlagen sowohl Direct-Publish als auch ein paar Poller-Durchläufe fehl, füllt sich die Outbox-Tabelle; die Action funktioniert weiter (denn sie tut nichts anderes, als eine Zeile einzufügen); sobald Kafka wieder läuft, leert sich der Rückstau, Consumer holen auf, und das System ist wieder konsistent. Die Wiederherstellung läuft automatisch. Audit ist eingebaut: Jedes jemals gepublishte Event ist mit seinem vollen Payload in event_outbox festgehalten, indexiert nach Zeit. Die Compliance-Frage — "rekonstruieren Sie die exakte Reihenfolge der Zustandsänderungen" — hat jetzt eine einzeilige SQL-Antwort.

Box 2 — Kafka auf Strimzi

Box 2 ist das durable Event-Log selbst. Kafka, auf Kubernetes bereitgestellt über den Strimzi-Operator. Auf diese Box möchte ich besonders eingehen, denn viele Teams überspringen die Selfhosted-Option in der Annahme, sie sei unmachbar, und enden bei einem gemanagten Broker für 2.000 bis 8.000 Euro im Monat, die nicht hätten ausgegeben werden müssen.

Strimzi ist ein CNCF-Graduated-Projekt, das "einen produktiven Kafka-Cluster betreiben" auf "ein Kubernetes-Manifest schreiben" reduziert. Den Operator installieren Sie einmal per Helm:

1helm repo add strimzi https://strimzi.io/charts/
2helm install strimzi-kafka-operator strimzi/strimzi-kafka-operator \
3 --namespace kafka --create-namespace

Anschliessend beschreiben Sie den gewünschten Cluster als Custom Resource, und der Operator reconciliiert ihn. In Produktion fahren wir KRaft-Modus (ohne ZooKeeper), mit getrennten Node-Pools für Controller und Broker, auf einem kleinen Footprint. Das Cluster-Manifest für das Fintech, leicht gekürzt:

1# kafka-cluster.yaml
2apiVersion: kafka.strimzi.io/v1beta2
3kind: Kafka
4metadata:
5 name: fintech
6 namespace: kafka
7 annotations:
8 strimzi.io/node-pools: enabled
9 strimzi.io/kraft: enabled
10spec:
11 kafka:
12 version: 4.1.0
13 metadataVersion: 4.1-IV0
14 listeners:
15 - name: plain
16 port: 9092
17 type: internal
18 tls: false
19 - name: tls
20 port: 9093
21 type: internal
22 tls: true
23 - name: external
24 port: 9094
25 type: cluster-ip
26 tls: true
27 configuration:
28 bootstrap:
29 host: kafka.fintech.example
30 brokers:
31 - broker: 0
32 host: kafka-0.fintech.example
33 - broker: 1
34 host: kafka-1.fintech.example
35 - broker: 2
36 host: kafka-2.fintech.example
37 config:
38 offsets.topic.replication.factor: 3
39 transaction.state.log.replication.factor: 3
40 transaction.state.log.min.isr: 2
41 default.replication.factor: 3
42 min.insync.replicas: 2
43---
44apiVersion: kafka.strimzi.io/v1beta2
45kind: KafkaNodePool
46metadata:
47 name: brokers
48 namespace: kafka
49 labels:
50 strimzi.io/cluster: fintech
51spec:
52 replicas: 3
53 roles: [broker]
54 storage:
55 type: persistent-claim
56 size: 50Gi
57 class: ebs-gp3
58 deleteClaim: false
59---
60apiVersion: kafka.strimzi.io/v1beta2
61kind: KafkaNodePool
62metadata:
63 name: controllers
64 namespace: kafka
65 labels:
66 strimzi.io/cluster: fintech
67spec:
68 replicas: 3
69 roles: [controller]
70 storage:
71 type: persistent-claim
72 size: 10Gi
73 class: ebs-gp3
74 deleteClaim: false

Das ist ein produktiver Kafka-Cluster. Drei Broker, drei Controller, Replikationsfaktor drei, Mindestanzahl synchroner Replikas zwei — wir tolerieren also den Verlust eines Brokers ohne Durability-Verlust und den Verlust eines weiteren Brokers ohne Availability-Verlust. Storage auf wachsenden EBS-gp3-Volumes. Drei Listener — plain für internen Cluster-Traffic, TLS für Cross-Namespace-Traffic, der ihn will, external für unsere Producer und Consumer, die ausserhalb des Clusters laufen. Der externe Listener wird via Cluster-IP mit TLS-Passthrough durch ein Envoy-Gateway exponiert, was uns Per-Broker-SNI-Routing verschafft, ohne einen Load Balancer pro Broker mieten zu müssen.

Topics dagegen sind bewusst keine Kubernetes-Ressourcen. Business-Domänen-Namen — fintech.bank.transactions-imported, fintech.journal.entry-posted, jedes Event, an dem das Produkt hängt — gehören für uns nicht in Infrastruktur-Manifeste. Der Topic-Name ist Produkt-Vokabular; der Kubernetes-Cluster hat damit nichts zu tun und soll erst recht nicht jedes Mal neu deployt werden müssen, wenn sich das Produkt-Team auf ein neues Event einigt.

Stattdessen lebt der Topic-Katalog im Anwendungs-Repository. Beim Start jedes Consumers ruft der kafkajs-Admin-Client die Topics, die er braucht, idempotent ins Leben — wenn das Topic bereits existiert, ist der Aufruf ein No-op:

1import { kafka } from '@/services/kafka'
2
3const admin = kafka.admin()
4await admin.connect()
5await admin.createTopics({
6 topics: [
7 { topic: 'fintech.bank.transactions-imported', numPartitions: 12, replicationFactor: 3 },
8 { topic: 'fintech.bank.transaction-categorisation-suggested', numPartitions: 12, replicationFactor: 3 },
9 // … ein Eintrag pro Event, das dieser Consumer braucht
10 ],
11})
12await admin.disconnect()

Das Kubernetes-Manifest bleibt rein strukturell — Broker, Controller, Listener, Storage. Der Topic-Katalog bleibt in TypeScript, neben den Producern und Consumern, die ihn tatsächlich publishen und abonnieren. Ein neues Event aufzusetzen wird damit zu einer einzeiligen Ergänzung in der Startup-Datei des Consumers, im selben PR wie der Producer, der es emittiert — kein Infra-Ticket erforderlich.

Der Topic-Name ist der Vertrag. Wir setzen eine einzige Namenskonvention über die gesamte Codebasis hinweg durch — <app>.<feature>.<event-kebab-case> — und zwar zur Lint-Zeit über eine eigene ESLint-Regel (strict/kafka-topic-kebab-case), die bei jedem Topic-String anschlägt, der nicht der Regex entspricht. Die Form ist bewusst gewählt. Das erste Segment scopen wir auf die produzierende Anwendung, sodass zwei Apps, die sich einen Kafka-Cluster teilen, einander niemals versehentlich Events wegkonsumieren. Das zweite Segment benennt das Feature, zu dem das Event gehört, sodass ein Glob wie fintech.bank.* jedes Banking-Event für eine Sammel-Subskription erfasst. Das dritte Segment benennt das Event selbst, in Kebab-Case, im Perfekt — Events beschreiben Dinge, die bereits passiert sind, keine Befehle. Das Perfekt ist eine Kleinigkeit, die eine ganze Klasse konzeptioneller Bugs verhindert: Ein Consumer, der einen Befehl ("bitte importiere diese") mit einem Event ("diese wurden importiert") verwechselt, wird irgendwann einen Retry falsch behandeln.

Die Producer-Seite ist fast nicht der Rede wert. kafkajs ist auf Node der Klient der Wahl, und ein Producer-Call sind drei Zeilen:

1import { kafka } from '@/services/kafka'
2
3const producer = kafka.producer()
4await producer.connect()
5await producer.send({
6 topic: row.topic,
7 messages: [{ key: row.key, value: JSON.stringify({ id: row.id, payload: row.payload }) }],
8})

Die interessante Arbeit passiert auf der Consumer-Seite, und die betrieblichen Eigenschaften des Brokers — Partitionierung, Consumer-Groups, Offset-Management, Replay — sind der eigentliche Sinn der Box. Zwölf Partitionen auf fintech.bank.transactions-imported bedeuten, dass zwölf Consumer-Instanzen einer Gruppe das Topic parallel verarbeiten können; der Partitions-Schlüssel (in unserem Fall die Fintech-ID) garantiert, dass alle Events einer Kundin auf derselben Partition landen und damit in der richtigen Reihenfolge bei derselben Consumer-Instanz ankommen. Replay ist eine Ein-Zeilen-Operation: --reset-offsets --to-earliest auf einer Consumer-Group, und jedes Event seit dem Anfang des Retention-Fensters fliesst nochmals durch den Handler dieses Consumers. Wir haben das verwendet, um neue Consumer nachzubefüllen, um den ML-Kontierer nach einem Re-Training nochmals laufen zu lassen, um einen kaputten GuV-Cache aus den Quell-Events wiederherzustellen, und um die zu Beginn erwähnte Compliance-Anfrage zu beantworten. Jede dieser Operationen ist gegen Supabase Realtime unmöglich, gegen einen gemanagten Broker teuer, und gegen diesen Stack Routine.

Der polyglotte Vorteil

Mit den Boxen 1 und 2 an Ort und Stelle hat das Backend seine Form geändert. Es gibt eine Server-Action, die Events emittiert, und eine Flotte unabhängiger Consumer, die darauf reagieren. Was das ermöglicht — und worum es in der Backend-Hälfte des Artikels nun geht — ist, für jeden Consumer das richtige Werkzeug zu wählen, unabhängig von der Sprache, in der die Anwendung geschrieben ist. Das Fintech, auf das ich immer wieder zurückkomme, betreibt heute drei verschiedene Consumer-Familien, in drei verschiedenen Sprachen, ohne Koordinierung zwischen ihnen ausser dem Topic-Vertrag.

Der Reactor ist der Node.js-Auffangbeschluss. Ein kleiner TypeScript-Service, als eigenes Kubernetes-Deployment bereitgestellt, der breite Topic-Bereiche abonniert — fintech.bank.*, fintech.journal.*, fintech.tax.* — und jedes Event an einen Handler weiterleitet, der bei dem Feature liegt, dem das Event gehört. Ein Handler ist eine asynchrone Funktion, die das geparste Event nimmt und die ihm zugewiesene Reaktion ausführt. Der Reactor erledigt die Brot-und-Butter-Integrationen: das Schreiben der Buchungssätze, wenn ein Bank-Import-Event eintrifft, das Neuberechnen der Steuerschuld, wenn ein Buchungssatz-Event eintrifft, das Markieren einer Voranmeldung als eingereicht, wenn die Antwort der Finanzverwaltung zurückkommt. Er ist der Consumer für die Node-förmige Arbeit — alles, was hauptsächlich Orchestrierung von Datenbank-Writes und SDK-Aufrufen ist. Er teilt seinen Dependency-Graph mit der Next.js-App (dasselbe Drizzle-Schema, dieselben Domänentypen, dieselbe i18n-Pipeline), was den kognitiven Overhead für die Entwicklerinnen, die ohnehin schon im Next.js-Code arbeiten, nahe null hält.

Der Notifier ist ein Geschwister-Node.js-Service mit engerem Auftrag. Er abonniert nur Events, die in einer für die Nutzerin sichtbaren Nachricht resultieren — fintech.tax.filing-submitted, fintech.journal.entry-flagged-for-review, fintech.banking.unusual-transaction-detected — und wandelt jedes davon in die richtige Kombination aus E-Mail, Push und (für den deutschen Markt) WhatsApp-Nachricht um. Der Notifier ist ein eigener Service und kein Reactor-Handler, weil er andere Skalierungs-Eigenschaften und einen anderen Deploy-Takt hat als der Reactor — Provider-Quotas, Rate-Limits, Retry-Strategien, Template-Rendering, das ganze operative Spezifikum von "wir schicken Menschen Nachrichten", das von Isolation profitiert. Er ist weiterhin Node.js und nutzt dasselbe Drizzle-Schema für User-Preference-Lookups, aber er ist sein eigener Pod, seine eigene Consumer-Group, seine eigenen SLOs.

Der Kontierer ist die Stelle, wo es interessant wird. Der Kontierer ist ein Python-FastAPI-Service, vom ML-Team verantwortet, mit einer völlig anderen Deploy-Pipeline als die Node-Seite des Hauses. Er konsumiert fintech.bank.transactions-imported direkt aus Kafka — über aiokafka, den asyncio-nativen Kafka-Klienten —, jagt jede Transaktion durch einen feingetunten Klassifizierer (einen relativ kleinen Transformer, der auf das Vokabular des deutschen SKR03-Kontenrahmens trainiert wurde), und published seine Prognose als fintech.bank.transaction-categorisation-suggested. Der Buchhaltungs-Handler des Reactors greift diese Empfehlung anschliessend auf und wendet sie entweder direkt an (bei Hochkonfidenz-Klassifikationen) oder zeigt sie der Nutzerin zur 1-Klick-Bestätigung (bei allem anderen). Das ML-Team schreibt Python, weil das Modellierungs-Ökosystem dort lebt — sie iterieren das Modell in einem Jupyter-Notebook, validieren es auf einem Hold-Out-Set im selben Notebook, deployen es in den FastAPI-Service, sobald es den Vorgänger schlägt, und der Reactor merkt davon nichts. Es gibt keine Node-⇄-Python-RPC-Schicht. Es gibt kein gemeinsames Schema ausser dem Kafka-Topic-Vertrag. Die beiden Services kennen einander ausschliesslich über die Events, die sie austauschen.

Das Muster verallgemeinert sich. Ein agentischer Workflow — etwa ein Quartals-Trendanalyse-Assistent, der die Buchhaltungs-Events eines Quartals konsumiert und einen geschriebenen Kommentar produziert, den die Nutzerin neben ihrer GuV lesen kann — lebt ebenfalls in Python, ebenfalls als FastAPI-Service, ebenfalls als Kafka-Consumer. Er abonniert fintech.reporting.quarter-closed, lädt die Events des Quartals, fächert einen strukturierten Prompt über das OpenAI- oder Anthropic-SDK aus und published fintech.reporting.quarter-commentary-ready, wenn er fertig ist. Das Python-Ökosystem für diese Arbeit — LangGraph, LangChain, die Agent-Tool-Integrationen, die Strukturierte-Output-Bibliotheken — ist den Node-Äquivalenten Jahre voraus, und der Instinkt des Teams, dorthin zu greifen, ist richtig. Das event-driven Rückgrat ist das, was dem Instinkt günstig macht zu folgen. Es gibt kein Integrations-Ticket. Es gibt kein "lass uns einen internen gRPC-Service hochziehen." Es gibt eine neue Consumer-Group, die ein bereits existierendes Topic abonniert, geschrieben in der Sprache, in der das Team ohnehin produktiv ist, unabhängig vom Rest deployt.

Ein Rust-Service würde sich auf dieselbe Weise einreihen. Das Fintech, das ich beschreibe, hat noch keinen gebraucht, aber an dem Tag, an dem zehntausend PDFs pro Stunde für Jahresabschluss-Auszüge gerendert werden müssen oder ein numerisch intensiver Portfolio-Aggregator innerhalb eines harten Latenz-Budgets laufen muss, lautet die Antwort nicht "wir schreiben den Node-Reactor in Rust um", sondern "wir fügen einen Rust-Consumer hinzu, der die relevanten Topics abonniert und die schwere Arbeit übernimmt." Die Wahl der Sprache ist lokal zum Consumer. Jeder Consumer zahlt nur die Betriebskosten seiner eigenen Runtime. Die Anwendung als Ganzes ist polyglott per Default, nicht per Ausnahme.

Das ist die architektonische Eigenschaft, die diesen Stack von jeder monolithischen Alternative trennt, und es ist — in meiner Erfahrung — der einzige grösste Grund, warum ein ernsthaftes Fintech ihn übernimmt. Es ist nicht die Durability des Logs, es ist nicht der Audit-Trail, es ist nicht einmal die Multi-Consumer-Fan-Out, so attraktiv alle drei sind. Es ist die Freiheit, ein Backend aus der besten Sprache für jede Aufgabe zusammenzustellen und jedes Stück unabhängig weiterzuentwickeln, ohne je die Integrations-Steuer zu zahlen, die polyglotte Architekturen früher impliziert haben. Kafka ist die Lingua Franca, die das Polyglotte bezahlbar macht.

"Aber Kafka ist noch kein Realtime-React"

An diesem Punkt ist die Backend-Hälfte des Systems fertig. Events fliessen durch ein durables Log; Consumer in drei Sprachen reagieren unabhängig; der Audit-Trail ist eingebaut; die Compliance-Frage hat eine Antwort. Und nichts davon, für sich genommen, lässt eine Zahl auf einem Bildschirm in einer React-Anwendung in Realtime aktualisieren. Der Browser spricht kein Kafka — und soll das auch nicht.

Drei Gründe, warum nicht. Erstens: Das Kafka-Wire-Protokoll geht von langlebigen TCP-Verbindungen zu bestimmten Brokern aus, wobei der Klient Metadaten darüber pflegt, welcher Broker welche Partition besitzt. Nichts davon passt zu den tatsächlichen Netzwerkbedingungen des Browsers, wo Verbindungen abreissen, IPs wechseln und die Proxy-Schicht dazwischen kein Konzept von Partitions-Affinität hat. Zweitens: Einen Kafka-Broker im öffentlichen Internet zu exponieren, ist eine Authentifizierungs- und Autorisierungs-Oberfläche, die niemand pflegen will. Kafka hat SASL und ACLs, aber sie auf Per-User-, Per-Kunde-, Per-Tenant-Browser-Authentifizierung abzubilden, ist ein Integrations-Albtraum. Drittens: Das Datenmodell des Brokers ist Topic-und-Partition, nicht User-und-Session. Das Filtern — welche Events diese konkrete Nutzerin empfangen soll und welche nicht — muss irgendwo passieren, und der Broker ist der falsche Ort dafür. Dieses Filtern ist die Aufgabe der nächsten Box.

Box 3 — der Socket.io-Server

Box 3 ist die Brücke vom Kafka-Topic zum Browser. Ein kleiner Node.js-Service, dessen einzige Aufgabe darin besteht, auf der einen Seite aus Kafka zu konsumieren, auf der anderen Seite WebSocket-Verbindungen zu Browsern offenzuhalten und die richtigen Events zu den richtigen Verbindungen zu leiten. Auf der Browser-Seite verwenden wir Socket.io, aus Gründen, auf die ich gleich komme.

Die Architektur des Service ist eine einzige Dualität. Auf der Kafka-Seite ist er ein Consumer wie jeder andere — er tritt einer Consumer-Group bei, abonniert die Topics, die ihn interessieren, und erhält für jede Nachricht einen Event-Handler-Aufruf. Auf der Browser-Seite ist er ein Socket.io-Server — er akzeptiert WebSocket-Verbindungen, authentifiziert sie gegen das Session-Cookie der Anwendung, ordnet jede Verbindung einem oder mehreren Räumen zu, basierend auf Identität und Mandanten-Zugehörigkeit der Nutzerin, und emittiert Events an diese Räume. Die interessante Arbeit ist die Fan-Out-Logik in der Mitte: Wenn ein fintech.tax.liability-recomputed-Event aus Kafka eintrifft, welche Sockets sollen es erhalten? Antwort: jeder Socket im Raum entity.<entityId>.user.<userId>, wobei entityId mit dem Event-Payload übereinstimmt und userId in der Menge der Nutzer liegt, die das Dashboard für diese Entity gerade offen haben. Der Lookup ist billig, weil Socket.io die Raum-Mitgliedschaft im Speicher hält; die Routing-Entscheidung ist ein einzelner Get-by-Room-Name und Emit.

Warum Socket.io konkret und nicht reiner WebSocket? Drei konkrete Gründe. Erstens: Reconnect. Browser verlieren ständig Verbindungen — Laptops schlafen ein, Mobilfunk-Netze wechseln, Proxies timen aus. Socket.io hat ein bewährtes Reconnect-Protokoll mit exponentiellem Backoff, automatischer Wiederzustellung anstehender Nachrichten und dem Konzept eines bestätigten Events. Das selbst auf dem rohen WebSocket-API zu schreiben ist ein Projekt, kein Wochenende. Zweitens: Räume und Namespaces. Das mentale Modell "diese Verbindung gehört zu diesen Räumen, broadcaste an einen Raum und jedes Mitglied empfängt es" ist genau das Modell, das wir für Mandanten-Scoping brauchen, und Socket.io liefert es als Primitiv. Roher WebSocket gibt Ihnen eine flache Verbindung; die Raum-Abstraktion bauen Sie obendrauf. Drittens: Transport-Fallback. In Umgebungen, in denen WebSocket blockiert ist — restriktive Konzern-Proxies, antike Mobilfunk-Netze — fällt Socket.io transparent auf HTTP-Long-Polling zurück. Das Dashboard funktioniert auch für eine Kundin, deren IT-Abteilung WebSocket blockiert. Das ist ein Support-Ticket weniger pro Monat.

Der tatsächliche Server, in Skelett-Form:

1// services/socket-gateway/src/server.ts
2import { Server } from 'socket.io'
3import { Kafka } from 'kafkajs'
4import { verifySessionCookie } from '@/services/auth'
5
6const io = new Server({
7 cors: { origin: process.env.APP_ORIGIN, credentials: true },
8 transports: ['websocket', 'polling'],
9})
10
11const kafka = new Kafka({
12 clientId: 'socket-gateway',
13 brokers: process.env.KAFKA_BOOTSTRAP_SERVERS.split(','),
14})
15const consumer = kafka.consumer({ groupId: 'fintech.socket-gateway' })
16
17// --- Browser-Seite: Authentifizierung und Raum-Subskription ---
18io.use(async (socket, next) => {
19 try {
20 const cookie = socket.handshake.headers.cookie ?? ''
21 const session = await verifySessionCookie(cookie)
22 socket.data.userId = session.userId
23 socket.data.entityIds = session.entityIds
24 next()
25 } catch (err) {
26 next(new Error('unauthenticated'))
27 }
28})
29
30io.on('connection', (socket) => {
31 for (const entityId of socket.data.entityIds) {
32 socket.join(`entity.${entityId}.user.${socket.data.userId}`)
33 socket.join(`entity.${entityId}.broadcast`)
34 }
35
36 socket.on('disconnect', () => {
37 // Socket.io verlässt die Räume automatisch.
38 })
39})
40
41// --- Kafka-Seite: Subskription und Fan-Out ---
42await consumer.connect()
43await consumer.subscribe({
44 topics: [
45 'fintech.tax.liability-recomputed',
46 'fintech.journal.entry-posted',
47 'fintech.banking.transaction-categorised',
48 'fintech.reporting.quarter-commentary-ready',
49 ],
50 fromBeginning: false,
51})
52
53await consumer.run({
54 eachMessage: async ({ topic, message }) => {
55 if (!message.value) return
56 const envelope = JSON.parse(message.value.toString('utf-8'))
57 const { entityId, userId } = envelope.payload
58
59 const room = userId
60 ? `entity.${entityId}.user.${userId}`
61 : `entity.${entityId}.broadcast`
62
63 io.to(room).emit(topic, envelope)
64 },
65})
66
67io.listen(Number(process.env.SOCKET_PORT))

Das ist der ganze Service. Etwa sechzig Zeilen Code, ohne betriebliche Details. Die Auth-Middleware liest das Session-Cookie der Anwendung (das Gateway teilt sich die Cookie-Domain mit der Next.js-App), verifiziert es und pinnt die userId der Verbindung sowie die Menge der entityIds, auf die die Nutzerin Zugriff hat. Beim Verbinden tritt der Socket je einem Raum pro Entity-User-Paar und einem Raum pro Entity für Broadcast-Events bei. Die Kafka-Konsumption ist ein flacher Dispatch: Envelope parsen, korrekten Raum wählen, emittieren. Events, deren Payload eine userId enthält, werden nur an diese Nutzerin geleitet; Events ohne userId gehen an jede Nutzerin mit Zugriff auf diese Entity als Broadcast.

Zwei betriebliche Punkte, die Erwähnung verdienen. Erstens: Das Gateway ist über Instanzen hinweg zustandslos, ausgenommen die Raum-Mitgliedschaft. Betreiben Sie zwei Replicas hinter einem Load Balancer mit Sticky Sessions, oder verwenden Sie den Socket.io-Redis-Adapter, um den Raum-Zustand zwischen Replicas zu teilen. Wir tun letzteres — Redis Pub/Sub kostet praktisch nichts und gibt uns horizontales Skalieren gratis. Eine Nutzerin, die sich auf Instanz A verbindet, empfängt weiterhin Events, die der Kafka-Consumer auf Instanz B aufgegriffen hat, weil der Raum-Emit über das Redis-Fabric broadcastet wird. Zweitens: Das Gateway ist die Security-Grenze. Der Browser kann nicht anfragen, Events für eine beliebige entityId zu empfangen; er bekommt die Räume, für die seine Session autorisiert ist, und nur diese. Die Kafka-Topics selbst sind aus dem öffentlichen Internet nicht erreichbar; sie leben innerhalb des Kubernetes-Clusters und nur der Gateway-Pod hat Netzwerkzugang zum externen Listener der Broker. Sollte das Gateway kompromittiert werden, ist der Wirkungsradius die Events, die durch es fliessen; der Kafka-Cluster selbst bleibt nicht exponiert.

Eine kleine Anmerkung zur Alternative Server-Sent Events. SSE ist einfacher als WebSocket und für einseitigen Push vollkommen ausreichend, was genau das ist, was das Dashboard braucht. Wenn das Team neu startet und die kleinstmögliche Oberfläche will, sind SSE plus EventSource-API vertretbar. Wir haben Socket.io gewählt, weil dieselbe Klient-Verbindung auch kleine Upstream-Nachrichten transportiert — Heartbeats, "ich fokussiere gerade Tab X"-Hinweise, Presence-Pings — und weil die Räume-Primitive bei Grössenordnungen von Zehntausenden gleichzeitigen Verbindungen ein echter Produktivitätsgewinn ist. Beide sind korrekte Antworten; wählen Sie diejenige, deren Feature-Set zu Ihrer Roadmap passt.

Box 4 — Zustand als Sink

Box 4 ist die letzte Box: der Zustand-Store auf dem React-Client, der den Dashboard-Zustand besitzt und die Socket.io-Verbindung abonniert. Hier zahlt sich jede vorherige architektonische Entscheidung in Code aus, den die React-Entwicklerin tatsächlich schreibt.

Ein kurzes Wort dazu, warum Zustand. Der State-Management-Raum in React im Jahr 2026 ist überfüllt — Redux Toolkit, Zustand, Jotai, Valtio, Signals, useReducer-und-Context, TanStack Query für Server-State. Alle sind für unterschiedliche Aufgaben vertretbar. Zustand verdient seinen Platz in diesem Stack aus drei Gründen. Erstens: Die API ist klein genug, dass eine Junior-Entwicklerin einen Slice am selben Nachmittag liest und versteht. Ein Store ist ein Hook, ein Hook ist eine Funktion, die Funktion gibt einen State-Slice zurück, der Slice hat Actions. Zweitens: Der Subscribe-ohne-Render-Escape (store.subscribe()) ist eingebaut, und das ist, was wir für die Socket-Verbindung brauchen — wir wollen, dass der Store auf eingehende Events reagiert, ohne zu rendern, sodass eine Komponente, die sich nicht für ein bestimmtes Event interessiert, nicht neu rendert, wenn es eintrifft. Drittens: Der Store lebt ausserhalb von React. Wir können seine Actions aus einem Nicht-React-Kontext aufrufen (dem Socket-Listener), was die Event-Handling-Logik aus useEffect-Spaghetti heraushält.

Die Form des Dashboard-Slices, gekürzt:

1// features/dashboard/client/store/dashboardStore.ts
2import { create } from 'zustand'
3import { Money } from '@fintech/money'
4import type { JournalEntry, TaxLiabilitySnapshot } from '@/types'
5
6interface DashboardState {
7 entityId: string | null
8 cashBalance: Money
9 taxLiability: TaxLiabilitySnapshot
10 recentEntries: JournalEntry[]
11 lastEventAt: Date | null
12}
13
14interface DashboardActions {
15 setFintech: (entityId: string) => void
16 applyTaxLiabilityRecomputed: (payload: {
17 entityId: string
18 liability: TaxLiabilitySnapshot
19 at: string
20 }) => void
21 applyEntryPosted: (payload: {
22 entityId: string
23 entry: JournalEntry
24 at: string
25 }) => void
26 applyTransactionCategorised: (payload: {
27 entityId: string
28 transactionId: string
29 accountCode: string
30 at: string
31 }) => void
32}
33
34export const useDashboardStore = create<DashboardState & DashboardActions>((set, get) => ({
35 entityId: null,
36 cashBalance: Money.fromCents(0, 'EUR'),
37 taxLiability: { vat: Money.fromCents(0, 'EUR'), tradeTax: Money.fromCents(0, 'EUR') },
38 recentEntries: [],
39 lastEventAt: null,
40
41 setFintech: (entityId) => set({ entityId, recentEntries: [], lastEventAt: null }),
42
43 applyTaxLiabilityRecomputed: ({ entityId, liability, at }) => {
44 if (get().entityId !== entityId) return
45 set({ taxLiability: liability, lastEventAt: new Date(at) })
46 },
47
48 applyEntryPosted: ({ entityId, entry, at }) => {
49 if (get().entityId !== entityId) return
50 set((state) => ({
51 recentEntries: [entry, ...state.recentEntries].slice(0, 50),
52 lastEventAt: new Date(at),
53 }))
54 },
55
56 applyTransactionCategorised: ({ entityId, transactionId, accountCode, at }) => {
57 if (get().entityId !== entityId) return
58 set((state) => ({
59 recentEntries: state.recentEntries.map((e) =>
60 e.sourceTransactionId === transactionId ? { ...e, accountCode } : e,
61 ),
62 lastEventAt: new Date(at),
63 }))
64 },
65}))

Drei Dinge sind hier bemerkenswert. Erstens: Jede Action ist nach dem Event benannt, mit dem sie sich abgleicht. applyTaxLiabilityRecomputed gleicht den Store gegen ein fintech.tax.liability-recomputed-Event ab. Die Namenskonvention macht die Bindung zwischen dem Topic und der Action selbstdokumentierend — eine Entwicklerin, die den Store liest, kann jede Action zurück auf das Backend-Event abbilden, ohne die Datei zu verlassen. Zweitens: Jede Action wacht über entityId. Events kommen in einem Per-Entity-Raum an, aber eine Nutzerin kann die Entity mitten in der Session wechseln; ein veraltetes Event für die vorherige Entity muss verworfen werden, nicht angewandt. Der Wächter ist eine Zeile und verhindert eine ganze Klasse von Tenant-übergreifenden Daten-Lecks. Drittens: Der Store verwendet die @fintech/money-Bibliothek für monetäre Werte statt roher Zahlen. Das ist in einer Finanz-Anwendung nicht verhandelbar — jede arithmetische Operation läuft durch eine präzisions-sichere Abstraktion, die Rundung, Währung und Allokation korrekt handhabt. Der Store wäre auf hundert kleine Weisen falsch, würde er JavaScript-Zahlen für Geld verwenden, und die meisten dieser Falschheiten blieben unsichtbar, bis eine Kundin sie auffinge.

Die Socket-zu-Store-Bindung lebt in einem einzigen Hook, der am Anfang der Dashboard-Route gemountet wird:

1// features/dashboard/client/hooks/useDashboardSocket/useDashboardSocket.ts
2'use client'
3
4import { useEffect } from 'react'
5import { io, type Socket } from 'socket.io-client'
6import { useDashboardStore } from '@/features/dashboard/client/store/dashboardStore'
7
8let socket: Socket | null = null
9
10export function useDashboardSocket(entityId: string) {
11 const apply = {
12 taxLiabilityRecomputed: useDashboardStore((s) => s.applyTaxLiabilityRecomputed),
13 entryPosted: useDashboardStore((s) => s.applyEntryPosted),
14 transactionCategorised: useDashboardStore((s) => s.applyTransactionCategorised),
15 }
16
17 useEffect(() => {
18 if (!entityId) return
19
20 socket = io(process.env.NEXT_PUBLIC_SOCKET_URL, { withCredentials: true })
21
22 socket.on('fintech.tax.liability-recomputed', (envelope) =>
23 apply.taxLiabilityRecomputed(envelope.payload),
24 )
25 socket.on('fintech.journal.entry-posted', (envelope) =>
26 apply.entryPosted(envelope.payload),
27 )
28 socket.on('fintech.banking.transaction-categorised', (envelope) =>
29 apply.transactionCategorised(envelope.payload),
30 )
31
32 return () => {
33 socket?.disconnect()
34 socket = null
35 }
36 }, [entityId])
37}

Das ist die gesamte Frontend-Verkabelung. Der Hook verbindet beim Mount, registriert pro Topic, das das Dashboard interessiert, einen Event-Listener, leitet das Payload jedes eingehenden Envelopes an die passende Store-Action weiter und trennt beim Unmount. Kein useState für irgendwelche Dashboard-Daten — der Store besitzt sie. Kein Prop-Drilling — Komponenten, die den Cash-Stand brauchen, rufen useDashboardStore((s) => s.cashBalance) auf und re-rendern, wenn (und nur wenn) er sich ändert. Kein Polling, kein manuelles Refetch, kein "Pull-to-Refresh"-Button. Der Bildschirm ist ehrlich im Präsens, was das Produkt versprochen hat.

Ein Wort zu optimistischen Updates, denn hier gehen event-driven Frontends und klassische optimistic-UI-Frontends manchmal auseinander. Die Nutzerin klickt einen Button, der etwa einen Vorschlag des Kontierers bestätigt. Der klassische Optimistic-UI-Schritt ist, den lokalen Zustand sofort zu aktualisieren und anschliessend den Request abzuschicken, mit Rollback bei Fehler. In diesem Stack ist die Regel leicht anders: Optimistische Mutationen sind erlaubt, aber sie sind explizit, gescoped und kurzlebig. Der Button-Handler wendet die Änderung optimistisch auf den Store an, mit einem optimistic: true-Flag auf der betroffenen Zeile. Wenn das entsprechende fintech.banking.transaction-categorised-Event über den Socket zurückkommt (typischerweise innerhalb weniger hundert Millisekunden), ersetzt die Reconciliation-Logik des Stores die optimistische Zeile durch die kanonische und löscht das Flag. Kommt das Event nicht innerhalb eines Timeouts an — sagen wir fünf Sekunden —, wird das Optimistic-Flag zu einer Fehler-UI eskaliert, und die Nutzerin wird zum erneuten Versuch eingeladen. Das Event aus dem Socket ist immer die Quelle der Wahrheit; der optimistische Zustand ist eine UX-Höflichkeit, kein Zustand des Datensatzes. Diese Regel ist klein und starr; sie verhindert die ganze Sorte von Bugs, bei denen lokaler Zustand und Backend-Zustand still auseinanderlaufen.

Der Vergleich, Seite an Seite

Um alles zusammenzuziehen, die Tabelle, die ich Kunden zeige, die sich zwischen dem einfachen Stack und dem Vier-Boxen-Stack entscheiden:

EigenschaftPollingSupabase Realtime / PusherDieser Stack (Kafka + Socket.io + Zustand)
AufsetzkostenTrivialHalber SprintZwei bis drei Sprints
Multi-Consumer-Fan-OutJeder Consumer polltNur Browser; Backend-Consumer fragilNativ; jede Sprache, jede Deploy-Frequenz
Replay aus der HistorieUnmöglichUnmöglichEinzeilige Offset-Reset, repliziert das Retention-Fenster
Audit-TrailKeiner (nur DB-Zustand)Keiner (nur DB-Zustand)Jedes Event als Event erhalten, nach Zeit abfragbar
Durchsatz-DeckeDB-gebunden; kollabiert frühWenige Tausend Events/Sekunde pro SlotZehntausende/Sekunde pro Partition, horizontal skalierbar
Polyglottes BackendMöglich, aber schmerzhaftPer Design Browser-onlyErstklassig; Node + Python + Rust auf demselben Log
Vendor-Lock-InKeinerStarkKeiner; Kafka und Strimzi sind offen, portabel, selfhosted
Monatskosten (Produktion)0 € + DB200 €–2.000 € (managed)150 €–400 € Compute, vollständig selfhosted

Die Zahlen in der letzten Zeile sind nicht der Punkt, und sie werden sich bewegen; die Form ist der Punkt. Der Vier-Boxen-Stack ist im Aufbau teurer und im Skalen-Betrieb günstiger; er ist am ersten Tag komplexer und am Tag, an dem der einfache Stack sichtbar versagt, dramatisch einfacher. Entscheiden Sie entsprechend.

Wann dieser Stack die richtige Antwort ist — und wann nicht

Dieser Stack ist nicht für jedes Produkt. Er ist für Produkte, in denen mindestens zwei der folgenden Punkte zutreffen: Die Daten auf dem Bildschirm repräsentieren Geld oder eine andere hochkritische Grösse; das Backend hat — oder wird innerhalb des nächsten Jahres haben — mehr als einen Consumer desselben Event-Stroms; das Team umfasst (oder wird umfassen) Entwicklerinnen, die in mehr als einer Sprache schreiben; die Compliance- oder Audit-Anforderung verlangt ein replaybares Protokoll der Zustandsänderungen; der Durchsatz an Zustandsänderungen übersteigt einige Tausend Events pro Sekunde; das Frontend hat mehr als eine Nutzerin, die gleichzeitig dieselben Daten anschaut. Das Fintech, das ich beschrieben habe, hakt jeden dieser Punkte ab. Das tun auch die meisten ernsthaften 2026er-Finanzdaten-Produkte, die meisten Healthcare-Plattformen, die meisten Logistik-Anwendungen jenseits einer gewissen Grösse und die meisten B2B-SaaS-Produkte, sobald deren grösster Kunde selbst eine mehrteamige Organisation wird.

Er ist die falsche Antwort für einen Single-Tenant-Prototypen, eine Marketing-Seite, ein kleines SaaS, dessen Backend einen einzigen Consumer hat (sich selbst), oder jedes Produkt, dessen Realtime-Bedürfnisse mit "die Seite ist beim nächsten Navigieren frisch" abgedeckt sind. Für die sind Supabase Realtime, Pusher, Polling oder schlicht keine Live-Updates korrekt. Die Kunst besteht darin zu erkennen, auf welcher Seite der Linie Ihr Produkt steht — und bereit zu sein, zu migrieren, wenn es sie überschreitet.

Wenn Sie das hier lesen, weil Sie jemanden für den Bau einer Realtime-React-Plattform für ein Fintech, eine regulierte Branche oder irgendein Produkt evaluieren, in dem die Daten auf dem Bildschirm Gewicht haben: Ich bin diejenige Person, die diesen Stack End-to-End gebaut und in Produktion betrieben hat. Die vier Boxen in diesem Artikel sind die tatsächliche Architektur, mit der ich täglich arbeite, in mehreren kommerziellen Codebasen. Die Muster sind kampferprobt. Die Trade-Offs sind explizit. Der Migrationspfad von einem einfacheren Startpunkt ist gut verstanden und inkrementell — es gibt kein Rip-and-Replace.

Ich bin verfügbar für freiberufliche Engagements in genau diesem Problemraum: Realtime-Daten-Plattformen in React und Next.js, mit event-driven Backends quer durch Node, Python und (wo es sich auszahlt) Rust, auf Kubernetes. Melden Sie sich über das Formular — ich freue mich auf einen Austausch.

Kontaktaufnahme

z.B. Ludwig van Beethoven
z.B. ludwig@beethoven.co
Falls das eine Herausforderung anspricht, vor der Sie stehen, freue ich mich auf einen Austausch darüber, wie ich helfen kann.