Design wechseln

Cloudflare Workers KV in der Praxis: Verteilter Key-Value-Speicher von den Grundlagen bis zur Profi-Nutzung

Sie starren auf die Latenzkurve im Cloudflare Dashboard. Die rote Linie hängt noch über 200 ms – obwohl Sie Workers nutzen und der Code schlank ist. Warum wartet jede Nutzeranfrage trotzdem so lange?

Das Problem liegt oft in der Datenbank. Jede Session-Abfrage läuft vom Edge-Knoten ins Rechenzentrum in Europa und zurück. Selbst wenn der Worker nur 5 ms braucht, frisst die Netzwerkstrecke den Rest.

Workers sind stateless. Sie brauchen Speicher, der wirklich am Edge lebt – Cloudflare Workers KV.

Dieser Artikel bündelt Fehler, Messwerte und Code aus der Praxis: was KV ist, warum sub-10ms möglich sind, vollständige Session- und API-Cache-Implementierungen sowie wann D1 oder R2 die bessere Wahl sind.

Was ist KV – verteilter Edge-Speicher verstehen

Kurz gesagt: KV ist der „mitlaufende Speicher“ von Cloudflare für Workers. Er sitzt nicht in einem einzelnen Rechenzentrum, sondern verteilt sich über 300+ Edge-Knoten weltweit. Eine Anfrage aus Tokio trifft Daten oft direkt in Tokio; aus Frankfurt oft schon im Frankfurter Cache.

Cloudflare Workers KV ist ein global verteilter Key-Value-Speicher für Edge Computing. Drei Merkmale prägen ihn:

Extrem schnelle Reads. Hot Keys liegen bei Cache-Treffern zwischen 500µs und 10 ms – beim ersten Blick skeptisch, bis eigene Benchmarks das bestätigen: stabil im einstelligen Millisekundenbereich.

Globale Replikation. Ein Write wird an alle Edge-Knoten repliziert. Anders als bei vielen Redis-Clustern gilt: einmal schreiben, überall lesen – ideal für read-heavy Workloads.

Hoher Read-Durchsatz. Einzelne Keys können tausende RPS lesen, weil Daten am Edge gecacht werden und nicht jedes Mal die Zentralinstanz anfahren müssen.

500µs - 10ms
Hot-Keys-Latenzbereich

Cloudflare-Speicher im Überblick

KV ist ein Teil der Cloudflare-Speichermatrix:

SpeicherdienstDatenmodellBeste SzenarienSchreiblimitLatenz
KVKey-ValueSession, Cache, Konfiguration1 RPS/keyHot Keys 500µs–10ms
D1SQL (SQLite)Nutzerdaten, Bestellungen, Reportskein hartes Limitoft 50–200ms, standortabhängig
R2Object StorageDateien, Bilder, Videokein hartes LimitDownload schnell, Upload abhängig von Größe
Durable ObjectsStateful ObjectsKollaboration, WebSocketkein hartes LimitBindung an bestimmten Knoten

Was bedeutet 1 RPS/key? Pro Key nur ein Write pro Sekunde – das wichtigste Limit von KV, später im Detail.

Wann KV – Kurzentscheidung

KV passt gut:

  • Session Storage (Login-Status)
  • API-Response-Cache (Drittanbieter-APIs)
  • Rate-Limiting-Zähler
  • Feature Flags / Konfiguration
  • Redirect-Mapping (URL-Regeln)

KV eher nicht:

  • häufige Writes am selben Key (über 1 RPS)
  • komplexe SQL-Daten (Nutzer-, Bestelltabellen → D1)
  • große Dateien (Bilder, Video → R2)
  • starke Konsistenz (Finanztransaktionen → Durable Objects)

In der offiziellen Doku: KV für „hohe Leserate, seltene Änderungen, keine sofortige Konsistenz“. Frameworks wie OpenAuth nutzen KV standardmäßig für Sessions – unten folgt vollständiger Beispielcode.

KV-Architektur – warum es so schnell ist

Die Geschwindigkeit kommt von drei Cache-Ebenen, nicht von Magie.

