KV Caching

Create a KV namespace, bind it to your Worker, and implement common patterns: cache-aside, feature flags, and per-source rate limiting. The Webhook Hub uses KV to throttle incoming webhooks per source.

Prerequisites: Storage Landscape, First Worker

Create the Namespace

npx wrangler kv namespace create CACHE

This outputs the namespace ID. Add it to wrangler.jsonc:

{
  "name": "webhook-hub",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],

  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "webhook-hub-db",
      "database_id": "<your-db-id>"
    }
  ],
  "r2_buckets": [
    {
      "binding": "BUCKET",
      "bucket_name": "webhook-payloads"
    }
  ],
  "kv_namespaces": [
    {
      "binding": "CACHE",
      "id": "<paste-your-namespace-id>"
    }
  ]
}

Re-generate types:

npx wrangler types

Now c.env.CACHE is typed as KVNamespace.

Basic Operations

// PUT - store a value (optional TTL)
await c.env.CACHE.put("key", "value");
await c.env.CACHE.put("key", "value", { expirationTtl: 3600 }); // expires in 1 hour
await c.env.CACHE.put("key", "value", { expiration: 1735689600 }); // expires at Unix timestamp

// GET - retrieve a value
const value = await c.env.CACHE.get("key");           // returns string | null
const json = await c.env.CACHE.get("key", "json");    // returns parsed JSON | null
const buf = await c.env.CACHE.get("key", "arrayBuffer"); // returns ArrayBuffer | null
const stream = await c.env.CACHE.get("key", "stream"); // returns ReadableStream | null

// DELETE
await c.env.CACHE.delete("key");

// LIST - paginated key listing
const listed = await c.env.CACHE.list({ prefix: "rate:", limit: 100 });
// listed.keys = [{ name: "rate:github", expiration: 1735689600, metadata: {...} }, ...]
// listed.list_complete = false means more pages available
// listed.cursor = use in next list() call for pagination

PUT with Metadata

You can attach metadata to any key without increasing the value size:

await c.env.CACHE.put("config:email", "alerts@example.com", {
  expirationTtl: 86400,
  metadata: { updatedBy: "admin", version: 3 },
});

// Read with metadata
const result = await c.env.CACHE.getWithMetadata("config:email");
// result.value = "alerts@example.com"
// result.metadata = { updatedBy: "admin", version: 3 }

Metadata is limited to 1024 bytes (serialized JSON). It’s useful for tagging keys with version info, ownership, or source context without parsing the value.

Gotcha: KV is eventually consistent. After a write, it can take up to 60 seconds for the new value to propagate to all 300+ edge locations. If you write from one region and immediately read from another, you may get the stale value. Never use KV as a source of truth for data that requires read-after-write consistency - use D1 or Durable Objects instead.

Pattern: Cache-Aside

Cache expensive computation or external API responses in KV:

app.get("/stats/:source", async (c) => {
  const source = c.req.param("source");
  const cacheKey = `stats:${source}`;

  // 1. Check cache
  const cached = await c.env.CACHE.get(cacheKey, "json");
  if (cached) {
    return c.json({ ...cached, fromCache: true });
  }

  // 2. Cache miss - compute from D1
  const stats = await c.env.DB.prepare(
    `SELECT
       count(*) as total,
       count(CASE WHEN received_at > datetime('now', '-1 day') THEN 1 END) as last_24h,
       max(received_at) as latest
     FROM webhooks WHERE source = ?`
  )
    .bind(source)
    .first();

  // 3. Store in cache with 5-minute TTL
  await c.env.CACHE.put(cacheKey, JSON.stringify(stats), {
    expirationTtl: 300,
  });

  return c.json({ ...stats, fromCache: false });
});

Cache-aside works well with KV because:

  • Reads are fast (cached at every edge location)
  • Stale data is acceptable (stats don’t need to be real-time)
  • TTL handles invalidation automatically

Pattern: Feature Flags

Store feature flags in KV for instant global rollout:

