Ob dieselbe Seite mit demselben Inhalt nach 20 Millisekunden oder erst nach drei Sekunden antwortet, hängt bei meinen Websites oft davon ab, ob sie schon im Cache liegt oder EmDash sie erst erzeugen muss.

Seit meiner ersten EmDash-Website beschäftigt mich dieser Unterschied. Ich habe einen Dienst zum Warmhalten gebaut, Fehler behoben, Datenbankabfragen eingespart und tagelang gemessen. Das hat die Situation verbessert, doch der erste Besucher nach einer Pause konnte noch immer Pech haben und eine, zwei oder sogar drei Sekunden warten.

Seit dem 8. November laufen drei meiner Websites deshalb anders: EmDash und seine Datenbank stecken zusammen in einem Durable Object von Cloudflare. Ungecachte Seiten kommen dort im weltweiten Median in 200 bis 450 Millisekunden statt in 700 bis 2800. An EmDash selbst habe ich dafür keine Zeile geändert.

Website · ungecachte HTML-Aufrufe, Median

Vorher: D1

Nachher: Durable Object

scllauerz.ch, weltweit

2837 ms

291 ms

scllauerz.ch, aus Singapur

3264 ms

256 ms

kevinkyburz.ch, weltweit, vorher bereits mit Placement und KV

726 ms

204 ms

linguadash.dev, weltweit

1184 ms

450 ms

Am deutlichsten ist der Sprung auf scllauerz.ch: Statt knapp drei Sekunden wartet man im weltweiten Median noch rund 0,3 Sekunden auf die Antwort. Das ist fast zehnmal schneller, ohne dass eine fertige Seite im Cache liegen muss. Auch kevinkyburz.ch und linguadash.dev legen zu, dort um den Faktor 3,6 beziehungsweise 2,6.

Für diesen Vergleich habe ich in den Cloudflare-Analytics nur erfolgreiche HTML-Aufrufe berücksichtigt, bei denen der Cache keine fertige Seite liefern konnte. Gezählt werden dabei sowohl Menschen als auch Bots. Die Werte vergleichen die sieben Tage vor dem Umzug mit den bisher erfassten Tagen danach und zeigen jeweils den Median, also die Mitte der Messwerte: Die Hälfte der Aufrufe war schneller, die andere langsamer. Wie sich das über Wochen hält, muss sich noch zeigen.

Dafür habe ich vor allem die Anwendung und ihre Datenbank näher zusammengebracht, die vorher bei jeder Seite aufeinander warten mussten.

Fast wäre es ein VPS geworden

Der Cache hält fertige Seiten bereit, die Cloudflare direkt ausliefern kann, ohne EmDash oder die Datenbank zu beschäftigen. Fehlt eine Seite, muss EmDash sie erst erzeugen. Das passiert nach einem Deploy oder einer Veröffentlichung ebenso wie bei selten gelesenen Unterseiten oder bei Besuchern aus einer Region, in der die Seite noch nicht bereitliegt.

Mit warmup.rocks habe ich relativ schnell einen Dienst gebaut, der Seiten regelmässig abruft, damit echte Besucher seltener auf einen leeren Cache treffen. In EmDash habe ich zuerst Fehler behoben, durch die Abfragen an D1 hängen blieben, und danach Datenbankabfragen eingespart: Ein Menü samt Einträgen braucht heute nur noch eine Abfrage, die Widgets ebenso. Mit Version 1.5 sollen auch die Bylines weniger brauchen.

Im Oktober habe ich ausserdem zehn Tage lang gemessen, wann der Cache leer ist. Weil Besucher trotz dieser Verbesserungen weiter warten mussten, nahm ich mir die letzten zwei Wochen Zeit, um die Ursache genauer zu untersuchen. Dabei habe ich verschiedene Ansätze ausprobiert und wieder verworfen, bis ich zwischendurch kurz davor war, meine Sites auf einen VPS zu verschieben: einen klassischen Server mit Cloudflare als Cache davor.

Weiter kam ich, als ich einen einzigen ungecachten Aufruf auseinandernahm. Am Abend des 7. November stand der erste Prototyp, und am nächsten Tag zog ich zuerst kevinkyburz.ch um, dann linguadash.dev und am Nachmittag scllauerz.ch. Die Seiten waren schnell, allerdings musste ich noch herausfinden, ob auch der Admin und grössere Last mit dem Aufbau zurechtkommen.

Warum EmDash länger wartet als rechnet

Die eigentliche Rechenarbeit für eine Seite dauerte in meinen Messungen 64 bis 159 Millisekunden, was eine Antwortzeit von zwei Sekunden nicht erklärte. Bei der Untersuchung zeigten sich vor allem zwei Stellen, an denen Zeit verloren ging.

Illustration: Die orange EmDash-Figur steht an einer Bushaltestelle, schaut auf ihre Uhr und hält eine halb fertige Webseite unter dem Arm. Eine lange Bahnstrecke windet sich bis zu einer fernen Datenbank am Horizont, auf der ein kleiner Datenbus unterwegs ist.
Ohne Cache wartet EmDash für jede Abfrage, bis der Bus aus der Datenbank zurück ist.

Jede Datenbankabfrage macht eine Reise

