Als wir vor einem Jahr begonnen haben, den Omnika-Hub zu bauen, war Postgres die offensichtliche Default-Wahl. Jeder Cloud-Anbieter hat einen managed Service, jedes ORM hat erstklassige Unterstützung, und unsere früheren Projekte liefen alle auf Postgres. Trotzdem haben wir uns dagegen entschieden. Wir betreiben den gesamten Hub auf SQLite mit better-sqlite3, im WAL-Mode, auf einer einzigen Hetzner-Box in Falkenstein. Dieser Artikel erklärt, warum — und wann wir migrieren werden.
Die Single-Box-These
Die meisten SaaS-Anwendungen sind weit kleiner als wir glauben wollen. Ein moderner Server mit 64 GB RAM und NVMe-SSDs hält den kompletten State von tausenden aktiven Teams im Memory. Wenn du nicht gerade Notion-Scale anpeilst, ist eine Box mit täglichem Backup nach S3-kompatiblem Storage betriebswirtschaftlich unschlagbar. Wir reden hier von €80 im Monat statt €800 für ein vergleichbares RDS-Setup mit Read-Replica und Multi-AZ-Failover.
SQLite passt zu dieser These wie kein anderes System. Es ist kein Netzwerk-Service, sondern eine Library, die in deinen Prozess gelinkt wird. Reads gehen über mmap direkt aus dem Page-Cache — Latenzen liegen unter 10 Mikrosekunden für die meisten Queries. Writes gehen über WAL-Mode parallel zu Reads, ohne dass Reader blockieren.
better-sqlite3 vs Prisma
Wir nutzen better-sqlite3 statt Prisma, und das ist eine bewusste Wahl. Prisma ist ein gutes Tool, aber sein Query-Engine-Modell mit einem separaten Rust-Prozess passt nicht zu unserer Architektur. Wir wollen die SQL-Engine im selben Prozess wie der Node-Server, damit wir Transactional-Boundaries selbst kontrollieren — und damit wir Migrationen mit `PRAGMA` direkt steuern können.
better-sqlite3 ist synchron. Das klingt erstmal verstörend für jeden, der mit Node.js sozialisiert wurde. In der Praxis ist es ein Feature: Eine SQLite-Query gegen den lokalen File-Descriptor dauert typischerweise 50 Mikrosekunden. Den Event-Loop für diese Zeit zu blocken, ist günstiger als eine Async-Round-Trip aufzubauen. Wir messen das in Production, und der p99-Latenzen-Vergleich gibt uns recht.
Wo SQLite schlechter ist
Ich will nicht so tun, als wäre die Wahl trade-off-frei. SQLite hat keinen echten Concurrent-Writer-Modus. Im WAL-Mode kann es zwar gleichzeitig viele Reader und einen Writer handhaben, aber wenn zwei Endpoints gleichzeitig schreiben wollen, serialisieren sie sich. Für uns ist das okay — unsere Write-Last liegt bei vielleicht 200 Writes pro Sekunde auf Peak, und ein Write dauert unter einer Millisekunde. Aber wenn du eine Workload hast, bei der hunderte parallele Writer existieren, passt SQLite nicht.
Zweite Schwäche: Replikation. Postgres hat erstklassige Built-In-Replikation. Bei SQLite mussten wir uns für Litestream entscheiden, das die WAL-Datei kontinuierlich nach S3 streamt. Das ist solide und ich vertraue der Implementierung, aber es ist ein zusätzliches Tool im Stack.
Drittens: Schema-Migrationen. SQLite kann historisch keine `ALTER COLUMN`-Statements wie Postgres. Wir haben unsere Migrations-Library so geschrieben, dass sie für `DROP COLUMN` und `ALTER COLUMN` automatisch ein Copy-Replace-Renaming durchführt. Das funktioniert, aber es ist mehr Code, den wir warten müssen.
Wann wir migrieren
Unser Migrations-Trigger ist klar definiert: Sobald wir entweder mehr als 50.000 aktive Teams haben, oder unsere Write-QPS-Last über 5.000 pro Sekunde geht, oder ein einzelner Mandant signifikant mehr als 10 GB Storage braucht — dann migrieren wir auf Postgres mit pgvector. Bis dahin gilt: SQLite ist nicht die billige Alternative, sondern die explizit überlegene Wahl für unsere Lastklasse.
Was wir aus dieser Entscheidung gelernt haben: Die Default-Wahl ist oft Folklore, nicht Engineering. Lies das Postgres-Manual und das SQLite-Manual nebeneinander. Dann triff deine eigene Entscheidung.