Am 9. Dezember 2025 taten wir etwas, das kein Engineering-Team gern tut: Wir lieferten einen Commit aus, der das wichtigste Feature unseres Kunden deaktivierte. Die nationale Kartenansicht — das Erste, was jeder Nutzer sah — wurde schlicht abgeschaltet.

Es war kein Bug. Es war eine Entscheidung. Die Karte war gegen eine Wand gelaufen, die sich durch kein Feintuning mehr verschieben ließ — und sie weiter humpeln zu lassen wäre schlimmer gewesen, als sie anzuhalten und das Fundament darunter neu zu bauen. Dies ist die Geschichte dieses Neubaus: fünf Monate, drei Architektur-Generationen und eine Karte, die heute in Sekunden lädt, was früher eine Minute dauerte — oder gar nicht ankam.

Unser Kunde steht unter NDA, deshalb finden Sie hier weder Namen noch Produkt. Alles andere — Architektur, Zahlen, Fehler — ist echt.

Die Ausgangslage

Die Plattform, die wir gebaut haben, visualisiert einen wöchentlich aktualisierten Datensatz über die gesamten USA auf Ebene der Census Block Groups — rund 250.000 Polygone, jedes bewertet über etwa 35 thematische Dimensionen, jede Woche neu. Nutzer verschieben die Karte, wählen ein Thema, wechseln Wochen, filtern nach Zielgruppensegmenten — und erwarten, dass das ganze Land wie eine native App reagiert.

Der letzte Satz ist die ganze Schwierigkeit. Jede Anforderung für sich ist Routine. Zusammen — nationale Skala, wöchentlicher Refresh, Dutzende umschaltbarer Ebenen, kundenspezifische Anpassung — verdichten sie sich zu einem wirklich harten Problem.

Pipeline-Diagramm: wöchentlicher S3-Drop, Python-Verarbeitung auf EC2, Tile-Server, Merge-Proxy, Mapbox-GL-Client
Der Stack von Ende zu Ende: wöchentliche Daten rein, gecachte Kacheln raus.

Akt I: Die GeoJSON-Wand

Die erste Version tat das Naheliegende. Das Backend lieferte Polygon-Geometrien als WKT-Strings; das Frontend parste sie zu GeoJSON und übergab sie Mapbox GL als einfache Datenquelle. Für einen einzelnen Bundesstaat funktionierte das gut.

Dann zoomten die Nutzer heraus.

Auf nationaler Ebene sollte der Browser eine Viertelmillion Polygone als rohes JSON herunterladen, parsen und rendern — pro Thema, pro Woche. Die Ladezeiten wuchsen auf über 30 Sekunden. Der Speicher lief voll. Wir versuchten IndexedDB-Caching für Bundesstaaten, verzögertes Laden, schlankere Payloads. Jeder Fix kaufte etwas Zeit und änderte nichts Grundsätzliches: Das Datenmodell passte nicht zur Skala.

Vergleich: rohes GeoJSON mit 250.000 Polygonen lädt 30 bis 60 Sekunden, Vector Tiles laden nur das Sichtfeld in 2 bis 5 Sekunden
Dieselbe Karte, zwei Datenmodelle: ein riesiger Payload vs. nur die Kacheln im Sichtfeld.

Also schalteten wir die nationale Ansicht ab — und gaben uns selbst die Erlaubnis, das eigentliche Problem zu lösen.

Akt II: In elf Tagen zum Tile-Server

Die Lösung hatte einen Namen, den wir längst kannten: Vector Tiles. Statt rohe Geometrie auszuliefern, zerschneidet man das Land vorab in eine z/x/y-Pyramide kompakter, binärer MVT-Kacheln — MBTiles auf der Festplatte — und die Karte fordert immer nur die Handvoll Kacheln im Sichtfeld an.

Die eigentliche Frage war, wo diese Kacheln leben sollten. Gehostete Tilesets sind bequem — Uptime und CDN gehören jemand anderem —, aber ein Datensatz, der sich jede Woche komplett ändert, würde in der Upload-Pipeline eines anderen leben, nach dessen Zeitplan. Selbst zu hosten hieß, die Uptime zu besitzen — aber eben auch die Refresh-Schleife von Ende zu Ende. Wir entschieden uns für Besitz, mit einem konkreten Ziel, festgehalten vor der ersten Zeile Code: das ganze Land, eine Viertelmillion Polygone, erstes Rendern unter zehn Sekunden.