D1 ist Cloudflares verwaltete SQL-Datenbank und läuft in einem anderen Prozess als EmDash. Für jede Abfrage muss deshalb eine Anfrage übers Netz zur Datenbank und die Antwort wieder zurück.

Eine solche Abfrage dauerte in meinem Test ungefähr 17 Millisekunden. Für sich genommen ist das wenig, doch eine Seite braucht 14 bis 25 Abfragen für Einstellungen, Menüs, Kategorien, Inhalte, Widgets und Autorenangaben. Viele davon laufen nacheinander, weil erst eine Komponente ihre Daten holt und dann die nächste.

Durch diesen ständigen Austausch zwischen Anwendung und Datenbank summieren sich die kleinen Wartezeiten zu Hunderten Millisekunden, obwohl die Datenbank selbst nur kurz rechnen muss. Die Netzwerkwege kosten auch dann Zeit, wenn EmDash bereits läuft und keinen Kaltstart mehr braucht.

Der Worker muss erst aufwachen

EmDash läuft auf Cloudflare in einem Worker, also einer Ausführungsumgebung, die Cloudflare dort startet, wo Anfragen eintreffen. Wird sie nicht mehr gebraucht, kann sie wieder verschwinden, sodass EmDash beim nächsten Aufruf neu starten muss. In meinen Tests reichten dafür teilweise schon 30 Sekunden ohne Besuch.

Ein frisch gestartetes EmDash braucht ausserdem mehr Datenbankabfragen: Laut dem Query-Count-Snapshot im EmDash-Repository sind es auf der Startseite 20 statt 8 und bei einem Beitrag 26 statt 14. Die zusätzlichen Abfragen prüfen unter anderem Migrationen, laden Einstellungen und Schema und holen liegen gebliebene Hintergrundjobs zurück. Bevor der Besucher seine Seite erhält, muss die Anwendung also erst wieder arbeitsbereit werden.

Technischer Blick: Was beim Kaltstart Zeit kostet

Bei der Untersuchung habe ich diese Grössenordnungen beobachtet beziehungsweise geschätzt:

Schritt

Zeit

Paket am Standort bereitstellen und Ausführung starten

geschätzt 600 bis 1000 ms

EmDash-Module laden

ca. 250 ms

Erste Abfrage an D1

170 bis 330 ms

Seite rendern, inklusive weiterer Abfragen

150 bis 200 ms

Das sind Anhaltspunkte aus der Untersuchung, keine lückenlose Zerlegung jedes Aufrufs. Insgesamt lagen die gemessenen Kaltstarts bei 1,1 bis 2,4 Sekunden.

Ein wichtiger Teil davon ist das Anwendungspaket: In meinem Aufbau war das EmDash-Bundle rund 19 MB gross, komprimiert etwa 5 MB. Es muss an den Standorten verfügbar sein, an denen EmDash ausgeführt wird.

Die «Worker Startup Time» im Dashboard zeigte dagegen nur rund 20 Millisekunden. Sie erfasst nicht die gesamte Wartezeit, die beim ersten Seitenaufruf entsteht.

Für EmDash 1.5 wurde eine Änderung gemergt, die beim ersten Aufruf weniger Code lädt. Die Messungen im Pull Request zeigen allerdings noch keinen messbaren Gewinn beim ersten Aufruf einer neuen Worker-Instanz auf echten Workers. Einen belastbaren Grössenvergleich muss ich am Release prüfen; die bisher veröffentlichten Angaben haben offenbar unterschiedliche Messgrundlagen.

Die Netzwerkwege zu D1 bleiben von dieser Änderung ohnehin unberührt.

Warum der Cache nicht reicht

Lange ging ich davon aus, dass ich möglichst viele Seiten im Cache halten müsste, um das Problem ausreichend abzufangen. Die Cloudflare-Zahlen schienen das zu bestätigen: Auf scllauerz.ch waren nur 0,8 Prozent der HTML-Aufrufe ein Cache-Miss. In dieser Zahl stecken allerdings auch Bots und die regelmässigen Aufrufe von warmup.rocks, die auf den Hauptseiten viele Cache-Treffer produzieren.

Um herauszufinden, wie oft echte Menschen warten, messe ich deshalb mit CoreDash direkt im Browser.

In einem 31-Tage-Zeitraum, der noch fast vollständig auf D1 entfiel, erfasste CoreDash auf scllauerz.ch 979 Seitenaufrufe. Gemessen habe ich die Time to First Byte (TTFB), also die Zeit, bis das erste Byte der Seite im Browser ankommt:

Zeit bis zum ersten Byte

Anteil der Seitenaufrufe

Unter 250 ms

62 %

Mindestens 875 ms

26 %

Mindestens 1875 ms

14 %

Die beiden langsamen Gruppen überschneiden sich: Die Aufrufe über 1875 Millisekunden sind in denen über 875 Millisekunden enthalten.

Bei jedem vierten gemessenen Seitenaufruf verging also fast eine Sekunde bis zum ersten Byte, bei jedem siebten waren es fast zwei. Eine hohe Browser-TTFB beweist für sich genommen noch keinen Cache-Miss, weil darin auch DNS-Auflösung und Verbindungsaufbau stecken. Die langsamen Aufrufe lagen aber auffällig oft zwischen einer und zweieinhalb Sekunden und damit genau im Bereich meiner Messungen mit kaltem Worker und D1.

