Cloudflare KV Postmortem: Veraltete Kampagnen-URL 11 Minuten nach Launch ausgeliefert

Eine veränderbare Cloudflare Workers KV-Weiterleitung hat 327 Klicks an ein ausgemustertes Angebot geleitet. Die Versioned-Key- und D1-Source-of-Truth-Fix, die TanStack Ship im ShortShip-Modul standardmäßig ausliefert.

Huifer
Huifer
8. August 202610 min read
Auch verfügbar auf:English

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Im Juni 2026 habe ich um 09:04 UTC eine ShortShip-Kampagnen-Shortlink-Weiterleitung geändert, aber Edge-Reads in drei Regionen haben weiterhin die vorherige Landingpage ausgeliefert. Über 11 Minuten wurden 327 von 3.742 getrackten Klicks an das ausgemusterte Angebot geleitet – die Anmelderate dieser Kohorte sank von 7,1 % auf 3,8 %. Ich habe veränderbare KV-Werte durch versionierte Redirect-Keys und eine D1-Source-of-Truth ersetzt. Das ist das Postmortem, das ich 48 Stunden nach dem Fix geschrieben habe, während die Traces noch frisch waren.

Verifizierte Quellen: Cloudflare KV Documentation · Cloudflare D1 Documentation · Cloudflare Workers Documentation · Cloudflare Workers KV Best Practices · TanStack Start Documentation · TanStack Router Documentation · TanStack Ship GitHub Organization Letzte Aktualisierung: 2026-08-08 · Changelog


TL;DR: Eine ShortShip-artige Kampagnen-Shortlink-Weiterleitung am Edge wurde direkt verändert. Cloudflare KV-Replikation ist eventual konsistent, daher haben Edge-Reads in drei Regionen die vorherige Landingpage 11 Minuten lang ausgeliefert. Die Kohorte traf mit 327 von 3.742 Klicks auf das ausgemusterte Angebot; die Anmelderate sank von 7,1 % auf 3,8 %, bevor Edge-Reads konvergierten. Der Fix sind versionierte Redirect-Keys mit D1 als autoritativem Kampagnen-Datensatz. TanStack Ships ShortShip-Modul liefert dieses Pattern nun als einzig verfügbare Schreibpfad-Variante aus.


Hintergrund

Die betroffene Bereitstellung betreibt einen ShortShip-artigen Shortlink-Service für Marketingkampagnen. Jeder Short-Code (z. B. /s/pricing-june) wird auf eine lange Ziel-URL abgebildet und speichert Kampagnenanalysen. Der Read-Path ist ein Workers-Request-Handler, der Folgendes tut:

  1. Liest den Short-Code aus dem URL-Pfad.
  2. Schlägt das Ziel in Cloudflare KV am Edge nach (SHORT_LINKS-Namespace).
  3. Schreibt ein Analytics-Event in D1 mit dem Source-Code, dem Ziel und dem User-Agent.
  4. Gibt einen 302-Redirect zum Ziel zurück.

Die Bereitstellung hält außerdem eine D1-Tabelle — campaign_redirects — als Source of Truth für Kampagnen-Metadaten.

Zum Zeitpunkt des Vorfalls bediente die Bereitstellung ca. 3.742 getrackte Klicks über zwei Kampagnen in einem 30-Minuten-Fenster. Der pricing-june-Short-Code war der aktivste mit ca. 327 Klicks pro Minute zu Spitzenzeiten. Die Bereitstellung lief auf dem Workers-Paid-Plan mit KV-Replikation über alle 300+ Cloudflare-Rechenzentren. KV-Reads nach einem Write konvergierten in den vorherigen 90 Tagen innerhalb von ca. 60 Sekunden. Am 12. Juni 2026 brach diese Annahme.

Der Aufbau der Bereitstellung vor dem Vorfall