Was dann geschah, ist heute bewusst gelebte Praxis bei uns: der dokumentierte Spike. Bevor wir uns auf die Architektur festlegten, schrieben wir ein Audit-Dokument ins Repository — aktueller Ansatz, seine Grenzen, das vorgeschlagene Design, messbare Ziele. Während des Spikes führten wir ein laufendes Issue-Log: Unser erster TileServer-GL-Container stürzte bei jeder Anfrage ab, und das Log sammelte sieben abgehakte Debugging-Schritte, bevor wir festen Boden fanden. Diese Dokumente liegen noch heute im Repo. Wenn ein neuer Engineer fragt, warum etwas so gebaut ist, ist die Antwort kein Stammeswissen — sondern eine Datei mit Datum.

Die Timeline überrascht uns immer noch:

  • Tag 1: erster Prototyp — Docker, TileServer-GL, ein tippecanoe-Skript, eine Testseite.
  • Tag 5: auf Staging gemerged; die Kartenkomponente sprach jetzt mit unserem eigenen Tile-Server.
  • Tag 11: Produktion — HTTPS, eine richtige Domain, ein EC2-Host und ein automatischer S3-Sync mit State-File, Speicherplatz-Checks und nächtlichem Cron.
Zeitstrahl: Tag 1 Prototyp, Tag 5 auf Staging, Tag 11 in Produktion
Elf Tage von der abgeschalteten Karte zur eigenen Infrastruktur.

Selbst zu hosten lehrte uns auch die eigene Folklore — die Sorte, die man nur um 23 Uhr lernt:

  • SQLite-Journal-Dateien. Ein Verarbeitungslauf hinterlässt -wal- und -shm-Dateien neben der Datenbank; ein Tile-Server unter anderem User versucht sie zu öffnen, scheitert und startet in einer Schleife neu. Der Fix sind zwei Zeilen — Container-User angleichen, Journale vor dem Neustart löschen.
  • Container-Pfade sind keine Host-Pfade. Die Konfiguration des Servers muss den Pfad innerhalb des Container-Mounts referenzieren, nicht den auf dem Host. Steht heute an drei Stellen dokumentiert, weil es uns mehr als einmal erwischt hat.
  • URL-Encoding gehört zum Vertrag. Tileset-IDs mit Schrägstrichen müssen prozent-codiert reisen — in der Server-Konfiguration, im URL-Builder des Frontends, überall. Eine inkonsistente Schicht, und nichts löst sich auf.

Jeder dieser Punkte ist heute eine Zeile im Runbook. Jeder hat uns zuerst einen Abend gekostet.

Akt III: 35 Tilesets betreten einen Browser

Vector Tiles reparierten das Rendering. Sie reparierten nicht die Arithmetik.

Der Datensatz kommt als ein Tileset pro Thema und Woche — rund 35 Dateien, etwa 2,5 GB wöchentlich. Die zentrale Ansicht der Plattform stellt eine Frage, die alle gleichzeitig betrifft: Welches Thema hat gerade in jedem Viertel Amerikas den höchsten Wert? Sie ehrlich zu beantworten hieß: 35 Tilesets im Browser laden und clientseitig vergleichen. Ladezeit: 30–60 Sekunden. Wir hatten die alte Wand aus neueren Ziegeln nachgebaut.

Unser erster Instinkt war der ausgetretene Pfad: tippecanoes tile-join kann Tilesets zusammenführen. Aber Zusammenführen stapelt nur Ebenen in einer Datei — es entscheidet nichts. Kein fertiges Tool konnte auf ein Polygon schauen, 35 Werte vergleichen und nur den Gewinner behalten. Also schrieben wir eins.

Dafür hilft es zu wissen, was eine Vektorkachel eigentlich ist: eine winzige Protobuf-Datenbank — Ebenen von Features, deren Koordinaten ganze Zahlen auf einem 4.096er-Raster innerhalb der Kachel sind. MBTiles verpackt Tausende solcher Kacheln in eine einzige SQLite-Datei und speichert die Zeilen von unten nach oben in TMS-Ordnung, während das Web sie von oben nach unten in XYZ anfragt — jeder Lesezugriff spiegelt also die Y-Koordinate. Und Decoder wie Encoder müssen sich einig sein, in welche Richtung Y zeigt: Macht man das nur auf einer Seite falsch, rendern die gesamten USA gespiegelt. Wir wissen das, weil wir zugesehen haben.