Stellen Sie sich einen Kiosk vor: Ware direkt an der Theke (Edge Cache); sonst aus dem Lager hinten (Regional); im Notfall aus dem Zentrallager per LKW (Central Store).

Anfrage → Edge Cache (schnellste)
        ↓ Miss
      Regional Cache
        ↓ Miss
      Central Store (langsamste)

Laut Cloudflare-Blog (Oktober 2025) lösen etwa 30 % der Requests alles in der Cache-Schicht – ein Drittel der Reads ohne Zentralzugriff, daher die niedrige Latenz.

30%
Edge-Cache-Trefferquote

Performance: Doku und eigene Messung

Referenzwerte aus der Doku:

  • Hot keys: 500µs bis 10 ms
  • Cold keys: höhere Latenz, Backhaul nötig

Ein einfacher Latenztest:

// Einfacher Latenztest
const start = Date.now();
await env.KV.get("test-key");
const latency = Date.now() - start;
console.log(`Latency: ${latency}ms`);

100 Durchläufe: Hot Keys im Schnitt 5–8 ms. Cold Keys beim ersten Zugriff 50 ms+, danach deutlich schneller – Cache greift.

2025 beschleunigte Cloudflare KV laut Blog etwa 3×, u. a. durch:

  1. direkte Verbindung Workers ↔ KV ohne frühere Front-Line-Schicht
  2. kürzere interne Datenpfade

Auch abhängige Dienste (Turnstile, Waiting Room) profitieren davon.

Konsistenz: Preis der Eventual Consistency

KV ist eventually consistent: ein Write erscheint nicht sofort auf jedem Edge-Knoten. In Tests: regionale Propagation oft Sekunden bis wenige Dutzend Sekunden.

Problematisch:

  • Session gerade geschrieben, nächste Anfrage auf anderem Edge – Session „fehlt“
  • Echtzeit-Kollaboration: A schreibt, B liest sofort – veralteter Stand

Meist unkritisch:

  • Feature Flags – Sekunden Verzögerung OK
  • API-Cache – Minuten TTL, Propagation egal
  • Redirect-Regeln – Nutzer merken kaum etwas

Brauchen Sie sofortige Konsistenz, sind Durable Objects oft besser – fester Knoten, garantierter Zustand.

Wrangler CLI – Setup in der Praxis

Zwei Schritte: Namespace anlegen, in wrangler.toml an den Worker binden.

Namespace anlegen

Ein Namespace ist der „Container“ für beliebig viele Key-Value-Paare. Pro Konto bis 1000 Namespaces (Anfang 2025 von 200 erhöht).

# Production-Namespace
wrangler kv namespace create MY_KV

# Ausgabe ähnlich:
# Created namespace with id "abc123def456..."
# Add the following to your wrangler.toml:
# [[kv_namespaces]]
# binding = "MY_KV"
# id = "abc123def456..."

Für lokale Entwicklung zusätzlich ein Preview-Namespace:

# Preview-Namespace
wrangler kv namespace create MY_KV --preview

# Ausgabe ähnlich:
# Created preview namespace with id "preview_abc123..."

wrangler.toml

IDs aus der CLI in die Konfiguration:

name = "my-worker"
main = "src/index.ts"

