KV Recipes
KV is a natural fit for data that is short-lived, accessed by key, and does not need relational queries. Each recipe below is self-contained — pick the one that matches your use case, paste it into a serverless function, and adjust the key names and TTLs for your app.
All examples use ctx.kv inside a function handler. See Key-Value Store for a full method reference.
Session store
Section titled “Session store”When you need per-user state that lives for a bounded time — auth sessions, shopping carts, wizard progress — KV is the simplest choice. Values are stored as JSON, scoped to your app, and expire automatically so you do not need a cleanup job.
export default async function handler(req, ctx) { // Read an existing session const sessionId = req.headers.get('x-session-id'); const session = await ctx.kv.get<{ userId: string; role: string }>( `session:${sessionId}`, ); if (!session) { return new Response('Unauthorized', { status: 401 }); } // session.userId and session.role are available here return Response.json({ userId: session.userId });}
// On login — write a session with a 24-hour TTLasync function createSession(ctx, sessionId: string, userId: string, role: string) { await ctx.kv.set( `session:${sessionId}`, { userId, role }, { ttl: 60 * 60 * 24 }, );}
// On logout — delete immediately rather than waiting for expiryasync function destroySession(ctx, sessionId: string) { await ctx.kv.del(`session:${sessionId}`);}TTL is fixed at write time and does not slide automatically. If you want sliding sessions (i.e., reset the clock on every request), call ctx.kv.expire(key, seconds) after a successful get to push the expiry forward. Keep session blobs small — store a user ID and role, not the full user record. For large payloads consider File Storage.
# Inspect a live session; force sign-out a specific user without waiting for TTLbutterbase kv get session:<id>butterbase kv del session:<id>Distributed lock
Section titled “Distributed lock”Use a distributed lock when two workers must not run the same job simultaneously — for example, processing a webhook, sending an email, or charging a card. setnx sets the key only if it does not already exist and returns true when the lock is acquired.
export default async function handler(req, ctx) { const { orderId } = await req.json(); const lockKey = `lock:order:${orderId}`;
// Attempt to acquire — lock expires in 30 s if the worker crashes const acquired = await ctx.kv.setnx(lockKey, ctx.requestId, { ttl: 30 }); if (!acquired) { return Response.json({ error: 'already_processing' }, { status: 409 }); }
try { // ... do the exclusive work here ... return Response.json({ status: 'done' }); } finally { // Release only if we still own the lock (guards against TTL expiry race) const swapped = await ctx.kv.cas(lockKey, ctx.requestId, null); if (swapped) { await ctx.kv.del(lockKey); } }}The TTL is a safety net, not a hard deadline — if your worker crashes before the finally block, the lock releases automatically when the TTL elapses. Keep your work duration well under the TTL. If work could legitimately take longer than 30 seconds, increase the TTL or implement lock renewal with ctx.kv.expire. Do not use this pattern for human-facing critical sections where the user needs immediate feedback on contention — design around idempotency (see below) instead.
# Inspect who holds the lock; force-release if a worker crashed without TTL covering itbutterbase kv get lock:order:<id>butterbase kv del lock:order:<id>Rate limiter
Section titled “Rate limiter”A fixed-window counter is the simplest way to cap actions per user per time period. The key includes a timestamp bucket so it resets automatically when the window rolls over.
export default async function handler(req, ctx) { const userId = req.headers.get('x-user-id') ?? 'anonymous'; const windowMinute = Math.floor(Date.now() / 60_000); const key = `ratelimit:${userId}:${windowMinute}`;
const count = await ctx.kv.incr(key); // Set the TTL on the first increment so the key expires after the window if (count === 1) { await ctx.kv.expire(key, 60); } if (count > 100) { return new Response('Rate limit exceeded', { status: 429 }); }
// ... handle the request ... return Response.json({ remaining: 100 - count });}If you want frontend code to call a rate-limiter endpoint directly, you can open the keyspace with expose():
// Call once during app setup or in a migration functionawait ctx.kv.expose('ratelimit:{user.id}:*', { read: 'owner', write: 'owner' });expose() is the only KV method that controls client-side access. See Access control for the full role model and pattern syntax rather than repeating it here.
Fixed-window counters are simple but allow a burst of up to 2× the limit at window boundaries (requests at the tail of one window plus the head of the next). For most APIs this is acceptable. If you need smoother enforcement, use a sliding-window approach with a sorted-set or store a small list of timestamps — or just halve your limit.
# Inspect the running request count for a user in the current time windowbutterbase kv get ratelimit:<user>:<window>Idempotency keys
Section titled “Idempotency keys”When a client retries a request (network timeout, duplicate submit), you need to ensure the work happens exactly once and the second call returns the same result as the first. setnx claims ownership of a request ID atomically, so only one execution proceeds.
export default async function handler(req, ctx) { const requestId = req.headers.get('x-idempotency-key'); if (!requestId) { return new Response('Missing X-Idempotency-Key header', { status: 400 }); }
const idemKey = `idempotent:${requestId}`;
// Try to claim this request ID const claimed = await ctx.kv.setnx(idemKey, 'pending', { ttl: 60 * 60 * 24 });
if (!claimed) { // Already processed (or in progress) — return cached result const cached = await ctx.kv.get<string>(idemKey); if (cached && cached !== 'pending') { return new Response(cached, { status: 200, headers: { 'content-type': 'application/json' }, }); } // Still pending — tell client to retry shortly return new Response('Request in progress', { status: 409 }); }
// We own it — do the work const result = await doTheWork();
// Store the serialised result so duplicates can return it await ctx.kv.set(idemKey, JSON.stringify(result), { ttl: 60 * 60 * 24 });
return Response.json(result);}
async function doTheWork() { // your business logic here return { charged: true, amount: 4900 };}The TTL controls how long duplicate detection lasts — make it longer than your client’s retry window. If your work function throws, the key stays set to 'pending' indefinitely; add error handling that either stores the error response or deletes the key so the client can retry cleanly.
# Verify whether a specific request ID has already been claimedbutterbase kv get idempotent:<request-id>Feature flags
Section titled “Feature flags”Store a flag value in KV and read it at request time. Changes take effect on the next request without a redeployment.
export default async function handler(req, ctx) { // Read flag — default to 'off' if key is missing const flagValue = await ctx.kv.get<string>('feature:new-checkout'); const newCheckoutEnabled = flagValue === 'on';
if (newCheckoutEnabled) { // new checkout path return Response.json({ checkout: 'v2' }); } else { // legacy path return Response.json({ checkout: 'v1' }); }}Flip the flag without touching code:
# CLIbutterbase kv set feature:new-checkout onbutterbase kv set feature:new-checkout offOr via MCP (manage_kv with action: 'set'), or directly in the Butterbase dashboard under KV → your app.
For a percentage rollout, store a threshold (e.g., '30' for 30 %) and check it against a deterministic hash of the user ID:
const threshold = parseInt((await ctx.kv.get<string>('rollout:new-checkout')) ?? '0', 10);const bucket = hashUserId(userId) % 100; // your own stable hash functionconst enabled = bucket < threshold;This gives a stable assignment per user — the same user always sees the same variant — and you can gradually raise the threshold to roll out to a wider audience.
# Flip a flag instantly without redeploying; read back to confirm the new valuebutterbase kv set feature:<name> onbutterbase kv get feature:<name>