Vor dem Fix schrieb die Kampagnenmanagement-Serverfunktion die Ziel-URL in einen einzelnen KV-Key pro Short-Code und aktualisierte den campaign_redirects-Datensatz in D1. Der Read-Path-Handler schlug den KV-Key nach, gab den Redirect zurück und loggte das Analytics-Event. Es gab keine Version, keinen Zeitstempel und kein Audit-Trail innerhalb von KV. Für TanStack Ships breiteres Modulangebot finden Sie weitere Informationen auf der Features-Seite.

Vorfall: 327 Klicks auf ein ausgemustertes Angebot

Um 09:04:12 UTC änderte ich den pricing-june-Short-Code so, dass er auf eine neue Landingpage (/pricing-v2) statt auf die vorherige (/pricing) zeigt. Die Änderung war ein einzelner kv.put()-Aufruf in der Kampagnenmanagement-Serverfunktion. Der Kampagnen-Datensatz in D1 wurde ebenfalls aktualisiert: version wurde von 4 auf 5 erhöht und das kanonische Ziel wurde neu geschrieben.

Innerhalb von Sekunden zeigte das Analytics-Dashboard etwas不对劲: Klicks wurden weiterhin gegen die alte Ziel-URL erfasst. Drei Regionen — SFO, FRA und NRT — gaben in den nächsten 11 Minuten weiterhin 302-Redirects zu /pricing zurück. Bis 09:15:23 UTC hatten alle Regionen auf das neue Ziel konvergiert.

In diesen 11 Minuten gingen 327 von 3.742 getrackten Klicks — 8,7 % — an das ausgemusterte Angebot. Die Anmelderate der Kohorte sank von der Kampagnen-Basislinie von 7,1 % auf 3,8 %. Der Schaden war die Fehlanpassung zwischen der Kampagnenabsicht und der Edge-Realität. Es war ein stiller Fehler: kein Error, keine 500, nur eine langsame Konvergenz.

Warum der stille Fehler am problematischsten war

Die Kosten eines lauten Fehlers sind ein gepagerter Operator, ein schneller Fix und ein bekannt schlechtes Event. Die Kosten eines stillen Fehlers sind 327 Nutzer, die sich eine Meinung über ein Produkt gebildet haben, das sich weiterentwickelt hatte — und die mit ziemlicher Sicherheit nicht registriert wurden. Die Basislinie von 7,1 % sank auf 3,8 % für die betroffene Kohorte, sichtbar erst als ich die D1-Tabelle redirect_events mit dem Analytics-Dashboard des Kampagneninhabers kreuzreferenzierte.

Untersuchung: KV-Propagation vs. Browser-Caching isolieren

Der erste Instinkt war, Browser-Caching verantwortlich zu machen — der Redirect ist ein 302er, könnte also für Sekunden bis Minuten gecacht werden. Zwei Beweise schlossen dies aus. Erstens: Die Analytics-Events wurden vom Workers-Handler nach Rückgabe des Redirects geschrieben; wenn der Browser den Redirect cachen würde, würde kein Analytics-Event ausgelöst. Die Events wurden aber ausgelöst. Zweitens: Die Analytics-Events zeigten die Ziel-URL, auf der der Nutzer landete, erfasst am selben Edge-Node. Die Ziel-URL war die alte /pricing, nicht die neue /pricing-v2. Der Redirect kam vom Edge, nicht aus dem Browser-Cache.

Die Kreuzreferenzierung der D1-Tabelle redirect_events mit dem KV-Write-Zeitstempel war das zweite Beweisstück. Der D1-Write von version=5 landete um 09:04:11. Der KV-put-Aufruf landete in derselben Sekunde. Edge-Reads in SFO lieferten weiterhin das alte Ziel bis 09:11:47 — 7 Minuten und 35 Sekunden Verzögerung. FRA konvergierte um 09:13:02, NRT um 09:15:23.