[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456..."        # Production
preview_id = "preview_abc123..." # Preview (lokal)

binding legt den Namen in Code fest:

// binding = "MY_KV" → env.MY_KV
const value = await env.MY_KV.get("some-key");

REST API vs. Workers Binding API

Workers Binding API (empfohlen):

  • direkt env.MY_KV.get() im Worker
  • kein extra HTTP-Hop, schnellste Variante
  • kostenlos (nur Worker-Laufzeit)

REST API:

  • HTTP-Zugriff auf KV
  • Token-Auth, für externe Systeme
  • unterliegt den globalen REST-Rate-Limits

Fast immer Binding API. REST eher für externe Tools, CI/CD-Bulk-Import oder Ops-Debugging.

Häufige Wrangler-KV-Befehle

# Schreiben
wrangler kv key put --namespace-id=abc123 "my-key" "my-value"

# Lesen
wrangler kv key get --namespace-id=abc123 "my-key"

# Löschen
wrangler kv key delete --namespace-id=abc123 "my-key"

# Keys auflisten (mit Präfix)
wrangler kv key list --namespace-id=abc123 --prefix="session:"

Praktisch zum Debuggen; in Produktion lieber Worker-Code.

TypeScript – Praxiscode

Vollständige, lauffähige Beispiele.

Basis-CRUD

// src/index.ts
interface Env {
  MY_KV: KVNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // Schreiben
    if (path === "/put") {
      const key = url.searchParams.get("key") || "default";
      const value = url.searchParams.get("value") || "hello";
      
      await env.MY_KV.put(key, value);
      return new Response(`Saved: ${key} = ${value}`);
    }

    // Lesen
    if (path === "/get") {
      const key = url.searchParams.get("key") || "default";
      const value = await env.MY_KV.get(key);
      
      if (value === null) {
        return new Response("Key not found", { status: 404 });
      }
      return new Response(value);
    }

    // Löschen
    if (path === "/delete") {
      const key = url.searchParams.get("key") || "default";
      await env.MY_KV.delete(key);
      return new Response(`Deleted: ${key}`);
    }

    // Keys listen (mit Präfix)
    if (path === "/list") {
      const prefix = url.searchParams.get("prefix") || "";
      const keys = await env.MY_KV.list({ prefix });
      
      const keyList = keys.keys.map(k => k.name).join("\n");
      return new Response(keyList || "No keys found");
    }

    return new Response("Try /put, /get, /delete, or /list");
  },
};

Lokal testen:

wrangler dev
# Schreiben
curl "http://localhost:8787/put?key=test&value=helloworld"
# Lesen
curl "http://localhost:8787/get?key=test"

Session Storage – vollständig

// src/session.ts
interface SessionData {
  userId: string;
  email: string;
  createdAt: number;
  expiresAt: number;
}

interface Env {
  SESSION_KV: KVNamespace;
}

const SESSION_TTL = 3600; // 1 Stunde

class SessionManager {
  private kv: KVNamespace;

  constructor(kv: KVNamespace) {
    this.kv = kv;
  }

  async create(userId: string, email: string): Promise<string> {
    const sessionId = crypto.randomUUID();
    const sessionData: SessionData = {
      userId,
      email,
      createdAt: Date.now(),
      expiresAt: Date.now() + SESSION_TTL * 1000,
    };

    await this.kv.put(
      `session:${sessionId}`,
      JSON.stringify(sessionData),
      { expirationTtl: SESSION_TTL }
    );

    return sessionId;
  }

  async get(sessionId: string): Promise<SessionData | null> {
    const raw = await this.kv.get(`session:${sessionId}`);
    if (!raw) return null;

    try {
      return JSON.parse(raw) as SessionData;
    } catch {
      return null;
    }
  }

  async delete(sessionId: string): Promise<void> {
    await this.kv.delete(`session:${sessionId}`);
  }

  async refresh(sessionId: string): Promise<boolean> {
    const session = await this.get(sessionId);
    if (!session) return false;

    session.expiresAt = Date.now() + SESSION_TTL * 1000;
    await this.kv.put(
      `session:${sessionId}`,
      JSON.stringify(session),
      { expirationTtl: SESSION_TTL }
    );

    return true;
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const sessionManager = new SessionManager(env.SESSION_KV);
    const url = new URL(request.url);

    if (url.pathname === "/login" && request.method === "POST") {
      const body = await request.json();
      const sessionId = await sessionManager.create(
        body.userId as string,
        body.email as string
      );
      
      return new Response(JSON.stringify({ sessionId }), {
        headers: { "Content-Type": "application/json" },
      });
    }

    if (url.pathname === "/verify") {
      const sessionId = url.searchParams.get("sessionId");
      if (!sessionId) {
        return new Response("Missing sessionId", { status: 400 });
      }

      const session = await sessionManager.get(sessionId);
      if (!session) {
        return new Response("Session not found", { status: 401 });
      }

      return new Response(JSON.stringify(session), {
        headers: { "Content-Type": "application/json" },
      });
    }

    if (url.pathname === "/logout") {
      const sessionId = url.searchParams.get("sessionId");
      if (sessionId) {
        await sessionManager.delete(sessionId);
      }
      return new Response("Logged out");
    }

    return new Response("Not found", { status: 404 });
  },
};

