KV (Key-Value)
Cloudflare KV namespace usage patterns
Overview
KV is a global, low-latency key-value store. It is eventually consistent -- writes propagate globally within ~60 seconds, but reads may return stale data during that window.
Setup
Create a Namespace
npx wrangler kv namespace create "MY_KV"This outputs the namespace ID. Add it to wrangler.toml:
[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456ghi789"Usage in Functions
interface Env {
MY_KV: KVNamespace;
}
// Read
const value = await env.MY_KV.get("key");
const json = await env.MY_KV.get("key", { type: "json" });
// Write
await env.MY_KV.put("key", "value");
await env.MY_KV.put("key", JSON.stringify(data));
// Write with expiration (TTL in seconds)
await env.MY_KV.put("key", "value", { expirationTtl: 3600 });
// Delete
await env.MY_KV.delete("key");
// List keys
const list = await env.MY_KV.list({ prefix: "logs:" });Real-World Pattern: Keyword Logs
From our zpaper project -- logging search keywords to KV:
interface Env {
KEYWORD_LOGS: KVNamespace;
}
export const onRequestGet: PagesFunction<Env> = async (context) => {
const url = new URL(context.request.url);
const query = url.searchParams.get("q")?.trim();
if (query) {
// Log the keyword asynchronously (don't block the response)
const key = `search:${Date.now()}:${crypto.randomUUID()}`;
context.waitUntil(
context.env.KEYWORD_LOGS.put(key, JSON.stringify({
query,
timestamp: new Date().toISOString(),
}), { expirationTtl: 86400 * 30 }) // 30 days
);
}
// ... return search results
};Use waitUntil for Non-Critical Writes
context.waitUntil() lets you perform async work after the response is sent. Use it for logging, analytics, and other non-critical writes.
Pattern: Short-Lived Conversation State
Store small bot, session, or cache state per thread with automatic TTL-based cleanup:
Do not use KV as the default durable chat database
KV is fine for short-lived TTL bot/session/cache state. For durable chat history, authorization-aware reads, and auditability, D1 is usually a better default. See Chat Memory and RAG for the broader chat storage architecture.
const CONVERSATION_TTL = 86400; // 24 hours
interface ConversationHistory {
messages: Array<{ role: string; content: string }>;
}
// Load with typed JSON deserialization
const key = `conv:${threadId}`;
const stored = await env.KV.get<ConversationHistory>(key, "json");
const history = stored ?? { messages: [] };
// Append new message
history.messages.push({ role: "user", content: userMessage });
// Save with TTL -- old conversations auto-expire
await env.KV.put(key, JSON.stringify(history), {
expirationTtl: CONVERSATION_TTL,
});Key design points:
Use a structured key like
conv:{threadId}for easy identificationThe
"json"type parameter onget()handles deserialization automaticallyTTL keeps this pattern focused on short-lived state instead of durable history
Pattern: Rate Limiting
Per-user rate limiting using KV counters with TTL expiration:
const RATE_LIMIT = 30;
const RATE_WINDOW = 86400; // 24 hours
async function checkRateLimit(
env: Env,
userId: string,
): Promise<boolean> {
const key = `rate:${userId}`;
const count = await env.KV.get<number>(key, "json") ?? 0;
if (count >= RATE_LIMIT) return false;
await env.KV.put(key, JSON.stringify(count + 1), {
expirationTtl: RATE_WINDOW,
});
return true;
}KV rate limiting is approximate
KV is eventually consistent, so under high concurrency a few extra requests may pass through. This is fine for bot/API rate limiting. For precise counting, use Durable Objects.
Pattern: Reverse-Chronological Feeds
Building a "recent activity" or "latest posts" feed on KV means solving two problems: getting the ordering right, and not paying an unbounded fan-out cost to render it.
Sortable keys
Give every feed item a key with an ISO-8601 timestamp so lexicographic key order matches chronological order:
const key = `feed:${new Date().toISOString()}:${crypto.randomUUID()}`;
await env.FEED.put(key, JSON.stringify(item));toISOString() is fixed-width and zero-padded (2026-08-12T03:15:22.123Z), so string comparison and time comparison agree -- unlike Date.now() without padding or a random UUID, neither of which sorts chronologically.
That gets chronological order, not reverse-chronological. list() only walks forward from the start of a prefix range -- there is no "start from the end" cursor. Left as plain ascending ISO-8601, the newest items sit at the tail of an ever-growing prefix, and reaching them means paging through the entire history first. Invert the timestamp instead, so the newest write is the smallest key and the first result from a plain list():
// Newest-first: invert the timestamp so ascending list() order is descending time order.
const REVERSE_EPOCH_MS = 9999999999999; // safely past any real Date.now()
function feedKey(id: string, when = new Date()): string {
const reversed = String(REVERSE_EPOCH_MS - when.getTime()).padStart(13, "0");
return `feed:${reversed}:${id}`;
}The fan-out cost
list() only returns key names and metadata -- never values. Rendering N feed items means N follow-up get() calls, and that fan-out, not the list itself, is the real cost.
Layer the bound in three steps so a spike in feed volume, or a run of items that get filtered out, never turns into an unbounded burst of get() calls:
interface FeedItem {
hidden: boolean;
// ...other fields
}
async function getFeed(env: Env, displayLimit = 6): Promise<FeedItem[]> {
// 1. List a candidate pool, oversized to absorb items filtered out in step 3.
const { keys } = await env.FEED.list({ prefix: "feed:", limit: 40 });
// 2. Bound the fan-out: fetch at most 20 bodies, regardless of how many
// candidates step 1 returned.
const candidates = keys.slice(0, 20);
const items = await Promise.all(
candidates.map((k) => env.FEED.get<FeedItem>(k.name, "json")),
);
// 3. Bound what actually reaches the UI.
return items
.filter((item): item is FeedItem => item !== null && !item.hidden)
.slice(0, displayLimit);
}40 -- the
list()call, sized with slack for items step 3 will drop (hidden, malformed, tombstoned)20 -- the hard ceiling on
get()fan-out, independent of how many keys step 1 returned. This is the number that sets your worst-case subrequest count and CPU time, so pick it deliberately.6 -- what the feed actually displays, kept separate from the fan-out ceiling so a UI change (show 10 instead of 6) never touches the fetch-cost budget.
Each layer absorbs loss from the layer below it -- list results that get filtered out, fetched items that turn out hidden -- without letting that loss silently balloon the number of KV operations.
Gotchas
Eventually consistent: Reads immediately after writes may return stale data
No compare-and-swap, and negative lookups are cached:
put()always overwrites unconditionally -- there is no atomic check-and-set, so two writers can each see a key as absent and both write, with no signal of the collision. Aget()miss can also be cached at the edge, so a lookup can keep returning "not found" for a moment after another writer'sput()for that key has already landed. Don't build dedupe, locking, or claim logic on KV -- that belongs in D1's atomic dedupe & claims, which has the primitives (ON CONFLICT,changes()) this requires.512-byte key limit: Keys cannot exceed 512 bytes
25 MiB value limit: Values cannot exceed 25 MiB
List pagination:
list()returns up to 1000 keys per call; use thecursorfor pagination