Auch bei Besuchern aus der Schweiz lagen rund 25 Prozent der Aufrufe über 875 Millisekunden. Selten besuchte Unterseiten, URLs mit Query-String, verdrängte Cache-Einträge und der geleerte Cache nach einer Veröffentlichung reichen dafür aus. Ein Warmer hilft dabei, kann aber nicht jede Seite an jedem Standort bereithalten.

Illustration: Ein freundlicher Crawler-Roboter mit acht Beinen, Stirnlampe, Stoppuhr und Klemmbrett steht vor einer vereisten, selten besuchten Webseite. Die orange EmDash-Figur rennt mit einem Arm voller Datenpakete herbei, im Hintergrund leuchten die beliebten Seiten warm.
Crawler fragen oft genau die Seiten ab, die niemand warm gehalten hat.

Google und KI-Crawler treffen genau diese Seiten

Suchmaschinen und KI-Crawler besuchen auch Seiten, die seit Tagen niemand gelesen hat. Sie treffen deshalb oft auf Seiten, die nicht mehr im Cache liegen.

In der Search Console von kevinkyburz.ch ist der Wechsel von WordPress zu EmDash deutlich zu sehen. Am 1. Oktober stieg die durchschnittliche Antwortzeit von 247 auf 646 Millisekunden.

Über die längeren Zeiträume sah es so aus:

Zeitraum

Plattform

Durchschnittliche Antwortzeit

12.8. bis 30.9.2026

WordPress

212 ms

1.10. bis 7.11.2026

EmDash auf D1

532 ms

Die durchschnittliche Antwortgrösse blieb mit rund 10 KB ähnlich. Grössere Seiten erklären den Anstieg also nicht. Am langsamsten D1-Tag, dem 2. November, lag die Antwortzeit im Schnitt bei 1031 Millisekunden.

Mit EmDash auf D1 wartete Google also mehr als doppelt so lange auf eine Antwort wie vorher mit WordPress. Weil Google dabei über alle Abrufe mittelt, also auch über Bilder und andere Dateien, sind die einzelnen Werte nur Richtwerte. Die Richtung ist aber eindeutig. Was sich nach dem Umzug geändert hat, zeige ich weiter unten.

Vier Versuche, die nicht weit genug gingen

Bevor ich mir eine eigene Architektur bauen wollte, habe ich geprüft, ob die vorhandenen Möglichkeiten reichen.

Versuch

Was er brachte — und was blieb

Migrationsprüfung zur Laufzeit abschalten

Mit migrations: { runtime: "manual" } sparte das rund 85 Millisekunden. Die langsame erste D1-Abfrage blieb, sie kam nur später.

Smart Placement und KV als Object-Cache

Smart Placement rückt den Worker näher an die Datenbank, KV hält fertige Abfrageergebnisse bereit. Ein laufender Worker konnte Seiten so ohne Datenbankabfrage liefern. Trotzdem waren 11 von 21 Testaufrufen kalt und brauchten 0,86 bis 1,72 Sekunden, teilweise direkt hintereinander.

Offiziellen Adapter durableObjects() verwenden

Mit dem Adapter aus @emdash-cms/cloudflare zog die Datenbank ins Durable Object, EmDash blieb im Worker. Damit blieben sowohl die Netzwerkwege pro Abfrage als auch die Worker-Kaltstarts.

Andere Cloudflare-Produkte einsetzen

Containers hätten Kaltstarts im Sekundenbereich und Kosten im Leerlauf mitgebracht. Hyperdrive war kein passender Ersatz für diese SQLite-Datenbank. KV konnte beim ersten Zugriff selbst kalt sein.

Mit diesen Ansätzen liessen sich einzelne Wartezeiten verkürzen, doch keiner beseitigte die Netzwerkwege und Worker-Kaltstarts zusammen.

Die Lösung: EmDash zieht zur Datenbank

Bisher lief EmDash im Worker und die Daten lagen in D1. Jede Abfrage musste deshalb den Weg zwischen beiden zurücklegen. Ein Durable Object bringt beides zusammen: Es ist eine Art kleiner Server für eine Website, der an einem festen Ort läuft und seine eigene SQLite-Datenbank mitbringt. Code und Daten liegen dort auf derselben Maschine.

Illustration: Die orange EmDash-Figur lehnt aus dem Fenster eines Doppelhauses mit leuchtendem Kristalldach und reicht einer freundlichen Datenbank-Figur im Nachbarfenster ein Datenpaket. Vor dem Haus stehen Umzugskisten, weiter hinten die verlassene Bushaltestelle und die lange Bahnstrecke zur alten Datenbank.
Statt quer durch die Stadt reicht EmDash seine Anfragen jetzt einfach ins Nachbarfenster.

Ich habe die Datenbank deshalb aus D1 in das Durable Object übernommen und EmDash mit hineingepackt. Eine Abfrage geht damit nicht mehr durchs Netzwerk, sondern bleibt im selben Prozess.

Davor steht ein kleiner Worker, der den Cache prüft und fertige Seiten direkt ausliefert. Findet er dort keinen Treffer, reicht er die Anfrage an das Durable Object weiter.

