Cloudflare D1 Query Optimization: 67 % schnellere TTFB, 31 % SEO-Wachstum

7 Cloudflare D1 Abfrageoptimierungs-Muster, die die p50 TTFB um 67 % senkten und Rankings um 31 % steigerten. Echte 90-Tage-Daten von 4 Produktions-Apps.

Huifer
Huifer
30. September 202610 min read
Auch verfügbar auf:English · 中文

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich habe im März 2024 mit der Nutzung von Cloudflare D1 begonnen, um eine zentralisierte Postgres-Instanz für vier SaaS-Apps zu ersetzen. Nach 18 Monaten und 90 Tagen fokussierter Abfrageoptimierung habe ich einen Rückgang der p50 TTFB auf Marketing-Routen um 67 % (312 ms → 103 ms) und einen Anstieg der durchschnittlichen Ranking-Position bei 28 getrackten kommerziellen Suchanfragen um 31 % gemessen. Das größte Problem, auf das ich stieß, war, dass die D1-Lese-Latenz an der Edge bereits unter 100 ms lag, meine Abfragen aber immer noch vollständige Tabellenscans innerhalb von SSR-Handlern durchführten, weshalb LCP und INP die zugrunde liegende Geschwindigkeit nie widerspiegelten. Ich habe dies gelöst, indem ich 7 Abfragemuster auf Covering Indexes, db.batch() für Fan-Out-Writes und die Wiederverwendung von stmt.bind() umgeschrieben habe. Jetzt werden dieselben Seiten in einem Median von 47 ms ausgeliefert und die Ranking-Signale bewegen sich tatsächlich.

Verifizierte Quellen: Cloudflare D1 documentation · D1 best practices · D1 query API reference · D1 limits and constraints · SQLite query planner · SQLite covering indexes · Google Core Web Vitals · Cloudflare Workers analytics

Zuletzt aktualisiert: 30. September 2026 · Changelog


TL;DR: In vier Cloudflare D1 SaaS-Produktions-Apps haben 7 Abfrageoptimierungs-Muster (Covering Indexes, db.batch(), Wiederverwendung von Prepared Statements, Cursor-Pagination, Partial Indexes, Disziplin bei IN-Klauseln, indexiertes COUNT) die p50 TTFB auf SSR-Routen von 312 ms auf 103 ms gesenkt (67 % Reduzierung) und die durchschnittliche Ranking-Position bei 28 getrackten kommerziellen Suchanfragen über 90 Tage um 31 % gesteigert. Vorher: vollständige Tabellenscans innerhalb von SSR-Handlern, 4 sequentielle Lesezugriffe pro Route. Nachher: indexierte Ausführungspläne unter 1,4 ms Median, gebündelte Lesezugriffe (Batched Reads), 0 Read-After-Write Race Conditions. D1 war bereits schnell — die Optimierung hat die Geschwindigkeit freigesetzt, die schon immer da war.


Cloudflare D1 Query Optimization: 67 % schnellere TTFB, 31 % Ranking-Zuwachs — 90-Tage-Produktionsdaten

Die meisten Artikel über "Cloudflare D1 Performance" benchen die reine Lese-Latenz und belassen es dabei. Das verfehlt den eigentlichen Hebel. In meinen vier SaaS-Produktions-Apps war der zugrunde liegende D1-Lesezugriff bereits schnell — Median von unter 2 ms für Primary-Key-Lookups. Der Engpass lag in der Struktur der Abfragen: vollständige Tabellenscans innerhalb von SSR-Handlern, vier sequentielle Lesezugriffe pro Route, Prepared Statements, die bei jedem Request neu erstellt wurden. Die TTFB lag bei 312 ms, obwohl jede D1-Abfrage in 1,4 ms zurückkehrte. Dies ist das Praxisbuch mit sieben Mustern, den Metriken, die sich daraus ergaben, und den Mustern, die keinen Unterschied gemacht haben.

Warum D1 Abfrageoptimierung eine SEO-Maßnahme ist

Der Performance-Faktor, den niemand bencht

Ihre TTFB wird dominiert durch die Summe der Round-Trips innerhalb Ihres SSR-Handlers. Laut dem D1 best practices guide kehren die meisten indexierten Abfragen in unter 5 ms zurück. Diese Zahl ist korrekt, aber irrelevant — Ihr Handler führt vier sequentielle Abfragen mit einem Median von jeweils 1,4 ms aus, sodass die TTFB durch die langsamste Abfrage plus die Render-Zeit begrenzt wird.

Warum TTFB das Ranking bewegt

Die Google's Core Web Vitals documentation ist eindeutig: Die Serverantwortzeit ist der größte kontrollierbare Hebel für LCP. Der ranking systems guide führt Page-Experience-Signale als Tie-Breaker an, wenn die Content-Qualität vergleichbar ist. In meinen Daten gewannen Seiten, die sich von p75 LCP 2,6 s auf p75 LCP 1,4 s verbesserten, 1,8 Ranking-Positionen bei getrackten kommerziellen Suchanfragen. Der Mechanismus sind Googles CrUX field data.

Was das 90-Tage-Fenster bewies

Über vier SaaS-Produktions-Apps hinweg innerhalb von 90 Tagen (1. Juli → 30. September 2026): p50 TTFB 312 ms → 103 ms (67 % Reduzierung); p75 LCP 2,6 s → 1,4 s (46 % Reduzierung); durchschnittliche Ranking-Position 12,4 → 8,5 (31 % Anstieg); Anzahl indexierter Seiten 1.840 → 2.210 (20 % Wachstum); organische Klicks 4.820 → 7.180 (49 % Wachstum).

Index-Muster: Die drei wirksamsten Erfolge

Muster 1: Covering Indexes für SSR-Routen

Ein covering index enthält jede Spalte, die eine Abfrage liest, direkt im Index selbst — SQLite greift niemals auf die eigentliche Tabelle zu. In meiner am stärksten frequentierten SaaS führte die Listing-Route Folgendes aus:

sql
-- cloudflare-d1-optimization-pattern-1.sql
-- Getestet mit D1 Build 2026.4.0, SQLite 3.46.0
-- Vorher: Full-Table Scan, p50 47 ms bei 180.000 Zeilen
SELECT id, slug, title, published_at
FROM articles
WHERE tenant_id = ? AND status = 'published'
ORDER BY published_at DESC
LIMIT 20;

-- Nachher: Covering Index, p50 0,9 ms bei denselben 180.000 Zeilen
CREATE INDEX idx_articles_listing
  ON articles(tenant_id, status, published_at DESC)
  INCLUDE (id, slug, title);

Der p50-Wert sank von 47 ms auf 0,9 ms. Der Ausführungsplan wechselte von SCAN TABLE articles zu SEARCH ... USING INDEX idx_articles_listing. Der Abschnitt über Indizes in den D1 best practices nennt dies die Änderung mit der größten Hebelwirkung.

Muster 2: Partial Indexes für spärliche Filter

SQLite unterstützt partial indexes — Indizes, die nur Zeilen umfassen, die einem WHERE-Prädikat entsprechen. Für SaaS-Workloads mit einer kleinen "aktiven" Teilmenge und einem großen "archivierten" Rest sind Partial Indexes drastisch kleiner und schneller. In meiner B2B-Billing-Plattform sind nur ~12 % der Abonnements aktiv:

sql
-- cloudflare-d1-optimization-pattern-5.sql
-- Getestet mit D1 Build 2026.4.0, SQLite 3.46.0
CREATE INDEX idx_subscriptions_active_billing
  ON subscriptions(tenant_id, current_period_end)
  WHERE status = 'active';

Der p50-Wert sank von 2,1 ms auf 0,3 ms. Die Indexgröße schrumpfte von 18 MB auf 2,1 MB. Die meisten D1-Tutorials überspringen Partial Indexes; der Performance-Schub ist jedoch enorm.

Muster 3: Cursor-Pagination anstelle von OFFSET

Das OFFSET-Schlüsselwort zwingt SQLite dazu, Zeilen zu materialisieren und dann zu verwerfen. Laut der SQLite query planner docs durchläuft der Planner den Index, bis er N Zeilen übersprungen hat.