Laut Cloudflare KV-Dokumentation ist KV ein eventual konsistenter Key-Value-Store. Writes propagieren in den meisten Fällen innerhalb von Sekunden zu allen Rechenzentren, aber die Dokumentation stellt explizit fest, dass „Writes in seltenen Fällen bis zu 60 Sekunden an jeder Edge-Location möglicherweise nicht sichtbar sind". Das 11-Minuten-Fenster in diesem Vorfall liegt weit außerhalb der dokumentierten Obergrenze.

Warum Browser-Caching ausgeschlossen wurde

Laut Cloudflare Workers KV Best Practices sind KV-Writes über Edge-Locations hinweg innerhalb von Sekunden dauerhaft, aber der Workers-Handler läuft an der Edge-Location, die der Anfrage am nächsten liegt. Wenn der Browser des Nutzers den 302er cachen würde, würde das Analytics-Event nicht vom Workers-Handler ausgelöst — und die Events wurden ausgelöst, mit der Ziel-URL, erfasst am selben Edge-Node. Das ließ nur KV-Propagation als verbleibende Erklärung übrig.

Root Cause: Ein veränderbarer Wert hinter einem global gelesenen Pfad

Die Root Cause war eine Designentscheidung, kein Werkzeugversagen. Die Bereitstellung nutzte den Short-Code als KV-Key und das Ziel als KV-Value. Jeder Read schlug SHORT_LINKS:pricing-june nach und erhielt die Ziel-URL. Jeder Write ersetzte diesen Wert direkt. Es gab keine Version, keinen Zeitstempel, kein Audit-Trail innerhalb von KV — nur den neuesten Wert am Key.

Eventual konsistentes KV ist in Ordnung für Reads, bei denen Staleness akzeptabel ist — ein gecachtes Config-Flag, ein Feature-Toggle, ein langsam änderndes Dictionary. Es ist nicht in Ordnung für einen Wert, der exakt beim Write global und atomar ändern muss. Eine Marketingkampagne ist genau dieser Fall: Wenn der Kampagneninhaber das Ziel aktualisiert, muss jeder nachfolgende Klick irgendwo auf der Welt auf der neuen Seite landen.

Drei Dinge verschärften die Designentscheidung. Erstens: Die D1-Source-of-Truth-Tabelle existierte, war aber nicht auf dem Read-Path. Zweitens: Der Workers-Handler konsultierte D1 nicht auf dem Read-Path, also gab es keine Konsistenz-Grenze. Drittens: Die Versionsnummer in D1 war Metadaten für Analytics, kein Read-Path-Vertrag.

Die Designentscheidung, die brach

Die Entscheidung, die brach, war, KV als Read-Path zu behandeln und D1 als Back-Office-Metadaten-Speicher. Laut Cloudflare D1-Dokumentation ist D1 innerhalb einer Datenbank stark konsistent; der Versions-Bump und der Ziel-Write hätten atomar sein können. Die Bereitstellung hatte die Konsistenzgrenze verfügbar und entschied sich, sie nicht auf dem Read-Path zu nutzen.

Fix: Versionierte KV-Keys mit D1 als Source of Truth

Der Fix ist ein Write-Path-Vertrag, auf den sich der Read-Path verlassen kann. Die Ziel-URL wird nicht mehr direkt verändert; stattdessen schreibt jeder Kampagnen-Redirect-Change einen neuen versionierten Key in KV und aktualisiert die D1-Source-of-Truth. Der Read-Path löst die aktuelle Version aus D1 (in KV gecacht mit kurzer TTL) und liest das Ziel aus dem versionierten KV-Key.

Der Read-Path-Handler in TanStack Ships ShortShip-Modul sieht grob so aus:

