| name | cloudflare-kv |
| description | 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, cacheTtl errors, wrangler types issues, or remote binding configuration.
|
| user-invocable | true |
Cloudflare Workers KV
Status: Production Ready ✅
Last Updated: 2026-01-20
Dependencies: cloudflare-worker-base (for Worker setup)
Latest Versions: wrangler@4.59.2, @cloudflare/workers-types@4.20260109.0
Recent Updates (2025):
- August 2025: Architecture redesign (40x performance gain, <5ms p99 latency, hybrid storage with R2)
- April 2025: Bulk reads API (retrieve up to 100 keys in single request, counts as 1 operation)
- January 2025: Namespace limit increased (200 → 1,000 namespaces per account for Free and Paid plans)
Quick Start (5 Minutes)
npx wrangler kv namespace create MY_NAMESPACE
wrangler.jsonc:
{
"kv_namespaces": [{
"binding": "MY_NAMESPACE",
"id": "<production-uuid>",
"preview_id": "<preview-uuid>"
}]
}
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
const value = await env.MY_KV.get('key');
const data = await env.MY_KV.get('key', { type: 'json' });
const buffer = await env.MY_KV.get('key', { type: 'arrayBuffer' });
const stream = await env.MY_KV.get('key', { type: 'stream' });
const value = await env.MY_KV.get('key', { cacheTtl: 300 });
const values = await env.MY_KV.get(['key1', 'key2']);
const { value, metadata } = await env.MY_KV.getWithMetadata('key');
const result = await env.MY_KV.getWithMetadata(['key1', 'key2']);
Write Operations
await env.MY_KV.put('key', 'value');
await env.MY_KV.put('user:123', JSON.stringify({ name: 'John' }));
await env.MY_KV.put('session', data, { expirationTtl: 3600 });
await env.MY_KV.put('token', value, { expiration: Math.floor(Date.now()/1000) + 86400 });
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
const result = await env.MY_KV.list({ prefix: 'user:', limit: 1000, cursor });
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
await env.MY_KV.delete('key');
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
await env.MY_KV.put('user:123', '', {
metadata: { status: 'active', plan: 'pro', lastSeen: Date.now() }
});
const users = await env.MY_KV.list({ prefix: 'user:' });
users.keys.forEach(({ name, metadata }) => console.log(name, metadata.status));
Understanding Hot vs Cold Keys
KV performance varies based on key temperature:
| Type | Response Time | When It Happens |
|---|
| Hot keys | 6-8ms | Read 2+ times/minute per datacenter |
| Cold keys | 100-300ms | Infrequently accessed, fetched from central storage |
Post-August 2025 Improvements:
- P90 for all KV Worker invocations: <12ms (was 22ms before)
- Hot reads up to 3x faster
- All operations faster by up to 20ms
Optimization: Use key coalescing to make cold keys benefit from hot key caching:
await kv.put('user:123:name', 'John');
await kv.put('user:123:email', 'john@example.com');
await kv.put('user:123:plan', 'pro');
const name = await kv.get('user:123:name');
const email = await kv.get('user:123:email');
const plan = await kv.get('user:123:plan');
await kv.put('user:123', JSON.stringify({
name: 'John',
email: 'john@example.com',
plan: 'pro'
}));
const user = JSON.parse(await kv.get('user:123'));
CacheTtl helps cold keys: For infrequently-read data, cacheTtl reduces cold read latency.
Trade-off: Coalescing 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);
}
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;
} 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 (read-your-own-write consistency within same POP)
- Other locations see update within ~60 seconds (or cacheTtl value)
- Cached reads may return stale data during propagation
Example:
await env.MY_KV.put('counter', '1');
const value = await env.MY_KV.get('counter');
const value2 = await env.MY_KV.get('counter');
Read-Your-Own-Write (RYOW) Guarantee: Since August 2025 redesign, requests routed through the same Cloudflare point of presence see their own writes immediately. Global consistency across different POPs still takes up to 60 seconds.
Timestamp Mitigation Pattern (for critical consistency needs):
const timestamp = Date.now();
await kv.put(`user:123:${timestamp}`, userData);
const result = await kv.list({ prefix: 'user:123:' });
const latestKey = result.keys.sort((a, b) =>
parseInt(b.name.split(':')[2]) - parseInt(a.name.split(':')[2])
).at(0);
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
Source: Redesigning Workers KV
Wrangler CLI Essentials
npx wrangler kv namespace create MY_NAMESPACE [--preview]
npx wrangler kv key put --binding=MY_KV "key" "value" [--ttl=3600] [--metadata='{}']
npx wrangler kv key get --binding=MY_KV "key" [--remote]
npx wrangler kv key list --binding=MY_KV [--prefix="user:"] [--remote]
npx wrangler kv key delete --binding=MY_KV "key"
npx wrangler kv bulk put --binding=MY_KV data.json
npx wrangler kv bulk delete --binding=MY_KV keys.json
IMPORTANT: CLI commands default to local storage. Add --remote flag to access production/remote data.
Development vs Production
Remote Bindings for Local Development (Wrangler 4.37+)
Connect local Workers to production KV namespaces during development:
wrangler.jsonc:
{
"kv_namespaces": [{
"binding": "MY_KV",
"id": "production-uuid",
"remote": true
}]
}
How It Works:
- Local Worker code executes locally (fast iteration)
- KV operations route to production namespace through proxy
- No manual data seeding required
Benefits:
- Test against real production data without deploying
- Fast local code execution with production data access
- Faster feedback loop (no deploy-test cycle)
⚠️ Warning: Writes affect production data. Consider using a staging namespace with remote: true instead of production.
Version Support:
- Wrangler 4.37.0+
- @cloudflare/vite-plugin 1.13.0+
- @cloudflare/vitest-pool-workers 0.9.0+
Source: Remote bindings architecture
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)
await env.MY_KV.put('counter', '1');
await env.MY_KV.put('counter', '2');
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
await env.MY_KV.get('key', { cacheTtl: 30 });
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_complete when paginating, not keys.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() returns null if 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
for (let i = 0; i < 10; i++) await kv.put('counter', String(i));
await kv.put('counter', '9');
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
for (const key of 5000keys) await kv.get(key);
const values = await kv.get(keys);
Issue 4: List returns empty but cursor exists
Cause: Deleted/expired keys create "tombstones" that must be iterated through
Solution: Always check list_complete, not keys.length
let cursor: string | undefined;
do {
const result = await kv.list({ cursor });
processKeys(result.keys);
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
CRITICAL: When using prefix, you must include it in all paginated calls:
let result = await kv.list({ prefix: 'user:' });
result = await kv.list({ cursor: result.cursor });
let cursor: string | undefined;
do {
const result = await kv.list({ prefix: 'user:', cursor });
processKeys(result.keys);
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
Source: List keys documentation
Issue 5: wrangler types Does Not Generate Types for Environment-Nested KV Bindings
Cause: KV namespaces defined within environment configurations (e.g., [env.feature.kv_namespaces]) are not included in generated TypeScript types
Impact: Loss of TypeScript autocomplete and type checking for KV bindings
Source: GitHub Issue #9709
Example Configuration:
[env.feature]
name = "my-worker-feature"
[[env.feature.kv_namespaces]]
binding = "MY_STORAGE_FEATURE"
id = "xxxxxxxxxxxx"
Running npx wrangler types creates type definitions for environment variables but not for the KV namespace bindings.
Workaround:
npx wrangler types -e feature
Or define KV namespaces at top level instead of nested in environments:
[[kv_namespaces]]
binding = "MY_STORAGE"
id = "xxxxxxxxxxxx"
Note: Runtime bindings still work correctly; this only affects type generation.
Issue 6: wrangler kv key list Returns Empty Array for Remote Data
Cause: CLI commands default to local storage, not remote/production KV
Impact: Users expect to see production data but get empty array from local storage
Source: GitHub Issue #10395
Solution: Use --remote flag to access production/remote data
npx wrangler kv key list --binding=MY_KV
npx wrangler kv key list --binding=MY_KV --remote
Why This Happens: By design, wrangler dev uses local KV storage to avoid interfering with production data. CLI commands follow the same default for consistency.
Applies to: All wrangler kv key commands (get, list, delete, put)
Production Checklist
Related Documentation
Last Updated: 2026-01-20
Package Versions: wrangler@4.59.2, @cloudflare/workers-types@4.20260109.0
Changes: Added 6 research findings - hot/cold key performance patterns, remote bindings (Wrangler 4.37+), wrangler types environment issue, CLI --remote flag requirement, RYOW consistency details, prefix persistence in pagination