Wichtig:

  1. TTL: expirationTtl löscht abgelaufene Einträge automatisch
  2. Key-Präfix: session: trennt Datentypen und erleichtert list
  3. JSON: KV speichert nur Strings – JSON.stringify / parse nötig

API-Response-Cache

// src/api-cache.ts
interface Env {
  CACHE_KV: KVNamespace;
}

const DEFAULT_CACHE_TTL = 300; // 5 Minuten

async function cachedFetch(
  kv: KVNamespace,
  cacheKey: string,
  url: string,
  ttl: number = DEFAULT_CACHE_TTL
): Promise<Response> {
  const cached = await kv.get(cacheKey, "text");
  
  if (cached) {
    console.log(`Cache hit: ${cacheKey}`);
    return new Response(cached, {
      headers: {
        "Content-Type": "application/json",
        "X-Cache": "HIT",
      },
    });
  }

  console.log(`Cache miss: ${cacheKey}`);
  const response = await fetch(url);
  const body = await response.text();

  await kv.put(cacheKey, body, {
    expirationTtl: ttl,
  });

  return new Response(body, {
    headers: {
      "Content-Type": "application/json",
      "X-Cache": "MISS",
    },
  });
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const apiUrl = url.searchParams.get("api");

    if (!apiUrl) {
      return new Response("Missing api parameter", { status: 400 });
    }

    const cacheKey = `api:${apiUrl}`;
    
    return cachedFetch(env.CACHE_KV, cacheKey, apiUrl);
  },
};

cacheTtl optimieren

Oft übersehen: cacheTtl steuert die Edge-Cache-Dauer (Standard 60 s). Für Hot Data höher setzen:

// Konfiguration mit längerem Edge-Cache
await env.MY_KV.get("config:feature-flags", {
  cacheTtl: 3600, // 1 Stunde Edge-Cache
});

Auch wenn sich der Central Store nicht ändert, hält der Edge den Wert länger – ideal für Feature Flags ohne Echtzeit-Zwang.

KV vs. D1 vs. R2 – Entscheidungshilfe

Welcher Datentyp?

├─ Dateien (Bilder, Video, PDF)?
│   └─ JA → R2

├─ SQL (Nutzer, Bestellungen, Joins)?
│   └─ JA → D1

├─ Einfaches Key-Value, read-heavy?
│   ├─ Writes &gt; 1 RPS/key?
│   │   └─ JA → nicht KV → D1 oder Durable Objects
│   │
│   └─ NEIN → KV ✓

├─ Sofortige Konsistenz nötig?
│   └─ JA → Durable Objects
│   └─ NEIN → KV oft OK

└─ Unsicher?
    └─ erst KV testen – reicht es, nicht wechseln
500µs-10ms
KV Hot-Key-Latenz
50-200ms
D1-Latenz
25MB
KV max. Value
Source: Cloudflare offizielle Dokumentation

Vergleichstabelle

DimensionKVD1R2
ModellKey-ValueSQL (SQLite)Object Storage
Abfragenget/put/deletevolles SQLPfad, kein Query
Schreiblimit1 RPS pro Keykein hartes Limitkein hartes Limit
Read-Latenz500µs–10ms (hot)50–200msschnell (Download)
Konsistenzeventualstark (Single Region)eventual
Max. Value25 MBSQLite-Zeilenlimit5 TB pro Objekt
Free Tier100k reads/Tag5 GB + 25M row reads10 GB Speicher
TypischSession, Cache, ConfigNutzer, Orders, ReportsDateien, Backup

Szenario-Empfehlungen

Auth / SessionKV

Einfaches Key-Value, viele Reads pro Request, wenige Writes. OpenAuth & Co. nutzen KV standardmäßig.

await env.SESSION_KV.put(`session:${sessionId}`, JSON.stringify(session));

Profile / BestellungenD1

SQL nötig – KV kann keine Joins oder Aggregationen.

SELECT * FROM orders WHERE user_id = ? AND created_at &gt; ?