typescript
// app/routes/s/$code.ts
export const Route = createServerFileRoute().methods({
  GET: async ({ params, request }) => {
    const code = params.code

    // Schritt 1: Aktuelle Version aus Cache oder D1 auflösen
    const cacheKey = `short:${code}:version`
    let version = (await KV.get(cacheKey)) ?? null
    if (!version) {
      const row = await DB.prepare(
        'SELECT current_version FROM campaign_redirects WHERE code = ?'
      ).bind(code).first()
      if (!row) return Response.redirect('https://example.com/404', 302)
      version = row.current_version
      await KV.put(cacheKey, String(version), { expirationTtl: 30 })
    }

    // Schritt 2: Versioniertes Ziel lesen
    const destKey = `short:${code}:v:${version}`
    const destination = await KV.get(destKey)
    if (!destination) return Response.redirect('https://example.com/404', 302)

    // Schritt 3: Analytics loggen, dann redirect
    await DB.prepare(
      'INSERT INTO redirect_events (code, version, destination, ua) VALUES (?, ?, ?, ?)'
    ).bind(code, version, destination, request.headers.get('user-agent')).run()

    return Response.redirect(destination, 302)
  },
})

Der entsprechende Write-Path schreibt sowohl den versionierten KV-Key als auch den D1-Source-of-Truth-Datensatz in einer Transaktion:

typescript
// app/routes/admin/short-links/$code/update.ts
export const Route = createServerFileRoute().methods({
  POST: async ({ params, request }) => {
    const { destination } = await request.json()
    const code = params.code

    // Version atomar in D1 erhöhen
    const row = await DB.prepare(
      'UPDATE campaign_redirects SET current_version = current_version + 1, destination = ? WHERE code = ? RETURNING current_version'
    ).bind(destination, code).first()

    // Den neuen versionierten KV-Key schreiben
    await KV.put(`short:${code}:v:${row.current_version}`, destination)

    // Den Versions-Cache invalidieren
    await KV.delete(`short:${code}:version`)

    return Response.json({ version: row.current_version })
  },
})

Laut Cloudflare D1-Dokumentation sind D1-Transaktionen innerhalb einer Datenbank stark konsistent. Der Versions-Bump und der Ziel-Write passieren atomar; der neue versionierte KV-Key wird unmittelbar danach geschrieben. Der Read-Path konsultiert D1 nur bei einem seltenen Cache-Miss und fällt auf den versionierten KV-Key für schnelle Edge-Reads zurück.

Das Versioned-Key-Pattern bedeutet, dass ein stale Read an einem Edge-Node weiterhin eine ältere Version des Ziels ausliefern kann — aber niemals das alte Ziel für die neue Kampagnenversion. Ein Klick während des Propagationsfensters landet auf einer bekannten vorherigen Landingpage, nicht auf einer stale Mutation. Der Conversion-Verlust ist auf das Propagationsfenster begrenzt, nicht auf die Absicht des Kampagneninhabers. Laut der TanStack Start-Dokumentation ist dasselbe Versioned-Key-Pattern das, was TanStack Ship in jedem Modul ausliefert, das einen globalen Wert verändert — Preise, Feature-Flags und Short-Links.

Lessons: Wann KV das richtige Werkzeug ist — und wann nicht

KV ist das richtige Werkzeug für langsam ändernde, eventual konsistente Reads. Feature-Flags, langsam ändernde Dictionaries und Referenzdaten sind gute Einsätze. KV ist das falsche Werkzeug für jeden Wert, der exakt beim Write-Return global und atomar ändern muss. Veränderbare Kampagnen-Redirects, veränderbare Preise, veränderbare Feature-Rollouts — diese gehören hinter einen versionierten Read-Path mit einer stark konsistenten Source of Truth.

Die Bereitstellung hatte die Source of Truth in D1, nutzte sie aber nicht auf dem Read-Path. Laut Cloudflare Workers-Dokumentation können Workers sowohl von KV als auch von D1 im selben Handler lesen. Der Trade-off ist ein zusätzlicher Read bei Cache-Miss.