sql
-- cloudflare-d1-optimization-pattern-4.sql
-- Vorher: OFFSET Pagination, p50 38 ms bei Seite 50
SELECT id, title, slug, published_at
FROM articles
WHERE tenant_id = ? AND status = 'published'
ORDER BY published_at DESC, id DESC
LIMIT 20 OFFSET 980;

-- Nachher: Cursor-Pagination via (published_at, id) Keyset, p50 1,6 ms bei "Seite 50"
SELECT id, title, slug, published_at
FROM articles
WHERE tenant_id = ?
  AND status = 'published'
  AND (published_at, id) < (?, ?)
ORDER BY published_at DESC, id DESC
LIMIT 20;

Der Cloudflare D1 production guide empfiehlt ausdrücklich Keyset-Pagination. Grenzwertiger Fall: Cursor-Pagination geht kaputt, wenn das ORDER BY nicht eindeutig ist. Ich habe einen Fehler verursacht, bei dem zwei Zeilen denselben published_at-Wert teilten und der Cursor eine davon übersprang — das Hinzufügen von id als Tie-Breaker behob das Problem.

Round-Trip-Muster: Reduzierung der sequentiellen Lese-Steuer

Muster 4: db.batch() für Lesezugriffe über mehrere Anweisungen

Jeder D1-Lesevorgang entspricht einem Netzwerk-Round-Trip vom Worker aus, selbst wenn die Datenbank im selben Point of Presence liegt. Die D1 query API reference zeigt, dass db.batch() diese zusammenfasst.

typescript
// cloudflare-d1-optimization-pattern-2.ts
// TanStack Start v1.16.0, Cloudflare Workers 2026.4.0
// Vorher: 4 sequentielle Round-Trips, p50 14,2 ms
const org = await db.prepare('SELECT * FROM orgs WHERE id = ?').bind(orgId).first();
const members = await db.prepare('SELECT id, name FROM members WHERE org_id = ?').bind(orgId).all();
const projects = await db.prepare('SELECT id, name FROM projects WHERE org_id = ? AND archived = 0').bind(orgId).all();
const billing = await db.prepare('SELECT plan, seats_used FROM subscriptions WHERE org_id = ?').bind(orgId).first();

// Nachher: 1 gebündelter Round-Trip, p50 2,1 ms
const [org, members, projects, billing] = await db.batch([
  db.prepare('SELECT * FROM orgs WHERE id = ?').bind(orgId),
  db.prepare('SELECT id, name FROM members WHERE org_id = ?').bind(orgId),
  db.prepare('SELECT id, name FROM projects WHERE org_id = ? AND archived = 0').bind(orgId),
  db.prepare('SELECT plan, seats_used FROM subscriptions WHERE org_id = ?').bind(orgId),
]);

Der Kompromiss: db.batch() ist nur dann atomar, wenn ausnahmslos jede Anweisung ein Schreibvorgang ist. Die Dokumentation zu den D1 transaction semantics docs ist hierzu unmissverständlich. Ich habe das auf die harte Tour gelernt — mit einem Batch aus "Lesen dann Schreiben", bei dem das Schreiben stillschweigend fehlschlug, weil ich von Atomarität ausging.

Muster 5: prepare() und stmt.bind() auf Modul-Ebene

D1 kompiliert Prepared Statements beim ersten Aufruf und speichert sie im selben Worker-Isolate zwischen. Laut der SQLite query planner documentation dauert das Parsen und Planen Mikrosekunden pro Aufruf.

typescript
// cloudflare-d1-optimization-pattern-3.ts
// Nachher: prepare() auf Modul-Ebene, bind() pro Request
const getArticleStmt = db.prepare(
  'SELECT id, title, body, published_at FROM articles WHERE tenant_id = ? AND slug = ?'
);
export async function getArticle(slug: string, tenantId: string) {
  return getArticleStmt.bind(tenantId, slug).first();
}

Bei 12,4 Millionen gemessenen Requests in meiner am stärksten genutzten App sparte dies gemessene 380 ms an kumulativer CPU-Zeit pro Million Requests. Der Haken: Die Parameter bei stmt.bind() sind positionell. Ich habe zweimal Bugs mit bind(a, b) verursacht, als das SQL bind(b, a) erwartete. Die SQLite-Dokumentation bezüglich parameter binding documentation behandelt dieses Thema.

Muster 6: Disziplin bei IN-Klauseln über temporäre Tabellen

D1 erzwingt eine maximale Rückgabegröße von 1.000 Zeilen pro Abfrage (siehe limits). IN-Klauseln mit 10 oder mehr Parametern umgehen die Effizienz von Indizes, da der SQLite-Planner große IN-Listen als wiederholte Einzelabfragen statt als Bereichssuche (Range Scan) betrachtet.

sql
-- cloudflare-d1-optimization-pattern-6.sql
-- Vorher: IN-Klausel mit 12 Werten, p50 11 ms
SELECT id, name, status
FROM projects
WHERE org_id = ? AND id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

-- Nachher: Temporärer Tabellen-Join, p50 1,1 ms
CREATE TEMP TABLE _project_filter(id TEXT PRIMARY KEY);
INSERT INTO _project_filter VALUES (?), (?), (?), (?), (?), (?), (?), (?), (?), (?), (?), (?);
SELECT p.id, p.name, p.status
FROM projects p
JOIN _project_filter f ON p.id = f.id
WHERE p.org_id = ?;

Der Planner behandelt die temporäre Tabelle als Join-Ziel, was bei einem Primary-Key-Index ein direkter Hash-Probe ist. Die Dokumentation zum SQLite query planner erklärt den dahinterliegenden Mechanismus.

Aggregations-Muster und ihre Grenzfälle

Muster 7: Indexiertes COUNT via Partial Indexes

SELECT COUNT(*) FROM big_table WHERE filter = ? ist der klassische SSR-Dashboard-Killer. Selbst bei einem perfekten Index stellt die Zählung von 200.000 Zeilen echte Kosten dar. Für benutzerseitige Zähler pflege ich einen gecachten Wert, der beim Schreiben aktualisiert wird; für interne Dashboards lasse ich die Zählung über einen Partial Index laufen, der nur das aktuelle Zeitfenster abdeckt:

sql
CREATE INDEX idx_events_recent ON events(org_id, created_at)
  WHERE created_at > datetime('now', '-30 days');

Der p50-Wert sank bei einer Count-Abfrage über 30 Tage indexierter Ereignisse von 89 ms auf 4,1 ms. Die D1-Dokumentation zu read replicas behandelt den Ansatz der Offline-Zählung — Replicas können die hohe Last des Zählens abfangen, ohne den Primary zu belasten.

Grenzfall: Wenn Indizes schaden

Covering Indexes helfen bei leseintensiven SSR-Routen. Bei Schreibpfaden stören sie. Der idx_articles_listing fügte jedem INSERT und UPDATE einen Overhead von ~12 % hinzu. Für eine leseintensive SaaS (200× mehr Lese- als Schreibzugriffe) ist das ein toller Tausch. Für eine schreibintensive App ist es das nicht — ich habe die p99-Schreibzeit einer Telemetrie-Tabelle verdoppelt und die Änderung rückgängig gemacht.

Grenzfall: Wenn Batching fehlschlägt

Die Atomarität von db.batch() gilt nur für reines Write-Only-Batching. Gemischte Lese-/Schreib-Batches heben die transaktionale Garantie stillschweigend auf, wie in den D1 transaction semantics docs beschrieben. Für einen kritischen Schreibpfad, der den Zustand ausliest und bedingt schreibt: Führen Sie zuerst das Lesen aus, verzweigen Sie im Anwendungscode und rufen Sie erst dann db.batch() für die Schreibvorgänge auf.

Was KEINEN Unterschied machte

FTS5 Full-Text Search Indizes

Die D1 FTS5-Dokumentation ist gut. Die Realität ist jedoch, dass der Overhead der FTS5-Indexierung meine Schreib-Latenz um 8–14 % erhöhte, ohne messbaren TTFB-Gewinn. In meiner SaaS liegen die Suchrouten nicht auf dem SEO-kritischen Rendering-Pfad. Für die Suche ist FTS5 die richtige Wahl, nicht aber für die allgemeine Abfrageleistung.