Bilder / DateienR2

Größer als 25 MB oder reines Blob-Storage – nicht KV.

await env.MY_BUCKET.put("images/profile.jpg", imageBuffer);

API Rate LimitingKV (vorsichtig)

Zähler sind Key-Value, Writes können 1 RPS/key sprengen. Grobe Tageslimits OK; präzises per-Second-Limit eher Durable Objects oder Upstash Redis.

const count = parseInt(await env.KV.get(`rate:${userId}`) || "0");
if (count &gt; 100) {
  return new Response("Rate limit exceeded", { status: 429 });
}
await env.KV.put(`rate:${userId}`, String(count + 1));

Drittanbieter-API-CacheKV

Viele Reads, seltene Writes, Minuten-TTL – Eventual Consistency reicht.

Kombination im Projekt

interface Env {
  SESSION_KV: KVNamespace;
  CACHE_KV: KVNamespace;
  DATABASE_D1: D1Database;
  FILES_R2: R2Bucket;
}

// Ein Request kann alles nutzen:
// 1. SESSION_KV für Session
// 2. CACHE_KV für API-Cache
// 3. DATABASE_D1 für Bestellungen
// 4. FILES_R2 für Avatar

So entfaltet sich die Cloudflare-Speichermatrix im Alltag.

Performance-Optimierung

1. cacheTtl tunen

// ❌ Standard: 60 s Edge-Cache
await env.KV.get("config:feature-flags");

// ✅ Konfiguration: länger cachen
await env.KV.get("config:feature-flags", {
  cacheTtl: 3600,
});

Erhöhen: Feature Flags, statische Endpoints, Redirect-Tabellen.

Nicht erhöhen: Live-Sessions, Echtzeit-Zähler.

2. Parallel statt seriell

// ❌ Seriell: Latenz addiert sich
const user = await env.KV.get(`user:${userId}`);
const settings = await env.KV.get(`settings:${userId}`);
const permissions = await env.KV.get(`permissions:${userId}`);

// ✅ Parallel: Latenz ≈ Maximum eines Reads
const [user, settings, permissions] = await Promise.all([
  env.KV.get(`user:${userId}`),
  env.KV.get(`settings:${userId}`),
  env.KV.get(`permissions:${userId}`),
]);

Messung: 3 Keys seriell ~20 ms, parallel ~8 ms.

60%
Latenzreduktion (parallel vs. seriell)
Source: Eigene Messung

3. Hot-Key-Design

// ❌ Nur ein Nutzer liest → cold
await env.KV.get(`session:${userId}`);

// ✅ Gemeinsame Daten → hot
await env.KV.get("config:global-flags");

Nicht alles in einen Key packen: private Daten pro User-ID, globale Config als ein Hot Key.

4. Namespace-Organisation

[[kv_namespaces]]
binding = "SESSION_KV"
id = "xxx"

[[kv_namespaces]]
binding = "CACHE_KV"
id = "yyy"

[[kv_namespaces]]
binding = "CONFIG_KV"
id = "zzz"

Vorteile: getrenntes Aufräumen, unterschiedliche TTL-Strategien, getrenntes Monitoring im Dashboard.

5. Batch mit list()

const result = await env.SESSION_KV.list({ prefix: "session:" });

for (const key of result.keys) {
  console.log(key.name);
}

if (!result.list_complete) {
  const next = await env.SESSION_KV.list({
    prefix: "session:",
    cursor: result.cursor,
  });
}

Bulk-Delete vorsichtig – viele Writes verbrauchen Kontingente.

Preise und Limits

100,000
Free Reads/Tag
1,000
Free Writes/Tag
1 GB
Free Speicher
$5
Paid Plan/Monat
Source: Cloudflare Pricing

Free vs. Paid

MetrikFreePaid ($5/Monat)
Reads100.000 / Tagunbegrenzt (metered)
Writes1.000 / Tagunbegrenzt (metered)
Deletes1.000 / Tagunbegrenzt (metered)
Lists1.000 / Tagunbegrenzt (metered)
Speicher1 GBunbegrenzt (metered)
Namespaces10001000