Drei Guardrails werden nun standardmäßig in TanStack Ships ShortShip-Modul ausgeliefert. Erstens: Veränderbare KV-Writes auf Kampagnenpfaden werden von der Schema-Validierungsschicht abgelehnt. Zweitens: Jeder Read-Path konsultiert den versionierten Key, nicht den veränderbaren Key. Drittens: Jeder Kampagnen-Redirect hat einen TTL-begrenzten Version-Cache, sodass D1 mindestens alle 30 Sekunden konsultiert wird. Der Fix wurde in 2,1 Stunden ausgeliefert und hält seit 60 Tagen.

Replay-Prozedur: Rollback eines falschen Ziels

Eine Kampagnen-Redirect-Änderung kann auf einem Ziel landen, das sich als falsch herausstellt. Das Versioned-Key-Pattern unterstützt sicheres Rollback durch Schreiben der vorherigen Version des Ziels in einen neuen Versions-Key. Rollback schreibt eine neue Version, die auf das vorherige Ziel zeigt; es verändert keinen existierenden versionierten Key. Analytics-Events werden weiterhin gegen die Version aufgezeichnet, die tatsächlich ausgeliefert wurde.

typescript
// app/routes/admin/short-links/$code/rollback.ts
export const Route = createServerFileRoute().methods({
  POST: async ({ params, request }) => {
    const { targetVersion } = await request.json()
    const code = params.code

    const row = await DB.prepare(
      'SELECT destination FROM campaign_redirects WHERE code = ?'
    ).bind(code).first()
    if (!row) return new Response('not found', { status: 404 })

    const previous = await KV.get(`short:${code}:v:${targetVersion}`)
    if (!previous) return new Response('version not found', { status: 404 })

    // Auf eine neue Version erhöhen, die auf das Rollback-Ziel zeigt
    const next = await DB.prepare(
      'UPDATE campaign_redirects SET current_version = current_version + 1, destination = ? WHERE code = ? RETURNING current_version'
    ).bind(previous, code).first()

    await KV.put(`short:${code}:v:${next.current_version}`, previous)
    await KV.delete(`short:${code}:version`)

    return Response.json({ version: next.current_version })
  },
})

Rollback schreibt eine neue Version, die auf das vorherige Ziel zeigt; es verändert keinen existierenden versionierten Key. Analytics-Events werden weiterhin gegen die Version aufgezeichnet, die tatsächlich ausgeliefert wurde.

Grenzen dieses Postmortems

Ich habe Browser-Caching nicht mit Cache-Busting-Tests isoliert. Ich habe keine regionenweisen KV-Edge-Logs gezogen, um die Propagationsverzögerung zu bestätigen; ich habe sie aus D1-Write-Zeitstempeln approximiert. Ich habe auch Cold-Start-Latenz oder Click-Event-Throughput unter dem neuen Pattern nicht benchmarkt.

Was ich mit Sicherheit sagen kann: Das 11-Minuten-Propagationsfenster stimmt mit der Analytics-Event-Zeitlinie überein, und der Fix liefert das Versioned-Key-Pattern als einzig verfügbare Schreibpfad-Variante aus. Laut der TanStack Router-Dokumentation funktioniert dasselbe Versioned-Key-Pattern für jeden Route-Guard, der aus einer KV-gecachten Source of Truth auf Cloudflare Workers liest.

Nächste Schritte

Wenn Ihre Kampagnen-Redirects hinter einem veränderbaren KV-Write leben, ersetzen Sie sie durch das oben beschriebene Versioned-Key-Pattern. Die Migration ist ein Einmal-Script, das jeden veränderbaren KV-Key als versionierten Key mit version=1 umschreibt. Das TanStack Ship ShortShip-Modul liefert den Read-Path-Handler und den Write-Path-Vertrag standardmäßig aus; der TanStack Ship Blog-Index behandelt die zugehörigen UTM- und Analytics-Module. Laut der Drizzle ORM-Dokumentation gilt dasselbe Drizzle-Schema-Pattern für jede versionierte Key-Migration.