13 KiB
name, description, license
| name | description | license |
|---|---|---|
| cloudflare-kv | Store key-value data globally with Cloudflare KV's edge network. Use when: caching API responses, storing configuration, managing user preferences, handling TTL expiration, or troubleshooting KV_ERROR, 429 rate limits, eventual consistency, or cacheTtl errors. | MIT |
Cloudflare Workers KV
Status: Production Ready ✅ Last Updated: 2025-11-24 Dependencies: cloudflare-worker-base (for Worker setup) Latest Versions: wrangler@4.50.0, @cloudflare/workers-types@4.20251121.0
Recent Updates (2025):
- August 2025: Architecture redesign (40x performance gain, <5ms p99 latency, hybrid storage with R2)
- January 2025: Namespace limit increased (200 → 1,000 namespaces per account for Free and Paid plans)
Quick Start (5 Minutes)
# Create namespace
npx wrangler kv namespace create MY_NAMESPACE
# Output: [[kv_namespaces]] binding = "MY_NAMESPACE" id = "<UUID>"
wrangler.jsonc:
{
"kv_namespaces": [{
"binding": "MY_NAMESPACE", // Access as env.MY_NAMESPACE
"id": "<production-uuid>",
"preview_id": "<preview-uuid>" // Optional: local dev
}]
}
Basic Usage:
type Bindings = { MY_NAMESPACE: KVNamespace };
app.post('/set/:key', async (c) => {
await c.env.MY_NAMESPACE.put(c.req.param('key'), await c.req.text());
return c.json({ success: true });
});
app.get('/get/:key', async (c) => {
const value = await c.env.MY_NAMESPACE.get(c.req.param('key'));
return value ? c.json({ value }) : c.json({ error: 'Not found' }, 404);
});
KV API Reference
Read Operations
// Get single key
const value = await env.MY_KV.get('key'); // string | null
const data = await env.MY_KV.get('key', { type: 'json' }); // object | null
const buffer = await env.MY_KV.get('key', { type: 'arrayBuffer' });
const stream = await env.MY_KV.get('key', { type: 'stream' });
// Get with cache (minimum 60s)
const value = await env.MY_KV.get('key', { cacheTtl: 300 }); // 5 min edge cache
// Bulk read (counts as 1 operation)
const values = await env.MY_KV.get(['key1', 'key2']); // Map<string, string | null>
// With metadata
const { value, metadata } = await env.MY_KV.getWithMetadata('key');
const result = await env.MY_KV.getWithMetadata(['key1', 'key2']); // Bulk with metadata
Write Operations
// Basic write (max 1/second per key)
await env.MY_KV.put('key', 'value');
await env.MY_KV.put('user:123', JSON.stringify({ name: 'John' }));
// With expiration
await env.MY_KV.put('session', data, { expirationTtl: 3600 }); // 1 hour
await env.MY_KV.put('token', value, { expiration: Math.floor(Date.now()/1000) + 86400 });
// With metadata (max 1024 bytes)
await env.MY_KV.put('config', 'dark', {
metadata: { updatedAt: Date.now(), version: 2 }
});
Critical Limits:
- Key: 512 bytes max
- Value: 25 MiB max
- Metadata: 1024 bytes max
- Write rate: 1/second per key (429 error if exceeded)
- Expiration: 60 seconds minimum
List Operations
// List with pagination
const result = await env.MY_KV.list({ prefix: 'user:', limit: 1000, cursor });
// result: { keys: [], list_complete: boolean, cursor?: string }
// CRITICAL: Always check list_complete, not keys.length === 0
let cursor: string | undefined;
do {
const result = await env.MY_KV.list({ prefix: 'user:', cursor });
processKeys(result.keys);
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
Delete Operations
// Delete single key
await env.MY_KV.delete('key'); // Always succeeds
// Bulk delete (CLI only, up to 10,000 keys)
// npx wrangler kv bulk delete --binding=MY_KV keys.json
Advanced Patterns
Caching Pattern with CacheTtl
async function getCachedData(kv: KVNamespace, key: string, fetchFn: () => Promise<any>, ttl = 300) {
const cached = await kv.get(key, { type: 'json', cacheTtl: ttl });
if (cached) return cached;
const data = await fetchFn();
await kv.put(key, JSON.stringify(data), { expirationTtl: ttl * 2 });
return data;
}
Guidelines: Minimum 60s, use for read-heavy workloads (100:1 read/write ratio)
Metadata Optimization
// Store small values (<1024 bytes) in metadata to avoid separate get() calls
await env.MY_KV.put('user:123', '', {
metadata: { status: 'active', plan: 'pro', lastSeen: Date.now() }
});
// list() returns metadata automatically (no additional get() calls)
const users = await env.MY_KV.list({ prefix: 'user:' });
users.keys.forEach(({ name, metadata }) => console.log(name, metadata.status));
Key Coalescing
// ❌ Bad: Many cold keys
await kv.put('user:123:name', 'John');
await kv.put('user:123:email', 'john@example.com');
// ✅ Good: Single hot key
await kv.put('user:123', JSON.stringify({ name: 'John', email: 'john@example.com' }));
Benefit: Cold keys benefit from hot key caching, fewer operations Trade-off: Requires read-modify-write for updates
Pagination Helper
async function* paginateKV(kv: KVNamespace, options: { prefix?: string } = {}) {
let cursor: string | undefined;
do {
const result = await kv.list({ ...options, cursor });
yield result.keys;
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
}
// Usage
for await (const keys of paginateKV(env.MY_KV, { prefix: 'user:' })) {
processKeys(keys);
}
Rate Limit Retry with Exponential Backoff
async function putWithRetry(kv: KVNamespace, key: string, value: string, opts?: KVPutOptions) {
let attempts = 0, delay = 1000;
while (attempts < 5) {
try {
await kv.put(key, value, opts);
return;
} catch (error) {
if ((error as Error).message.includes('429')) {
attempts++;
if (attempts >= 5) throw new Error('Max retry attempts');
await new Promise(r => setTimeout(r, delay));
delay *= 2; // Exponential backoff
} else throw error;
}
}
}
Understanding Eventual Consistency
KV is eventually consistent across Cloudflare's global network (Aug 2025 redesign: hybrid storage, <5ms p99 latency):
How It Works:
- Writes immediately visible in same location
- Other locations see update within ~60 seconds (or cacheTtl value)
- Cached reads may return stale data during propagation
Example:
// Tokyo: Write
await env.MY_KV.put('counter', '1');
const value = await env.MY_KV.get('counter'); // "1" ✅
// London (within 60s): May be stale ⚠️
const value2 = await env.MY_KV.get('counter'); // Might be old value
// After 60+ seconds: Consistent ✅
Use KV for: Read-heavy workloads (100:1 ratio), config, feature flags, caching, user preferences Don't use KV for: Financial transactions, strong consistency, >1/second writes per key, critical data
Need strong consistency? Use Durable Objects
Wrangler CLI Essentials
# Create namespace
npx wrangler kv namespace create MY_NAMESPACE [--preview]
# Manage keys
npx wrangler kv key put --binding=MY_KV "key" "value" [--ttl=3600] [--metadata='{}']
npx wrangler kv key get --binding=MY_KV "key"
npx wrangler kv key list --binding=MY_KV [--prefix="user:"]
npx wrangler kv key delete --binding=MY_KV "key"
# Bulk operations (up to 10,000 keys)
npx wrangler kv bulk put --binding=MY_KV data.json
npx wrangler kv bulk delete --binding=MY_KV keys.json
Limits & Quotas
| Feature | Free Plan | Paid Plan |
|---|---|---|
| Reads per day | 100,000 | Unlimited |
| Writes per day (different keys) | 1,000 | Unlimited |
| Writes per key per second | 1 | 1 |
| Operations per Worker invocation | 1,000 | 1,000 |
| Namespaces per account | 1,000 | 1,000 |
| Storage per account | 1 GB | Unlimited |
| Key size | 512 bytes | 512 bytes |
| Metadata size | 1024 bytes | 1024 bytes |
| Value size | 25 MiB | 25 MiB |
| Minimum cacheTtl | 60 seconds | 60 seconds |
Critical: 1 write/second per key (429 if exceeded), bulk operations count as 1 operation, namespace limit increased from 200 → 1,000 (Jan 2025)
Error Handling
1. Rate Limit (429 Too Many Requests)
Cause: Writing to same key >1/second Solution: Use retry with exponential backoff (see Advanced Patterns)
// ❌ Bad
await env.MY_KV.put('counter', '1');
await env.MY_KV.put('counter', '2'); // 429 error!
// ✅ Good
await putWithRetry(env.MY_KV, 'counter', '2');
2. Value Too Large
Cause: Value exceeds 25 MiB Solution: Validate size before writing
if (value.length > 25 * 1024 * 1024) throw new Error('Value exceeds 25 MiB');
3. Metadata Too Large
Cause: Metadata exceeds 1024 bytes when serialized Solution: Validate serialized size
const serialized = JSON.stringify(metadata);
if (serialized.length > 1024) throw new Error('Metadata exceeds 1024 bytes');
4. Invalid CacheTtl
Cause: cacheTtl <60 seconds Solution: Use minimum 60
// ❌ Error
await env.MY_KV.get('key', { cacheTtl: 30 });
// ✅ Correct
await env.MY_KV.get('key', { cacheTtl: 60 });
Critical Rules
Always Do ✅
- Use bulk operations when reading multiple keys (counts as 1 operation)
- Set cacheTtl for frequently-read, infrequently-updated data (min 60s)
- Store small values (<1024 bytes) in metadata when using
list()frequently - Check
list_completewhen paginating, notkeys.length === 0 - Use retry logic with exponential backoff for write operations
- Validate sizes before writing (key 512B, value 25MiB, metadata 1KB)
- Coalesce related keys for better caching performance
- Use KV for read-heavy workloads (100:1 read/write ratio ideal)
Never Do ❌
- Never write to same key >1/second (causes 429 rate limit errors)
- Never assume immediate global consistency (takes ~60 seconds to propagate)
- Never use KV for atomic operations (use Durable Objects instead)
- Never set cacheTtl <60 seconds (will fail)
- Never commit namespace IDs to public repos (use environment variables)
- Never exceed 1000 operations per invocation (use bulk operations)
- Never rely on write order (eventual consistency = no guarantees)
- Never forget to handle null values (
get()returnsnullif key doesn't exist)
Troubleshooting
Issue 1: "429 Too Many Requests" on writes
Cause: Writing to same key >1/second Solution: Consolidate writes or use retry with exponential backoff
// ❌ Bad: Rate limit
for (let i = 0; i < 10; i++) await kv.put('counter', String(i));
// ✅ Good: Single write
await kv.put('counter', '9');
// ✅ Good: Retry with backoff
await putWithRetry(kv, 'counter', String(i));
Issue 2: Stale reads after write
Cause: Eventual consistency (~60 seconds propagation) Solution: Accept stale reads, use Durable Objects for strong consistency, or implement app-level cache invalidation
Issue 3: "Operations limit exceeded"
Cause: >1000 KV operations in single Worker invocation Solution: Use bulk operations
// ❌ Bad: 5000 operations
for (const key of 5000keys) await kv.get(key);
// ✅ Good: 1 operation
const values = await kv.get(keys); // Bulk read
Issue 4: List returns empty but cursor exists
Cause: Deleted/expired keys create "tombstones"
Solution: Always check list_complete, not keys.length
// ✅ Correct pagination
let cursor: string | undefined;
do {
const result = await kv.list({ cursor });
processKeys(result.keys); // Even if empty
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
Production Checklist
- Environment-specific namespaces configured (
idvspreview_id) - Namespace IDs stored in environment variables (not hardcoded)
- Rate limit retry logic implemented for writes
- Appropriate
cacheTtlvalues set for reads (min 60s) - Sizes validated (key 512B, value 25MiB, metadata 1KB)
- Bulk operations used where possible
- Pagination with
list_completecheck (notkeys.length) - Error handling for null values
- Monitoring/alerting for rate limits
Related Documentation
Last Updated: 2025-11-24 Package Versions: wrangler@4.50.0, @cloudflare/workers-types@4.20251121.0