Das ändert drei Dinge:

  • Die Datenbankabfragen bleiben im selben Prozess. Aus Netzwerkreisen werden lokale Aufrufe im Mikrosekundenbereich. Nur die Anfrage vom Front-Worker zum Durable Object muss noch übers Netz. Dieser Weg kostete in meinem Test etwa 8 Millisekunden.
  • EmDash startet nicht mehr für jeden Besucherstandort getrennt. Alle ungecachten Aufrufe einer Website landen bei derselben Instanz. Auch die kann neu starten, aber der Kaltstart verteilt sich nicht mehr über viele Standorte.
  • An den Besucherstandorten startet nur der kleine Front-Worker. Im Prototyp war er in rund 25 Millisekunden bereit. Das grosse EmDash-Paket braucht er nicht.
text
Besucher
   │
   ▼
Kleiner Front-Worker
   ├─ Seite im Cache? → direkt ausliefern
   │
   └─ Kein Cache-Treffer
         │
         ▼
      Durable Object der Website
         ├─ EmDash erzeugt die Seite
         └─ SQLite liefert die Daten direkt vor Ort

In diesem ersten Versuch lag der Umbau um EmDash herum, nicht darin. Ich nutzte zwei Erweiterungspunkte, die EmDash schon heute anbietet: einen eigenen entrypoint für die Datenbank und einen eigenen cache.provider. Soll der Aufbau einmal fest zu EmDash gehören, muss sich auch EmDash selbst ändern. Mehr dazu am Schluss.

Der Datenbank-Dialekt leitet SQL direkt an ctx.storage.sql weiter. Der Cache-Provider sorgt dafür, dass beim Veröffentlichen die betroffenen Seiten aus dem Cache entfernt werden.

Im Labor: bis zu zehnmal schneller nach einer Pause

Bevor ich die erste Website umgestellt habe, habe ich den Aufbau ausgiebig im Labor getestet. Auf einer Kopie der EmDash-Demo mit denselben Daten lief er dreimal nebeneinander: mit D1, mit D1 plus Smart Placement und KV und im Durable Object. Die Aufrufe schickte ich aus Zürich mit curl und jeweils neuem Query-String am Cache vorbei.

Am meisten interessierten mich die Pausen. Ein Worker bleibt nach einem Aufruf nur kurz aktiv. Kommt danach eine Weile niemand, schläft er ein, und der nächste Besucher muss warten, bis er wieder gestartet ist und D1 erreicht hat. Genau das passiert auf einer kleinen Website ständig: Zwischen zwei Besuchern liegen oft Minuten.

Ein Durable Object verhält sich anders. Es läuft an einem festen Ort, alle Anfragen einer Website kommen dort an, und solange es etwas zu tun hat, bleibt es wach. Die Daten liegen ohnehin bei ihm. In meinen Tests half ich mit einem Timer alle 30 Sekunden und einem Alarm alle 20 Sekunden nach, damit es auch zwischen den Besuchern nicht einschläft.

Situation

D1

D1 mit Smart Placement und KV

Durable Object

Worker läuft schon

0,29–0,39 s

0,17–0,39 s

0,08–0,23 s

Erster Aufruf an einem neuen Standort

1,1–2,4 s

0,86–1,72 s

0,10–0,28 s

Nach 30 bis 180 Sekunden ohne Besucher

1,1–2,4 s

Kaltstarts ab 30 s beobachtet

0,12–0,16 s

Nach 5 / 10 Minuten ohne Besucher

1,19 / 1,28 s

—

0,21 / 0,13 s

Nach Pausen und an neuen Standorten brauchte das Durable Object 0,1 bis 0,3 statt 1,1 bis 2,4 Sekunden, nach zehn Minuten ohne Besucher 0,13 statt 1,28 Sekunden. Smart Placement und KV halfen nur, solange der Worker lief. Nach einer Pause war er wieder kalt.

Zum Schluss habe ich die Live-Website scllauerz.ch mit einer Testinstanz im Durable Object verglichen. Fünf Seiten mit je drei Aufrufen brauchten live 1,1 bis 3,0 Sekunden, in der Testinstanz 0,15 bis 0,35 Sekunden. Das sind einzelne Testreihen aus Zürich, keine Langzeitmessung.

Was Besucher davon merken

Die Cloudflare-Werte am Anfang zeigen bereits einen deutlichen Unterschied. Spannender ist aber, ob davon auch im Browser etwas ankommt.

Für scllauerz.ch meldet CoreDash:

Messwert

31 Tage vorher, überwiegend D1

Erste 24 Stunden nach Umstellung

24 Stunden bis 10.11., 20 Uhr

Zeit bis zum ersten Byte, p75

1025 ms

196 ms

296 ms

Grösster sichtbarer Seiteninhalt geladen, LCP p75

1412 ms

580 ms

840 ms

Anteil als schlecht eingestufter TTFB

15 %

7 %

0 %

Das 75. Perzentil, kurz p75, bedeutet: Drei Viertel der gemessenen Aufrufe waren mindestens so schnell wie dieser Wert. LCP beschreibt, wann der grösste sichtbare Seiteninhalt geladen ist.