// Set a feature flag
app.put("/admin/flags/:flag", async (c) => {
  const flag = c.req.param("flag");
  const { enabled, rolloutPercent } = await c.req.json<{
    enabled: boolean;
    rolloutPercent?: number;
  }>();

  await c.env.CACHE.put(
    `flag:${flag}`,
    JSON.stringify({ enabled, rolloutPercent: rolloutPercent ?? 100 }),
    { metadata: { updatedAt: new Date().toISOString() } }
  );

  return c.json({ flag, enabled });
});

// Check a feature flag
async function isEnabled(
  kv: KVNamespace,
  flag: string,
  userId?: string
): Promise<boolean> {
  const raw = await kv.get(`flag:${flag}`, "json") as {
    enabled: boolean;
    rolloutPercent: number;
  } | null;

  if (!raw || !raw.enabled) return false;
  if (raw.rolloutPercent >= 100) return true;

  // Simple percentage rollout based on user ID hash
  if (!userId) return false;
  const hash = Array.from(userId).reduce((acc, c) => acc + c.charCodeAt(0), 0);
  return (hash % 100) < raw.rolloutPercent;
}

// Usage in a route
app.get("/dashboard", async (c) => {
  const newUI = await isEnabled(c.env.CACHE, "new-dashboard", c.req.header("X-User-ID"));
  // ...
});

Tip: Flag changes propagate globally within ~60s (KV’s consistency window). For instant rollout/rollback, this is fast enough. For features that need instant kill switches, consider Durable Objects instead.

Pattern: Rate Limiting

The Webhook Hub uses KV counters with TTL to throttle incoming webhooks per source:

const RATE_LIMIT = 100; // max webhooks per source per minute
const RATE_WINDOW = 60; // seconds

async function checkRateLimit(
  kv: KVNamespace,
  source: string
): Promise<{ allowed: boolean; remaining: number }> {
  const key = `rate:${source}`;
  const current = parseInt((await kv.get(key)) ?? "0");

  if (current >= RATE_LIMIT) {
    return { allowed: false, remaining: 0 };
  }

  // Increment counter with TTL
  await kv.put(key, String(current + 1), { expirationTtl: RATE_WINDOW });

  return { allowed: true, remaining: RATE_LIMIT - current - 1 };
}

app.post("/webhook/:source", async (c) => {
  const source = c.req.param("source");

  // Check rate limit
  const { allowed, remaining } = await checkRateLimit(c.env.CACHE, source);
  if (!allowed) {
    return c.json({ error: "rate limited", retryAfter: RATE_WINDOW }, 429);
  }

  // Process the webhook...
  const payload = await c.req.text();
  const eventType = c.req.header("X-Event-Type") ?? "unknown";

  const result = await c.env.DB.prepare(
    "INSERT INTO webhooks (source, event_type, payload) VALUES (?, ?, ?)"
  )
    .bind(source, eventType, payload)
    .run();

  return c.json(
    { id: result.meta.last_row_id, source, remaining },
    { status: 201, headers: { "X-RateLimit-Remaining": String(remaining) } }
  );
});

This rate limiter is approximate because KV is eventually consistent. Two simultaneous requests from different edge locations might both read the same counter value and both increment to the same number. For the Webhook Hub, approximate rate limiting is fine - you’re preventing abuse, not enforcing exact quotas.

Gotcha: For strict rate limiting (billing, API quotas), use Durable Objects instead. They provide single-threaded, strongly consistent state, so concurrent increments are serialized. KV-based rate limiting works for soft limits and abuse prevention.

Testing Locally

npx wrangler dev

# Set a value
curl -X PUT http://localhost:8787/admin/flags/new-dashboard \
  -H "Content-Type: application/json" \
  -d '{"enabled": true, "rolloutPercent": 50}'

# Rate limit test - send 5 rapid requests
for i in {1..5}; do
  curl -s -X POST http://localhost:8787/webhook/test \
    -H "Content-Type: application/json" \
    -d '{"test": true}' | jq .remaining
done

Local KV data persists in .wrangler/state/ across dev sessions, same as D1.

What’s Next

  • Turnstile - protect the Webhook Hub dashboard with bot verification
  • Durable Objects - strongly consistent state for strict rate limiting and coordination