Der Prozessor decodiert jede Vektorkachel und wählt für jedes der 250.000 Polygone das Thema mit dem höchsten Wert — und codiert dann ein einschichtiges Tileset, das die Frage direkt beantwortet. Interessant wird es beim Ausgabeschema: Neben dem Gewinner speichern wir die Namen der Ränge zwei bis fünf als einfache Properties. Diese eine Modellierungsentscheidung bedeutet: Das Frontend kann das ganze Land per reiner Mapbox-GL-match-Expression nach dem „zweitplatzierten Thema“ umfärben — eine GPU-Operation. Kein JavaScript fasst ein einziges Feature an. Der Rangwechsel ist sofort da.

Der Prozessor durchlief sechs Generationen in rund drei Wochen — die Abfolge ist ein Crashkurs in Data Engineering:

  1. Naiv: alles in den Speicher laden. Funktionierte an Beispielen, starb an nationalen Zoomstufen.
  2. Parallel: mit Multiprocessing auffächern. Schneller, weiter speichergebunden.
  3. Merge-first: Eingaben vorab zusammenführen, eine Datei verarbeiten. Einfacherer Input, gleiche Decke.
  4. Streaming: der Durchbruch — direkt über den SQLite-Cursor iterieren und nichts festhalten. Spitzenverbrauch: 50–100 MB, unabhängig von der Eingabegröße.
  5. Direkt: den Merge ganz überspringen; die Themen-Dateien im Gleichschritt lesen.
  6. Segmentiert: vier Zielgruppen-Varianten jedes Werts ins selbe Tileset backen, sodass eine Datei einen kompletten Segment-Umschalter antreibt — wieder rein auf der GPU umgestylt.

Im Streaming-Rewrite steckt auch die unglamouröse Datenbankarbeit: Write-Ahead-Logging auf der Ausgabedatei, synchrone Writes für die Dauer des Laufs abgeschaltet, ein 500-MB-Page-Cache, ein Commit alle paar tausend Kacheln. Nichts davon ist exotisch; zusammen ist es der Unterschied zwischen einem Job, der über Nacht fertig wird, und einem, den der Kernel um drei Uhr morgens abschießt.

Noch etwas haben uns die langen Läufe gelehrt: Ein Fünf-Stunden-Job, den man nicht sehen kann, ist ein Horrorfilm. Jeder Lauf loggt eine Fortschrittszeile pro Batch — erledigte Kacheln von 21.861, verstrichene Zeit, Ausgabegröße, Fehlerzähler — und wir schauten aus drei Blickwinkeln zu: ein tail auf dem Log, ein kleines Skript, das den Prozess jede Minute per SSH abfragt, und Cloud-CPU-Metriken aus einer Browser-Shell, wenn kein Terminal in der Nähe war. Die Ziellinie ist ein wörtliches DONE im Log. Langweilige Observability, konsequent angewendet — das erlaubte uns, mehrere solcher Läufe über verschiedene Wochen laufen zu lassen, ohne nervös zu werden.

Der Produktionslauf, der es bewies: 21.861 Kacheln, Zoomstufen 4–10, verarbeitet in 4,7 Stunden mit null Fehlern auf einer 8-vCPU-Maschine — bewusst auf sechs Worker begrenzt, damit der Tile-Server auf derselben Maschine ansprechbar blieb. Und das Ergebnis, das Nutzer spüren:

~2,5 GB → ~70 MB. 35 Ebenen → eine. 30–60 Sekunden → 2–5 Sekunden.
Diagramm: 35 Wochen-Tilesets werden decodiert, pro Polygon wird der Gewinner gewählt und in ein einziges 70-MB-Tileset mit eingebackenen Rängen codiert
35 Wochen-Tilesets, ein Argmax, eine Antwort.

Eine 35-fache Verkleinerung — weil wir die richtige Frage vorberechnet haben.

Akt IV: Monatskacheln, die es nicht gibt

Wöchentliche Daten erzeugen eine Produktfrage: Wie sieht „dieser Monat“ aus? Die naheliegende Antwort — Monats-Tilesets im Batch erzeugen — schafft Speicher, Sync und Veralterung im Wert von vier neuen Pipelines. Wir gingen den anderen Weg.

Wir bauten einen kleinen FastAPI-Proxy, der Monatskacheln zur Anfragezeit synthetisiert. Ein „Monat“ ist nichts als eine URL: eine Liste von Wochen und ein Gewichtsvektor:

/monthly/{theme}/{z}/{x}/{y}.pbf?weeks=W09,W08,W07,W06&weights=40,30,20,10

Unter der Haube durchläuft jede Anfrage vier Phasen:

  1. Lesen. Die MBTiles jeder Woche parallel und read-only öffnen — ein indizierter Zugriff pro Datei für die angefragte Kachel.
  2. Decodieren. Die Kompression an den ersten Bytes erkennen (manche Dateien sind gzip, manche zlib, manche unkomprimiert), dann das eigentliche Vector-Tile-Protobuf decodieren.
  3. Mischen. Features nach Polygon-ID gruppieren und den gewichteten Durchschnitt pro Feld berechnen. Existiert ein Feld nur in drei von vier Wochen, werden die Gewichte über diese drei renormalisiert — eine Datenlücke kann den Wert eines Viertels nicht still nach unten ziehen.
  4. Encodieren. Eine Ebene neu codieren, gzippen, mit den richtigen Headern ausliefern. Der Browser erfährt nie, dass es die Kachel vor fünfhundert Millisekunden noch nicht gab.

Drei Details, die wir mögen:

  • Die Gewichte sind ein Produkt-Regler, keine Engineering-Konstante. Ein Admin-Screen lässt den Kunden die Wochengewichtung einstellen; die nächste Kachelanfrage rechnet einfach mit den neuen Gewichten. Data Science kann experimentieren, ohne zu deployen.
  • Cache-Keys führen Buch. Der Schlüssel hasht Thema, sortierte Wochen, Gewichte und Koordinaten. Sortierte Wochen heißt: Zwei Reihenfolgen teilen sich einen Eintrag. Enthaltene Gewichte heißt: Jedes Experiment ist automatisch sein eigener Cache-Namespace. Es gibt keine Invalidierungslogik, die man falsch bauen könnte — eine neue Konfiguration ist ein neuer Schlüssel.
  • Drei Cache-Schichten, ehrliche Telemetrie. In-Process-LRU, SQLite auf Platte (Treffer werden zurück in den Speicher befördert) und nginx davor. Ein Health-Endpoint meldet Hit- und Miss-Zähler, jeder Merge loggt seine Phasenzeiten. Ist ein Merge langsam, wissen wir genau, welche Phase schuld ist.
Diagramm: vier Wochenkacheln mit Gewichten fließen in einen gewichteten Durchschnitt und werden als Monatskachel über Memory-, SQLite- und nginx-Cache ausgeliefert
Monatskacheln entstehen pro Anfrage — Wochen, Gewichte und drei Cache-Schichten.

Zwei Produktionsnotizen zum Stehlen. Erstens: Der Reverse-Proxy davor gibt diesen Anfragen großzügige Timeouts — eine kalte, landesweite Kachel auf niedriger Zoomstufe darf legitim Dutzende Sekunden Merge-Zeit brauchen, und das als Fehler zu werten würde die Arbeit nur erneut anstoßen. Zweitens: CORS-Header leben in genau einer Schicht des Stacks. Früher waren es zwei, und der daraus entstandene Doppel-Header-Bug ist heute ein Kommentar in der Konfigurationsdatei, damit ihn niemand wieder einbaut.

Der Teil, der das Ganze zu einem Favoriten macht: Der Merge-Algorithmus wurde nicht für den Service geschrieben. Er stammt aus dem Batch-Prozessor von Akt III. Derselbe Code, der einmal als Fünf-Stunden-Job offline lief, beantwortet heute HTTP-Anfragen — Batch-Logik, befördert in die Anfragezeit, weil das Caching sie bezahlbar macht.

Akt V: Eine Plattform, viele Kunden

Das leiseste Stück der Architektur ist das, welches das Team des Kunden am häufigsten anfasst. Jeder Datensatz — jede Woche, jedes Inhaltspaket, bis hinunter zu einzelnen Daten-Snapshots — trägt unabhängige Staging- und Produktions-Flags, verwaltet über ein Admin-Panel. Derselbe Build bedient beide Umgebungen; ein Hostname-Check entscheidet, welche Flags gelten.

Die Konsequenz ist leicht gesagt und schwer zu überschätzen: Daten-Releases sind von Code-Deploys entkoppelt. Eine neue Datenwoche kommt freitags an; das Team des Kunden prüft sie auf Staging im Produktions-Build und schaltet sie dann per Toggle live. Kein Deploy, kein Release-Zug, kein Engineer in der Schleife. Rollback ist derselbe Toggle. Die Personalisierung läuft derweil durch die Kacheln selbst — Segment-Varianten und Affinitätsfelder stecken in jedem Polygon, sodass die Filterauswahl eines Nutzers nur ändert, welche Properties die GPU-Expressions lesen. Maßschneiderei ohne Zusatz-Requests.

Das Frontend hält die letzte Verteidigungslinie — auch Tile-Server haben schlechte Tage. Die Auflösung von Thema zu Tileset fährt zweigleisig: zuerst ein unscharfes Matching gegen den Katalog des Tile-Servers — Titel werden normalisiert und über Wortüberlappung verglichen, und alles unter einer Konfidenz-Untergrenze wird verworfen, denn eine selbstbewusst falsche Karte ist schlimmer als eine fehlende. Ist der Katalog langsam oder der Treffer zu schwach, baut ein deterministischer Builder die URL allein aus der Namenskonvention. Darum herum: Timeouts, exponentielles Backoff, Stale-Response-Guards, ein Metadaten-Cache pro Woche — und eine Lade-State-Machine, deren Phasen (Katalog holen, Matching, Kacheln laden, fertig) dem Nutzer als ehrlicher Fortschritt gezeigt werden statt als Spinner unbekannter Absicht.

Akt VI: Weniger ausliefern, mehr overzoomen

Die aktuelle Iteration dreht den üblichen Instinkt noch einmal um. Statt jede Zoomstufe von 4 bis 12 zu erzeugen, wechseln wir zu spärlichen Tilesets — nur Zoom 4 und Zoom 8 — und lassen Mapbox GL overzoomen: die nächstliegende vorhandene Kachel über die fehlenden Stufen strecken. Da die Polygone ohnehin Viertel-Größe haben, sind die visuellen Kosten vernachlässigbar. Die einzige tragende Änderung im Frontend war Ehrlichkeit: der Kartenquelle das echte Zoom-Maximum nennen, damit der Renderer skaliert, was existiert, statt anzufragen, was es nicht gibt.

Die Arithmetik ist brutal und wunderbar. Eine Kachelpyramide vervierfacht sich grob pro Stufe: Unser Build für Zoom 4–10 umfasst ~21.800 Kacheln, und Zoom 10 allein stellt davon ~15.800. Behält man nur Zoom 4 und Zoom 8, erzeugt man etwa 1.210 — rund 94 % weniger Kacheln zum Bauen, Speichern und Syncen, jede einzelne Woche, multipliziert über ~35 Themen.

Diagramm einer spärlichen Kachelpyramide, in der nur Zoom 4 und Zoom 8 erzeugt und die Zwischenstufen overzoomt werden
Spärliche Pyramide: z4 und z8 erzeugen, alles dazwischen overzoomen.

Es ist dieselbe Lektion wie in Akt III im anderen Mantel: Die billigste Kachel ist die, die man nie erzeugt.

Was wir Ihnen beim Kaffee erzählen würden

  • Ein Feature abzuschalten kann der produktivste Commit des Quartals sein. Es verwandelt ein langsames Ausbluten in ein definiertes Problem mit Deadline.
  • Schreiben Sie das Audit vor dem Rewrite — und heben Sie das Debugging-Log auf. Unser Docs-Ordner ist ein Entscheidungsjournal; fünf Monate später hat er den Großteil dieses Artikels geschrieben.
  • Berechnen Sie die Frage vor, nicht die Daten. Jeder Größenordnungs-Gewinn kam daher, eine Entscheidung — Top-Thema, Rangfolge, Segment-Variante, Monatsmischung — auf die günstigste mögliche Schicht zu verlegen: offline, in den Cache-Key oder auf die GPU.
  • Übernehmen Sie die langweiligen Teile selbst. Selbst gehostetes Tile-Serving kostete uns etwas Spätabend-Folklore — aber genau das machte wöchentlichen Refresh, Merges zur Anfragezeit und sofortige Daten-Toggles überhaupt möglich.
  • Kleines Team, kurze Schleife. Gebaut von einem sehr kleinen Team in rund viereinhalb Monaten aus einem leeren Repository — Trunk-Entwicklung, Spike-Branches für die riskanten Teile und Product-Owner-Toggles statt Release-Zeremonien.

Die Karte ist wieder an, national als Standard, und niemand denkt mehr über sie nach. Genau das war das Ziel.