Nach der Umstellung liegen alle Werte deutlich unter denen mit D1. In den letzten 24 Stunden kamen zwei Drittel der Aufrufe in weniger als 250 Millisekunden beim Browser an, keiner brauchte länger als 2,5 Sekunden. Ganz verschwunden sind langsame Aufrufe trotzdem nicht: Am Tag nach dem Umzug lagen noch drei darüber.

Für kevinkyburz.ch fehlt mir noch ein gleichwertiger Browser-Vergleich. Vorher habe ich ein weltweites p75 von 1247 Millisekunden, nachher einen Median von 124 Millisekunden aus der Schweiz. Unterschiedliche Regionen und unterschiedliche Kennzahlen ergeben keinen seriösen Beschleunigungsfaktor.

Und was sieht Google?

Nach der Umstellung kamen rund 90 Prozent der ungecachten Aufrufe von Bots, darunter warmup.rocks, Google, Bing und KI-Crawler. In den Cloudflare-Daten erhielten Google und Bing die Seiten in 284 bis 312 Millisekunden statt in 1,9 bis 4 Sekunden.

Auch die Search Console zeigt den Effekt, bisher allerdings erst an einem Tag. Ihr Export vom 10. November reicht bis zum 8. November, dem Tag des Umzugs. An diesem Tag wartete Google bei kevinkyburz.ch im Schnitt 69 Millisekunden auf eine Antwort, bei scllauerz.ch 148 Millisekunden. An keinem anderen EmDash-Tag war Google schneller.

Der Haken an einem gemeinsamen Ort: Es kann sich eine Schlange bilden

Wenn alle ungecachten Aufrufe bei einem Durable Object landen, stellt sich die nächste Frage fast von selbst: Wie viel verträgt das?

Am 9. November habe ich zuerst die Demo und danach kevinkyburz.ch unter Last gesetzt. Jeder ungecachte Aufruf bekam einen neuen Query-String. Die Tests liefen aus Zürich, jeweils 15 Sekunden pro Laststufe, bis zu 160 Anfragen gleichzeitig.

Illustration: Vor dem Doppelhaus mit leuchtendem Kristalldach steht eine lange, geduldige Schlange von Besuchern und Crawlern bis um die Strassenecke. Die orange EmDash-Figur reicht an einem kleinen Schalter in der Haustür eine fertige Seite nach der anderen hinaus, die Datenbank hilft im Nachbarfenster. Vorne rechts gibt ein heller Kiosk fertig verpackte Seiten ohne Wartezeit aus.
Gecachte Seiten gibt es sofort am Kiosk. Alles andere wartet am einen Schalter, an dem EmDash Seite für Seite erzeugt.

Das Ergebnis:

  • Die Demo schaffte ohne Cache rund 120 Seiten pro Sekunde.
  • kevinkyburz.ch kam mit den aufwendigeren echten Seiten auf rund 20 Seiten pro Sekunde.
  • In beiden Tests gab es keinen einzigen Fehler.

Obwohl keine Anfrage scheiterte, wurden die Antworten bei hoher Last deutlich langsamer, weil sich die Aufrufe vor der Verarbeitung stauten. Mit 160 gleichzeitigen Anfragen lag das 95. Perzentil auf der Demo bei rund 2,5 Sekunden, auf kevinkyburz.ch bei 11,4 Sekunden.

Wie viel der Aufbau verträgt, hängt vor allem von der Rechenzeit ab, die EmDash zum Erzeugen einer Seite braucht; die Grösse des fertigen HTML allein sagt darüber wenig aus. Mein vorheriger Überschlag von 20 bis 50 ungecachten Seiten pro Sekunde passte für kevinkyburz.ch nur am unteren Ende.

Gecachte Seiten betrifft das nicht. Sie kommen gar nicht bis zum Durable Object, sondern der kleine Front-Worker liefert sie direkt aus dem Cache aus. Eng wird es nur bei vielen gleichzeitigen Cache-Misses, etwa nach einem grossen Cache-Purge, einem Deploy oder einem Ansturm auf bisher unbesuchte Seiten. Die kurzen Tests mit einem Lastrechner sind allerdings keine Zusage für dauerhaften Betrieb unter dieser Last.

Was beim ersten erfolgreichen Seitenaufruf noch nicht fertig war

Über die schnelle Startseite habe ich mich gefreut, doch sie sagte noch wenig darüber aus, ob Migrationen, der Admin oder das nächste Update funktionieren würden. Bei den weiteren Tests stiess ich auf einige Probleme, die ich für einen verlässlichen Betrieb zuerst lösen musste.

Vorne darf wirklich nur der kleine Worker stehen

In der ersten Version steckte das gesamte 19-MB-Bundle weiterhin im Front-Worker, sodass Kaltstarts mit 1,3 bis 2,0 Sekunden praktisch genauso lange dauerten wie vorher. Neben der Datenbank am richtigen Ort brauchte es deshalb auch einen kleinen Front-Worker, der an vielen Standorten schnell starten kann.

Warmhalten war der Anfang, nicht das Ziel

In den ersten Tests kostete das Laden des Pakets 81 Millisekunden. Das erste Rendern brauchte aber 388 Millisekunden CPU-Zeit statt der 65 bis 120 Millisekunden im warmen Zustand.

Ein Cron oder Alarm im Minutentakt hielt die Instanz Anfang November nicht zuverlässig warm. Deshalb setzte ich zunächst auf einen setInterval und einen Alarm alle 20 Sekunden, der intern die Startseite renderte.

Am 9. November hat Cloudflare im Changelog angekündigt, dass ein Durable Object nach einer Pause nach Möglichkeit wieder auf der Maschine startet, auf der es zuletzt lief. Seine Daten müssen dann nicht neu geladen werden, und Kaltstarts sollen seltener werden. Wenn das in der Praxis reicht, braucht es meinen Timer gar nicht mehr.

Seit dem 10. November teste ich deshalb auf kevinkyburz.ch das Gegenteil: kein Keep-alive-Timer, kein künstliches Seitenrendern. Übrig bleibt ein Alarm pro Minute für Hintergrundjobs.

Fünf Cache-Misses, gemessen rund sieben Minuten nach einem weiteren Deploy, lieferten die vollständige Antwort im Median nach 118 Millisekunden. Bis zu den Response-Headern waren es 114 Millisekunden.

Das ist vielversprechend, aber noch kein Beweis dafür, dass Warmhalten generell überflüssig ist. Es handelt sich um eine kleine Browser-fetch-Stichprobe, nicht um Navigations-TTFB oder eine Woche Betrieb. Den allerersten Aufruf beim Versionswechsel habe ich nicht gemessen. Der Minutenalarm lädt weiterhin die Anwendung, und zwischen den Messungen gab es echten Verkehr. Einen vollständigen Kaltstart nach völliger Inaktivität habe ich damit nicht nachgewiesen. Ob die neue Platzierung von Cloudflare dahintersteckt, belegen meine Messungen ebenfalls noch nicht.

Ein Deploy braucht einen bewussten Ablauf

Im damaligen Dauer-Warmbetrieb brachte deferred nicht den erhofften Vorteil, weil der alte Code bis zum Ende von max_delay weiterlief, in meinem Fall fünf Minuten. Danach traf der Kaltstart doch wieder einen Besucher.

Ich habe deshalb auf immediate umgestellt. Dann wechselt das Durable Object sofort auf die neue Version. Direkt nach dem Deploy rufe ich die Seite einmal selbst auf, damit der langsame erste Aufruf mich trifft und nicht einen Besucher. Der Nachteil: Anfragen, die beim Wechsel gerade laufen, können abbrechen, etwa ein längerer Import oder ein Upload.

Ganz ohne Kaltstart geht es also auch mit dem Durable Object nicht. Er kommt aber seltener vor und dauert kürzer. Mit D1 startet EmDash nach einem Deploy oder einer Pause an jedem Besucherstandort neu, und jeder dieser Starts kostete in meinen Tests mehr als eine Sekunde. Im Durable Object startet EmDash nur an einem einzigen Ort, und auch dieser Start ging deutlich schneller.

Der Cache ist nicht automatisch weltweit gefüllt

Der Workers Cache verhält sich nicht wie der Zone-Cache. Die Cache Rules der Zone und Cache Reserve greifen hier nicht.

Auch ein Treffer in Frankfurt bedeutet nicht, dass die Seite überall bereitliegt. In meinen Tests gab es danach Treffer in Paris, Wien, Stockholm und Mailand, aber nicht in Tokio, Sydney oder São Paulo. Darauf würde ich keine garantierte Verteilung aufbauen.

Illustration: Eine kleine Weltlandschaft wie ein Diorama mit mehreren Cache-Kiosken. Neben dem voll bestückten Kiosk in Europa stellt die orange EmDash-Figur stolz die letzte fertige Seite ins Regal und blickt dann überrascht zu den fernen Kiosken bei Tokio, Sydney und São Paulo, deren Regale leer sind und an denen einzelne Besucher warten.
In Frankfurt liegt die Seite bereit, in Tokio, Sydney und São Paulo ist das Regal noch leer.

Der erste ungecachte Aufruf aus einer entfernten Region muss also bis zum Durable Object in Europa. Aus Tokio dauerte das rund 850 Millisekunden, aus São Paulo rund 940. Das war in meinen Tests noch immer schneller als D1 mit kaltem Worker dort, aber weit weg von den europäischen Bestwerten.

Cache-Treffer lagen weltweit im Median dagegen bei 6 bis 38 Millisekunden.

Die technischen Stolpersteine: Migrationen, Admin und Transaktionen

Interne Tabellen blockierten eine Migration

SQLite in einem Durable Object enthält interne Tabellen wie _cf_KV, auf die Zugriffe mit SQLITE_AUTH scheitern. Kysely, die von EmDash verwendete Datenbank-Abstraktion, stiess darauf, als es vor offenen Migrationen die Spalten aller Tabellen auslesen wollte.

Auf kevinkyburz.ch fiel das zunächst nicht auf, weil beim Umzug keine Migration offen war. Auf linguadash.dev scheiterte dagegen die Migration, woraufhin EmDash 30 Sekunden bis zum nächsten Versuch wartete. Die Seiten antworteten währenddessen zwar mit Status 200, blieben aber ohne Inhalt.

Ein eigener Introspector behebt das Problem, indem er Tabellen mit dem Präfix _cf_ auslässt. Ohne diesen Fix hätte auch kevinkyburz.ch beim nächsten EmDash-Update mit einer neuen Migration ausfallen können.

Eine geänderte Zeile wurde als zwei gezählt

Beim Admin-Test am 9. November scheiterte das Anlegen eines Feldes, weil mein Dialekt nach einem UPDATE den Wert rowsWritten meldete. Dieser zählt auch geschriebene Index-Einträge, sodass aus einer geänderten Zeile der Wert 2 wurde. EmDash erwartete genau 1 und brach deshalb ab.

Mit SELECT changes() erhält EmDash die passende Zahl. Im offiziellen Durable-Object-Adapter war derselbe Fehler bereits mit #3633 behoben worden.

Transaktionen brauchten einen eigenen Blick

Beim Löschen einer Sammlung traf ich auf eine direkte Transaktion, die ohne den Schutz des offiziellen Adapters zu einem Fehler 500 führte. Die Sammlung blieb bei «Löschen läuft» hängen. Für diesen Pfad übernimmt jetzt transactionSync die echte Transaktion.

Eine allgemeine Transaktionsunterstützung für den gesamten Dialekt ergibt sich daraus allerdings nicht: An anderen Stellen führt EmDash die Schritte über seinen Helfer withTransaction wie bei D1 nacheinander aus. Bricht eine solche Folge ab, können Änderungen unvollständig bleiben.

Was danach getestet war

Nach den Fixes funktionierten das Anlegen von Feldern und das Löschen einer Sammlung samt Inhalt. Auch Passkeys einrichten, ab- und wieder anmelden sowie Bilder hochladen, ausliefern und löschen liefen im Prototyp.

Auf kevinkyburz.ch habe ich zusätzlich per API einen vollständigen Ablauf geprüft: Sammlung und Feld anlegen, Inhalt erstellen, veröffentlichen und anschliessend wieder löschen.

Was es kostet, EmDash im Durable Object statt mit D1 zu betreiben

Die erste Variante hielt alle drei Durable Objects dauerhaft wach, was bei 128 MB rund 332'000 GB-Sekunden pro Monat und Website entspricht. Diese Einheit beschreibt den belegten Speicher über die Laufzeit; im Workers-Paid-Tarif sind davon 400'000 enthalten.

Eine dauerhaft warme Website würde damit in dieses Kontingent passen. Für drei ergäben sich rechnerisch rund 12.50 Dollar zusätzlich zum Grundpreis von 5 Dollar, ohne weitere Nutzung einzurechnen. Das ist eine Kalkulation für den bisherigen Dauer-Warmbetrieb und noch keine gemessene Monatsrechnung.

Mit dem Experiment auf kevinkyburz.ch fallen seit dem 10. November die künstlichen Seitenrenders und der Keep-alive-Timer weg. Ein Alarm pro Minute statt alle 20 Sekunden ergibt ausserdem 1'440 statt 4'320 geplante Alarme am Tag.

Zwei Drittel weniger Alarme bedeuten allerdings nicht zwei Drittel weniger Kosten. Entscheidend sind auch echter Verkehr, Hintergrundarbeit, abrechenbare Laufzeit und die übrige Nutzung des Kontingents. Was der Umbau tatsächlich spart, muss der Abrechnungsvergleich zeigen.

Was ich dafür aktuell aufgebe

Die Geschwindigkeit ist nicht gratis — auch dann nicht, wenn die Cloudflare-Rechnung überschaubar bleibt.

Ungecachte Aufrufe haben einen zentralen Anlaufpunkt. Wer aus Übersee kommt, muss bis nach Europa. Der Cache kann diesen Weg vermeiden, aber nur bei einem Treffer.

Admin und Besucher teilen sich dieselbe Rechenkapazität. Bei 40 gleichzeitigen ungecachten Frontend-Anfragen stieg der Median eines Admin-Aufrufs von 0,10 auf 1,6 Sekunden. Der höchste gemessene Wert lag bei 3,5 Sekunden. Ein Ansturm auf die Website kann also auch die Redaktion ausbremsen.

Der Umzug brauchte einen eigenen Export. wrangler d1 export scheiterte an den Tabellen der Volltextsuche, FTS5.

Die Wiederherstellung ist erst teilweise erprobt. Tägliche Exporte nach R2 sind eingerichtet. Dazu kommt die Möglichkeit von Durable Objects, auf einen Zeitpunkt innerhalb der letzten 30 Tage zurückzugehen. Das habe ich mit einem Testbeitrag ausprobiert: Die Wiederherstellung klappte, auch wenn Cloudflare dabei den Fehler 1101 meldet, weil das Durable Object absichtlich neu startet. Eine vollständige Wiederherstellung aus dem R2-Export habe ich dagegen noch nicht getestet. Auch das nächste nächtliche R2-Backup nach dem Ende des künstlichen Warmhaltens ist noch nicht live verifiziert. Mehr zur allgemeinen Sicherung steht in meinem Artikel über EmDash-Backups.

Der eigene Code bleibt meine Verantwortung. Datenbank-Dialekt, Cache-Provider und Front-Worker muss ich bei jedem EmDash-Update erneut prüfen.

