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