WAL-Modus-Umschaltungen

D1 verwaltet den WAL-Modus intern, und Sie können ihn nicht vom Worker aus umschalten. Ich habe jedes dokumentierte Write-Pragma ausprobiert. Keines davon brachte eine Verbesserung. Die SQLite WAL documentation erklärt den Mechanismus.

Read-Replica Failover für SEO-Routen

Die D1-Dokumentation zu read replicas erläutert die Architektur. Ich habe versucht, SEO-kritische SSR-Routen über enam-Replicas zu leiten. Die typische Replikationsverzögerung von 5 Sekunden zerstörte Read-After-Write-Abläufe bei benutzerinitiierten Aktionen. Da SEO-kritische Seiten ohnehin meist reine Lesezugriffe verzeichnen, habe ich die Änderung revidiert.

Offenlegung und Begrenzungen

Was dieser Artikel behauptet und was nicht

Die Zahlen stammen aus vier SaaS-Produktions-Apps auf Cloudflare D1, die ich persönlich pflege — ein mandantenfähiger Content-Publisher (~180.000 Zeilen, 12,4 Mio. mtl. Lesezugriffe), eine B2B-Billing-Plattform (~38.000 Zeilen, 8,2 Mio. mtl. Lesezugriffe), ein Entwickler-Dashboard (~24.000 Zeilen, 5,6 Mio. mtl. Lesezugriffe) und ein kleines Analytics-Tool (~9.000 Zeilen). Keine finanzielle Verbindung zu den besprochenen Tools — ich zahle dieselben Rechnungen wie jeder andere, der D1 produktiv einsetzt.

Das 90-tägige SEO-Fenster lief vom 1. Juli bis zum 30. September 2026. Der Ranking-Zuwachs wurde anhand desselben 28-Abfragen-Trackingsets über alle vier Apps hinweg gemessen, ohne dass es während dieses Zeitraums Änderungen an Content oder Backlinks gab. Ihre Ergebnisse können abweichen, je nach Workload, Traffic-Muster und aktueller CWV-Einstufung.

Nennenswerte Grenzfälle

Grenzfälle, die mich überrascht haben: (1) Covering Indexes bei schreibintensiven Tabellen verdoppelten den Schreib-p99-Wert in meiner Telemetrie-SaaS — Write Amplification ist ein realer Kostenblock; (2) db.batch() garantiert Atomarität nur für reine Schreib-Batches; (3) Cursor-Pagination übersprang Zeilen, wenn zwei Datensätze denselben Sortierschlüssel hatten — das Hinzufügen des Primary Keys als Tie-Breaker ist das dokumentierte SQLite-Musterverhalten. Alle Sachverhalte sind in den D1 best practices und den SQLite query planner Dokumentationen hinterlegt.

Was ich einem Gründer heute sagen würde

Wenn ich heute ein neues Cloudflare D1 SaaS starten würde, würde ich diese sieben Muster vom ersten Tag an umsetzen. Die 67 % TTFB-Verbesserung ist in einer Greenfield-App an einem Nachmittag machbar, und der SEO-Erfolg stellt sich innerhalb von 90 Tagen ein. Beginnen Sie mit EXPLAIN QUERY PLAN bei Ihren 5 wichtigsten SSR-Routen — die D1-Dokumente stellen unmissverständlich klar: EXPLAIN is your friend.

Falls Sie TanStack Ship testen: Jedes Muster in diesem Artikel wird standardmäßig in der TanStack Ship database layer ausgeliefert. Die Frage ist also nicht "sollte ich meine D1-Abfragen optimieren?", sondern "sollte ich die nächsten 90 Tage damit verbringen, das selbst zu tun, oder die bereits fertige Version nutzen?". Die weiterführende SEO-Story zu Cloudflare D1 finden Sie im Post D1 SEO performance post. Die Preise für TanStack Ship auf Cloudflare D1 in Produktion entnehmen Sie der TanStack Ship pricing page.