Sandbox-Plugins funktionieren in diesem Aufbau nicht. Ihre Datenbrücke ist in EmDash fest an D1 gebunden. Ohne D1 können sie darüber weder Einstellungen speichern noch Inhalte lesen. Das ist kein kleiner Schönheitsfehler, sondern für entsprechende Websites ein Ausschlussgrund. kevinkyburz.ch und scllauerz.ch verwenden keine Sandbox-Plugins.

Vor allem die letzten Punkte machen den Unterschied zwischen einem erfolgreichen Versuch und einer Lösung, die ich anderen zum bedenkenlosen Einbauen empfehlen würde.

Für wen sich der Aufwand lohnt

Wenn deine Besucher fast alle aus einer Region kommen, deine Seiten dort zuverlässig im Cache liegen und du selten veröffentlichst oder deployst, würde ich bei D1 bleiben. Es ist einfacher zu betreiben und bietet mit Time Travel einen integrierten Wiederherstellungsweg; allein wegen dieser Messwerte würde ich deshalb nicht umbauen.

Wer Sandbox-Plugins braucht, kann meinen Aufbau derzeit ohnehin nicht verwenden. Dazu kommt die Verantwortung für die eigenen Infrastruktur-Erweiterungen, deren Migrationen und Wiederherstellung man prüfen können muss.

Interessant wird der Ansatz eher, wenn mehrere dieser Punkte zutreffen:

  • Deine Website hat wenig Verkehr, sodass Worker zwischen zwei Besuchen auskühlen.
  • Besucher kommen aus unterschiedlichen Regionen, in denen der Cache noch leer ist.
  • Du veröffentlichst oder deployst häufig und musst den Cache danach neu füllen.
  • Suchmaschinen und andere Crawler rufen viele selten gelesene Seiten ab.
  • Deine Seiten benötigen viele Datenbankabfragen, etwa für Menüs, Widgets und Übersetzungen.

Auf meine drei Websites trifft vieles davon zu. Der Weg aus Übersee nach Europa bleibt dabei ein Nachteil, allerdings wartet der Besucher am Ziel nicht zusätzlich auf einen grossen Worker-Kaltstart und eine ganze Reihe einzelner Netzwerkabfragen zur Datenbank.

Was als Nächstes kommt

Nach meinem Post auf X wollten viele wissen, ob dieser Aufbau in EmDash selbst landen wird. Mit Matt Kane habe ich ihn bereits intern besprochen, den Code veröffentliche ich derzeit aber nicht: Es ist ein eigener Versuch mit klaren Einschränkungen und noch kein fertiger Adapter zum Einbauen.

Für EmDash wäre das eine Architekturänderung. Deshalb habe ich einen offiziellen Vorschlag eingereicht, einen RFC. Bevor daraus etwas in EmDash wird, braucht es eine Design-Prüfung und Antworten auf die offenen Betriebsfragen. Vor allem die fest an D1 gebundene Datenbrücke für Sandbox-Plugins muss gelöst werden. Mitdiskutieren kannst du in der Diskussion auf GitHub.

Währenddessen arbeite ich am nächsten Schritt: Lesekopien der Datenbank in anderen Regionen. Dann müssten ungecachte Aufrufe aus Asien oder Australien nicht mehr bis nach Europa.

Erster Versuch mit regionalen Lesekopien

Getestet habe ich das am 9. November. Weil sich die eingebaute Read-Replication von Cloudflare noch nicht deployen liess, habe ich eigene Kopien gebaut. Im warmen Zustand sparten sie in Singapur und Sydney 75 bis 210 Millisekunden pro ungecachtem Aufruf.

Allerdings brauchte der erste Aufruf ungefähr eine Sekunde, und nach einer Veröffentlichung blieb jede Kopie bis zur nächsten Synchronisierung veraltet. Ich habe die Testkopien danach wieder geleert, denn trotz der kürzeren Wege bleibt es eine eigene Aufgabe, aktuelle Inhalte zuverlässig über mehrere Regionen zu verteilen.

Zuerst will ich deshalb die Grundlagen absichern: längere Browser-Messreihen, einen vollständigen Zeitraum in der Search Console, die tatsächlichen Kosten ohne künstliches Warmhalten und eine getestete Wiederherstellung aus dem R2-Export.

In meinen bisherigen Messungen steckte der grösste Teil des Wartens in den Netzwerkwegen und Kaltstarts rund um die eigentliche Seitenberechnung. Mit derselben Anwendung in einem anderen Aufbau sank der weltweite Median ungecachter HTML-Aufrufe auf scllauerz.ch von 2837 auf 291 Millisekunden. Auf den beiden anderen Websites fällt der Gewinn kleiner aus, ist aber ebenfalls deutlich.

Dass sich solche Ursachen finden und verbessern lassen, bestärkt mich darin, als Maintainer an EmDash mitzuarbeiten, auch wenn noch nicht alles schnell oder fertig ist. Gemeinsam mit den Menschen, die Fehler melden, messen und Pull Requests schreiben, können wir daran weiterarbeiten.

Ich wollte eigentlich nur herausfinden, warum der erste Besucher so lange warten muss. Jetzt wartet er auf meinen drei Websites deutlich kürzer, und wir wissen genauer, woran wir als Nächstes arbeiten müssen.