Free reicht für Tests und kleine Projekte. Produktion oft Paid: keine Read-Caps, mehr Writes, Dashboard-Alerts.

Write Rate Limit – 1 RPS/key

Pro unique key maximal 1 Write pro Sekunde.

// ❌ Schleife überschreitet Limit
for (let i = 0; i &lt; 10; i++) {
  await env.KV.put("counter", String(i));
}

// ✅ Zeitbasierte Keys
await env.KV.put(`counter:${Math.floor(Date.now() / 1000)}`, value);

Hintergrund: globale Replikation pro Write – Cloudflare schützt die Plattform mit 1 RPS/key.

Strategien: Zeit- oder UUID-Keys verteilen; bei echtem High-Frequency-Write D1 oder Durable Objects.

Value-Größe

Maximal 25 MB. Größere Daten → R2.

Namespace-Strategie

Bis 1000 Namespaces pro Konto. Funktionsweise trennen oder per Präfix in einem Namespace:

await env.KV.put("session:user1", data);
await env.KV.put("cache:api1", data);
await env.KV.put("config:flags", data);

Kostenschätzung (Paid)

Monat = $5 (Basis) + Reads + Writes + Speicher

Reads  = Anzahl × $0,01 / 100.000
Writes = Anzahl × $1,00 / 1.000.000
Speicher = GB × $0,50

Beispiel 100k Requests/Tag, 30 Tage: oft nur wenige Dollar über dem $5-Basispreis.

Fazit

KV ist der „mitlaufende Speicher“ von Workers – für Session, Cache und Config mit vielen Reads und wenigen Writes.

KV, wenn:

  • einfaches Key-Value
  • Reads ≫ Writes
  • Eventual Consistency OK
  • ≤ 1 Write/Sekunde pro Key

D1, wenn SQL, Joins oder > 1 RPS Writes nötig sind.

R2, wenn Dateien oder Values > 25 MB.

Durable Objects, wenn sofortige Konsistenz oder Echtzeit-Kollaboration.

Als Nächstes: Session-Code oben mit wrangler dev ausprobieren. Mehr zur Speichermatrix in der Serie cloudflare-bindui – D1 und R2 dort vertieft.

FAQ

Welche Schreiblimits hat Cloudflare Workers KV?
Pro unique key maximal 1 Schreibvorgang pro Sekunde (1 RPS). Darüber schlagen Requests fehl. Strategien: Keys mit Zeitstempel verteilen (z. B. counter:timestamp) oder D1/Durable Objects nutzen.
Eignet sich KV für User-Sessions?
Sehr gut. Sessions sind einfaches Key-Value, Reads dominieren (jede Anfrage prüft die Session), Writes sind selten (Login/Logout). Mit TTL laufen Einträge automatisch ab – kein manuelles Aufräumen nötig.
Was ist der Unterschied zwischen KV und D1 – wann was?
Kernunterschied:

• KV: Key-Value-Modell, Hot Keys 500µs–10ms, Schreiblimit 1 RPS/key
• D1: SQL (SQLite), komplexe Queries, kein hartes Schreiblimit pro Key

Regel: SQL-Queries → D1; einfaches Key-Value mit vielen Reads und wenigen Writes → KV.
Warum kann KV 500µs–10ms Latenz erreichen?
Drei Cache-Ebenen: Edge Cache → Regional Cache → Central Store. Etwa 30 % der Requests treffen direkt am Edge – kein Roundtrip zur Zentralinstanz. Nach Cloudflare-Optimierungen 2025 etwa 3× schneller.
Wozu dient der Parameter cacheTtl?
Steuert die Lebensdauer des Edge-Caches. Standard 60 Sekunden. Für Hot Data (Feature Flags, Konfiguration) z. B. 3600 Sekunden (1 Stunde) – weniger Backhaul zur Zentralinstanz.
Wie groß darf ein KV-Value maximal sein?
25 MB (Anfang 2025 von 10 MB erhöht). Darüber schlagen Writes fehl – Bilder, Videos und große Blobs gehören in R2 Object Storage.

11 Min. Lesezeit · Veröffentlicht am: 22. Apr. 2026 · Aktualisiert am: 4. Juli 2026

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog