Ein Blog wird fast nur gelesen. Jeder anonyme Aufruf von theweeklydash.com bekommt dieselbe Seite, also muss der Worker sie nicht jedes Mal neu rendern und dafür D1 fragen. Cloudflare kann sie am Edge zwischenspeichern, im Rechenzentrum nahe beim Leser.
Den Haken kennt jeder, der schon mal einen Cache vor ein CMS gestellt hat. Du korrigierst einen Fehler im Artikel, und die Leser sehen weiter die alte Fassung. Schnell und aktuell ziehen eben in verschiedene Richtungen. Bei uns gelten deshalb drei Regeln: Ein veröffentlichter Fix ist sofort sichtbar, Redakteure behalten auch auf gecachten Seiten ihren Edit-Button, und ein Aufruf von /wp-login.php kostet keine Datenbankabfrage.
Ich zeige dir, wie theweeklydash.com das löst, mit dem echten Code aus unserem Repository. Erst klären wir, was überhaupt in den Cache darf, dann kommen die drei Regeln der Reihe nach. Geprüft habe ich alles gegen EmDash 1.1.0, Astro 7.3 und @astrojs/cloudflare 14.3.
Was an den Edge darf und was nicht
Der Cache ist der Workers Cache von Cloudflare. Er sitzt vor dem Worker. Liegt dort schon eine passende Antwort, läuft der Worker gar nicht erst an. Welche Seiten wie lange im Cache bleiben, legst du in Astro über Route Rules fest. Der Cache-Provider cacheCloudflare() macht daraus Header, die Cloudflare versteht:
// astro.config.mjs (Auszug)
cache: { provider: cacheCloudflare() },
routeRules: {
"/": { maxAge: 300, swr: 86400 },
"/en": { maxAge: 300, swr: 86400 },
"/archiv": { maxAge: 300, swr: 86400 },
// Posts and pages.
"/[slug]": { maxAge: 300, swr: 86400 },
"/en/[slug]": { maxAge: 300, swr: 86400 },
// Rules match paths, not routes, so the catch-all rules above would also cover these.
// A rule with tags only and no maxAge keeps them out of the edge cache.
"/search": { tags: [] },
"/en/search": { tags: [] },
"/robots.txt": { tags: [] },
"/404": { tags: [] },
"/rubrik/[slug]": { maxAge: 300, swr: 86400 },
// … topics, authors and the English listings alike
"/_image": { maxAge: 300, swr: 2592000 },
"/og/[locale]/[slug].png": { maxAge: 300, swr: 2592000 },
"/rss.xml": { maxAge: 300, swr: 86400 },
"/sitemap.xml": { maxAge: 3600, swr: 86400 },
},Startseiten, Archiv, Artikel, Rubriken, Themen und Autorenseiten sind fünf Minuten frisch (maxAge: 300). Danach liefert der Edge bis zu einen Tag lang die alte Fassung aus und rendert im Hintergrund neu (swr: 86400, stale-while-revalidate). Von den beiden Werten halte ich swr für den wichtigeren. Der Cache hat zwar zwei Stufen: Fehlt die Seite im Rechenzentrum beim Leser, springt ein übergeordnetes ein. Aber auf einer kleinen Site bekommt ein einzelner Artikel oft stundenlang keinen einzigen Aufruf. Mit kurzem swr würde dann fast jeder erste Leser auf ein komplettes Rendering warten.
Bilder und Vorschaubilder für geteilte Links bekommen 30 Tage swr, weil sie neu zu rendern teuer ist. Feeds laufen wie die Seiten, und die Sitemaps kommen mit einer Stunde aus, ganz ohne Purge.
Die Kommentarzeilen in der Mitte markieren die Falle, in die wir fast getappt wären. Route Rules vergleichen Pfade, nicht Routen. /[slug] passt deshalb auch auf /search und /robots.txt. Eine Regel, die nur tags setzt und kein maxAge, holt die beiden wieder heraus.
Ein paar Dinge lassen wir bewusst draußen:
- Suche. Jede Suchanfrage hat ihre eigene URL. Tausend Varianten zu speichern, die fast nie zweimal kommen, lohnt sich für mich nicht.
- Adminbereich und API. Hier schickt EmDash selbst
private, no-store, und für/_emdash/haben wir keine Route Rule. - Vorschau und Bearbeiten. Hinter
?_preview=stecken unveröffentlichte Entwürfe, hinter?_edit=der Editor-Modus. Beides hat in einem geteilten Cache nichts verloren. - Alles außer GET und HEAD, dazu Redirects und Fehlerseiten. Die Ausnahme sind 404s, dazu unten mehr.
Cache-Tags: damit ein Fix sofort sichtbar ist
Fünf Minuten klingen kurz, bis du einen Tippfehler im Titel entdeckst. Deshalb hängen an jeder Antwort Cache-Tags, und beim Veröffentlichen purgt EmDash genau die Tags, die betroffen sind.
Die Tags kommen aus den Abfragen. getEmDashEntry() und getEmDashCollection() liefern neben den Daten einen cacheHint, und den reichst du im Frontmatter der Seite an Astro.cache.set() weiter:
---
// src/pages/[slug].astro (Auszug)
const found = await findEntry("de", Astro.params.slug);
if (!found) return Astro.rewrite("/404");
if (Astro.cache?.enabled) Astro.cache.set(found.result.cacheHint);
---Ein einzelner Eintrag bekommt seine ULID als Tag, eine Liste den Namen der Collection, bei uns also posts. Entscheidend ist der Ort. Der Aufruf gehört ins Frontmatter der Seite und nicht in ein Layout oder eine Komponente, denn die rendern erst im Stream, wenn Astro die Header längst geschrieben hat. Ein cache.set() dort geht nach dem ersten await verloren, und die Seite wird gar nicht gecacht.
Navigation, Einstellungen und Rubriken stehen auf jeder Seite. Ändert jemand das Menü, müssen also alle Seiten aus dem Cache. Dafür hat EmDash eigene Varianten mit Cache-Hint, und unsere Middleware registriert sie vor jedem gecachten Rendering:
// src/middleware.ts (Auszug)
const dependencies = await Promise.all([
getSiteSettingsWithCacheHint(),
getMenuWithCacheHint("primary", { locale }),
getTaxonomyTermsWithCacheHint("category", { locale, includeCounts: false }),
getTaxonomyTermsWithCacheHint("tag", { locale, includeCounts: false }),
]);
for (const dependency of dependencies) context.cache.set(dependency.cacheHint);Die einfachen getMenu() und getSiteSettings() liefern keinen Hint. Wer sie auf gecachten Seiten verwendet, sieht eine Menüänderung erst, wenn die Cache-Zeit abgelaufen ist.
Gepurgt wird in den API-Routen, die der Adminbereich aufruft, also beim Veröffentlichen, Zurückziehen, Planen, Löschen, Wiederherstellen und Verwerfen eines Entwurfs. Menüs, Einstellungen, Taxonomien und Widget-Bereiche purgen ihre eigenen Tags. Der Provider ruft dafür cache.purge() aus cloudflare:workers auf und kommt ohne Zone-ID und API-Token aus.
Nicht gepurgt wird, wenn ein veröffentlichter Beitrag automatisch gespeichert wird, und das ist Absicht. Die Änderung landet im Entwurf, öffentlich hat sich noch nichts getan. Erst Änderungen veröffentlichen löst den Purge aus. SQL direkt gegen D1 purgt gar nichts, deshalb ändern wir Inhalte nur im Adminbereich.
Wenn der Purge so zuverlässig arbeitet, könnten die Seiten eigentlich einen ganzen Tag im Cache liegen. Ich bleibe trotzdem bei fünf Minuten, weil auch ein Purge mal ausbleiben kann. Der Workers Cache hängt am Worker und nicht an der Zone, deshalb gelten für ihn immer die Purge-Limits des Free-Plans, also fünf Anfragen pro Minute mit Puffer für 25. Lehnt Cloudflare einen Purge ab, steht das im Rückgabewert, und den wertet der Astro-Provider nicht aus. Für eine Redaktion aus zwei Leuten reicht das Limit locker. Geht trotzdem mal etwas schief, setzt maxAge die Obergrenze: fünf Minuten plus ein Aufruf, der noch die alte Fassung bekommt und das Neu-Rendern anstößt.
Zwei Header: einer für den Edge, einer für den Browser
Der Purge erreicht allerdings nur den Edge, nicht den Browser. Deshalb schickt jede Seite bei uns zwei verschiedene Anweisungen:
Cloudflare-CDN-Cache-Control: public, max-age=300, stale-while-revalidate=86400
Cache-Control: public, max-age=0, must-revalidateDen ersten liest nur Cloudflare, beim Browser kommt er gar nicht an. Den zweiten liest der Browser. Er darf die Seite behalten, muss aber bei jedem Besuch nachfragen, ob sie noch stimmt. Würde der Browser das HTML fünf Minuten selbst cachen, käme kein Purge an ihn heran. Und nach einem Deploy würde die alte Seite auf CSS-Dateien verweisen, die es nicht mehr gibt.
Am Anfang haben wir dem Browser no-store geschickt. Das war sicher, hatte aber zwei Nachteile. Die Seiten landeten nicht im Back/Forward-Cache, und Astros Link-Prefetch lief ins Leere, weil der Browser eine no-store-Antwort nicht bis zum Klick aufheben darf. Mit max-age=0, must-revalidate klappt beides. Eine veraltete 304-Antwort nach einem Deploy fängt EmDash ab, weil es die Build-Zeit in Last-Modified einrechnet.
Gesetzt werden die Header in einer eigenen Middleware. Ist eine Antwort nicht für alle gleich oder ist ihr Status nicht 200, bekommt sie no-store und bleibt aus dem Cache. Normale HTML-Seiten bekommen die Browser-Variante von oben:
// src/cache-response.ts (Auszug)
const personalized = !!context.locals.user;
const shared = !privateView && !personalized && ["GET", "HEAD"].includes(context.request.method);
if (shared && response.status === 404 && CATCH_ALL_ROUTES.has(context.routePattern)) {
// A slug with nothing published under it, see below.
} else if (!shared || response.status !== 200) {
context.cache.set(false);
response.headers.set("Cache-Control", personalized ? "private, no-store" : "no-store");
} else if (response.headers.get("Content-Type")?.startsWith("text/html") && !response.headers.has("Cache-Control")) {
response.headers.set("Cache-Control", "public, max-age=0, must-revalidate");
}Die Middleware muss nach EmDash zum Zug kommen, weil EmDash den HTML-Stream noch anfasst, etwa für die Toolbar. Deshalb registrieren wir sie als middleware.outer in der EmDash-Integration. Das klingt erst mal verdreht, denn die äußere Middleware startet vor EmDash. Nach await next() hält sie aber die fertige Antwort in der Hand.
Redakteure behalten ihren Edit-Button
Damit sind die Leser versorgt. Die zweite Regel gilt denen, die die Artikel schreiben. Der Workers Cache läuft vor dem Worker und schaut sich keine Cookies an, ein eingeloggter Redakteur bekommt also dieselbe gecachte Seite wie alle anderen. Die EmDash-Doku sagt das ganz offen. Mit der Standard-Toolbar fehlt dem Redakteur seine Toolbar, wenn gerade ein anonymer Besucher den Cache gefüllt hat.
Die Lösung ist eine Zeile in astro.config.mjs, nämlich toolbar: "client":
emdash({
toolbar: "client",
middleware: { outer: "./src/outer-middleware.ts" },
// …
}),Im Client-Modus bekommen alle Besucher dasselbe HTML. Ein kleines Skript schaut im localStorage nach einem Merker, den der Adminbereich setzt, sobald sich jemand in diesem Browser angemeldet hat. Findet es ihn, zeigt die Seite unten einen Edit-Button. Ein Klick fragt beim Server nach, ob die Sitzung noch gilt, und lädt die Seite dann mit ?_edit=1 neu. Diese URL rendert EmDash immer frisch, mit voller Toolbar und private, no-store. Anonyme Leser kostet das einen Blick in den localStorage.
Umgekehrt darf nichts Persönliches in den Cache. Rendert der Worker eine Seite für einen eingeloggten Nutzer, stehen im Kommentarformular Name und E-Mail. Solche Antworten bekommen private, no-store und cache.set(false). Auf der Live-Site habe ich das mit einer frischen, noch nie aufgerufenen URL und Admin-Sitzung geprüft: dreimal hintereinander cf-cache-status: BYPASS. Eine Seite, die schon im Cache liegt, bekommst du dagegen auch eingeloggt als HIT.
404s ohne Datenbank
Bleibt die dritte Regel, und die hat mit Lesern gar nichts zu tun. Jede öffentliche Site bekommt Besuch von Bots, die nach /wp-login.php, /.env oder /xmlrpc.php suchen. Bei uns liegen die Artikel direkt unter der Wurzel, /<slug> und /en/<slug>. Ohne Vorsorge würde jeder dieser Aufrufe EmDash starten und D1 nach einem Beitrag fragen, den es nicht geben kann.
Deshalb prüft die äußere Middleware den Slug, bevor EmDash anläuft. Ein Slug, den kein Eintrag haben kann, bekommt sofort eine schlichte 404. Unmöglich ist ein Slug, der nicht so aussieht, wie EmDash Slugs erzeugt, also mit Punkt, Großbuchstabe, doppeltem Bindestrich oder mehr als 120 Zeichen. Dasselbe gilt für Namen, die schon an eine Route vergeben sind, etwa archiv oder rss.xml.
// src/outer-middleware.ts (Auszug)
if (CATCH_ALL_ROUTES.has(context.routePattern)) {
const slug = decodeSlugParam(context.params.slug);
if (!slug || slugIssue(slug)) return invalidSlug(context);
}
function invalidSlug(context: APIContext): Response {
if (context.cache?.enabled) {
context.cache.set(false);
context.cache.set({ maxAge: INVALID_SLUG_MAX_AGE }); // 3600
}
// …
return new Response(notFoundPage(locale), { status: 404, headers });
}Diese 404 kommt ohne Layout aus, denn für Menü und Einstellungen müsste sie die Datenbank fragen. Sie bleibt eine Stunde im Cache. Ändern kann sich die Antwort ohnehin nur mit neuem Code, und jeder Deploy fängt mit leerem Cache an. Auf der Live-Site liefert /wp-login.php eine 404 mit cf-cache-status: HIT. Der Bot holt sich seine Abfuhr also direkt am Edge ab, und der Worker merkt davon nichts.

Anders liegt der Fall bei einem Slug, der gültig aussieht, unter dem aber nichts veröffentlicht ist, etwa /edge-caching-teil-2. Hier sind wir wieder bei der ersten Regel, denn unter genau dieser Adresse kann morgen ein Artikel erscheinen. EmDash fragt die Datenbank und rendert die normale 404-Seite. Die bleibt nur 60 Sekunden im Cache und trägt die Tags posts und pages:
// src/cache-response.ts (Auszug)
if (shared && response.status === 404 && CATCH_ALL_ROUTES.has(context.routePattern)) {
context.cache.set(false);
context.cache.set({ maxAge: MISSING_ENTRY_MAX_AGE, tags: ["posts", "pages"] }); // 60
response.headers.set("Cache-Control", "public, max-age=0, must-revalidate");
}Wird unter dem Slug später etwas veröffentlicht, purgt EmDash den Tag posts, und die 404 ist weg. Die 60 Sekunden sind nur die Obergrenze für den Fall, dass dieser Purge ausbleibt. Damit niemand einen Artikel unter archiv veröffentlicht und so die Route verdeckt, verweigert ein kleines Site-Plugin das Veröffentlichen reservierter oder ungültiger Slugs.
Geplante Beiträge: veröffentlicht ohne Klick
Bisher hing jeder Purge an einem Klick im Adminbereich. Geplante Beiträge erscheinen dagegen ohne Klick. Auf Cloudflare veröffentlicht sie der Cron Trigger, und unser Worker läuft mit */5 * * * *. Ein Beitrag für 9:00 Uhr erscheint also spätestens um 9:05 Uhr. Diese Verzögerung kommt vom Cron, nicht vom Cache.
Laut Quellcode purgt der Cron danach auch. Der scheduled-Handler aus @emdash-cms/cloudflare 1.1.0 holt sich den Cache-Provider über das Astro-Manifest. Nach jeder Collection, in der er etwas veröffentlicht hat, purgt er den Tag der Collection und die IDs der neuen Einträge. Das sind dieselben Tags wie beim Klick auf Veröffentlichen. Unser src/worker.ts übernimmt diesen Handler mit createScheduledHandler() unverändert. Schreibst du dir einen eigenen, fehlt dieser Purge, solange du ihn nicht nachbaust.
Beobachtet habe ich das auf der Live-Site noch nicht. Fällt dieser Purge aus, greifen die Sicherheitsnetze von oben. Startseite und Listen zeigen den Beitrag spätestens nach fünf Minuten plus einem Aufruf, und eine gecachte 404 für seine URL hält höchstens eine Minute. Ob das alles so klappt, verraten die Header.
So prüfst du es
Auf der Live-Site zeigt cf-cache-status, was passiert ist. Ruf die Seite zweimal hintereinander ab:
curl -sI https://theweeklydash.com/ | grep -i -E 'cf-cache-status|^age|cache-control'Der erste Aufruf nach einem Deploy oder Purge zeigt MISS, der zweite HIT und dazu einen age-Header. Sind die fünf Minuten um, siehst du UPDATING oder STALE, das ist stale-while-revalidate bei der Arbeit. BYPASS bedeutet, dass der Worker gelaufen ist und die Antwort nicht in den Cache durfte.
Den Header Cloudflare-CDN-Cache-Control siehst du auf der Live-Site nicht, Cloudflare entfernt ihn vor der Auslieferung. Die eigentliche Prüfung läuft deshalb lokal gegen den gebauten Worker:
pnpm build
pnpm exec wrangler dev --config dist/server/wrangler.json --port 4322
node scripts/check-cache.mjs http://localhost:4322scripts/check-cache.mjs prüft 14 öffentliche Seiten in beiden Sprachen auf Header, Tags und Client-Toolbar. Dazu kommt alles, was ungecacht bleiben muss, also Suche, Adminbereich, Vorschau, Edit-URL und POST, außerdem die beiden Arten von 404, die alten Redirects, Feeds, Sitemaps und Vorschaubilder. Legst du eine neue öffentliche Seite an und vergisst ihre Route Rule, fällt dir das hier auf und nicht erst am Ladeverhalten.
Messwerte für Hit und Miss von der Live-Site reiche ich nach dem Launch nach.
Für diese Zahlen gilt eins schon jetzt: Der Workers Cache nimmt die Version des Workers in den Cache-Schlüssel auf. Nach jedem Deploy ist der Cache also leer, und die ersten Leser zahlen das volle Rendering, inklusive aller D1-Abfragen. Wie schnell ein Miss ist, hängt deshalb weiterhin davon ab, wo die Datenbank steht.
Unsere steht in Westeuropa, und Read Replication ist eingeschaltet. Mit d1({ session: "auto" }) liest EmDash für anonyme Besucher aus der nächstgelegenen Kopie, ein Leser in Übersee wartet also nicht bei jeder Abfrage auf den Weg nach Europa. Warum der Standort so viel ausmacht und was du tust, wenn er nicht stimmt, erkläre ich im Artikel zur D1-Region.
Fazit
Edge-Caching mit EmDash auf Cloudflare beginnt mit ein paar Zeilen Konfiguration. Die Arbeit steckt in den Ausnahmen. Damit ein Fix sofort sichtbar ist, gehören die Cache-Hints ins Frontmatter der Seite, Menü und Einstellungen brauchen die WithCacheHint-Varianten, sonst purgt nichts, und der Browser bekommt einen anderen Header als der Edge. Weil Route Rules Pfade vergleichen, musst du Suche und Co. ausdrücklich herausnehmen. Und toolbar: "client" ist auf einer gecachten Site Pflicht, sonst hängt der Edit-Button davon ab, wer zuerst da war. Für die Bots reicht ein Blick auf den Slug.
Damit zurück zum Titel. Die fünf Minuten am Edge sind bei uns nicht die Zeit, bis ein Fix sichtbar wird, das erledigt der Purge beim Veröffentlichen. Sie sind nur die Obergrenze für den Fall, dass ein Purge ausbleibt.

Kommentare
No comments yet
Erstkommentare erscheinen nach unserer Freigabe. Was mit deinen Angaben passiert