KV Patterns
KV Patterns
@workkit/kv wraps Cloudflare Workers KV with typed get/put, automatic serialization, key prefixing, batch operations, and ergonomic error handling.
Quick Start
import { kv } from '@workkit/kv'
interface User { id: string name: string role: 'admin' | 'user'}
const users = kv<User>(env.USERS_KV, { prefix: 'user:', defaultTtl: 3600, // 1 hour})
await users.put('alice', { id: 'alice', name: 'Alice', role: 'admin' })const user = await users.get('alice') // User | nullCreating a Client
import { kv } from '@workkit/kv'
// Basic -- JSON serialization, no prefixconst store = kv<MyType>(env.MY_KV)
// With all optionsconst store = kv<MyType>(env.MY_KV, { prefix: 'cache:', // auto-prepended to all keys defaultTtl: 3600, // default TTL in seconds defaultCacheTtl: 60, // edge cache TTL for reads serializer: 'json', // 'json' | 'text' | 'arrayBuffer' | 'stream'})Core Operations
Get
const value = await store.get('key') // T | null
// Override cache TTL per requestconst fresh = await store.get('key', { cacheTtl: false }) // bypass edge cacheconst cached = await store.get('key', { cacheTtl: 300 }) // cache for 5 minGet with Metadata
interface UserMeta { lastLogin: number }
const result = await store.getWithMetadata<UserMeta>('alice')// result.value: User | null// result.metadata: UserMeta | null// result.cacheStatus: string | nullPut
// Use default TTLawait store.put('key', value)
// Override TTLawait store.put('key', value, { ttl: 7200 }) // 2 hours
// Absolute expirationawait store.put('key', value, { expiration: Math.floor(Date.now() / 1000) + 86400, // tomorrow})
// Attach metadataawait store.put('key', value, { metadata: { source: 'api', version: 2 },})Delete
await store.delete('key')Has
Check key existence without reading the value:
const exists = await store.has('key') // booleanBatch Operations
Get Many
Fetch multiple keys in parallel:
const results = await store.getMany(['alice', 'bob', 'charlie'])// results is Map<string, User>
for (const [key, user] of results) { console.log(key, user.name)}Put Many
Write multiple entries:
await store.putMany([ { key: 'alice', value: { id: 'alice', name: 'Alice', role: 'admin' } }, { key: 'bob', value: { id: 'bob', name: 'Bob', role: 'user' } },])
// Per-entry optionsawait store.putMany([ { key: 'temp', value: data, options: { ttl: 60 } }, { key: 'permanent', value: data }, // uses defaultTtl])Delete Many
await store.deleteMany(['old-key-1', 'old-key-2', 'old-key-3'])Listing Keys
Async Iterator
list() returns an AsyncIterable that auto-paginates:
for await (const entry of store.list()) { console.log(entry.name) // key name (prefix stripped) console.log(entry.expiration) // unix timestamp or undefined console.log(entry.metadata) // attached metadata or undefined}
// Filter by sub-prefixfor await (const entry of store.list({ prefix: 'active:' })) { // Only keys matching "user:active:*" (namespace prefix + filter prefix)}Collect All Keys
const allKeys = await store.listKeys()// KVListEntry[]Pattern: Caching
const cache = kv<ApiResponse>(env.CACHE_KV, { prefix: 'api:', defaultTtl: 300, // 5 min TTL defaultCacheTtl: 60, // 1 min edge cache})
async function getWithCache(endpoint: string): Promise<ApiResponse> { const cached = await cache.get(endpoint) if (cached) return cached
const response = await fetchFromApi(endpoint) await cache.put(endpoint, response) return response}Pattern: Sessions
interface SessionData { userId: string role: string expiresAt: number}
const sessions = kv<SessionData>(env.SESSION_KV, { prefix: 'session:', defaultTtl: 86400, // 24 hours})
// Create sessionconst sessionId = crypto.randomUUID()await sessions.put(sessionId, { userId: 'user-123', role: 'admin', expiresAt: Date.now() + 86400000,})
// Lookup sessionconst session = await sessions.get(sessionId)if (!session || session.expiresAt < Date.now()) { // expired or not found}
// Destroy sessionawait sessions.delete(sessionId)Pattern: Feature Flags
interface FeatureFlag { enabled: boolean rolloutPercent: number allowlist: string[]}
const flags = kv<FeatureFlag>(env.FLAGS_KV, { prefix: 'flag:', defaultCacheTtl: 30, // cache flags at edge for 30s})
async function isEnabled(flag: string, userId: string): Promise<boolean> { const config = await flags.get(flag) if (!config) return false if (!config.enabled) return false if (config.allowlist.includes(userId)) return true
// Deterministic rollout based on user ID const hash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(`${flag}:${userId}`), ) const value = new DataView(hash).getUint32(0) / 0xffffffff * 100 return value < config.rolloutPercent}Pattern: Rate Counter
const counters = kv<number>(env.COUNTER_KV, { prefix: 'count:', serializer: 'text', // numbers are serialized as text in KV})
// Note: KV does not support atomic increments.// For strict counters, use Durable Objects.// This pattern works for approximate counters.async function increment(key: string): Promise<number> { const current = await counters.get(key) ?? 0 const next = current + 1 await counters.put(key, next) return next}Key Utilities
import { validateKey, prefixKey, stripPrefix } from '@workkit/kv'
// Validate a key (throws on empty or invalid)validateKey('my-key')
// Manual prefix operationsconst full = prefixKey('user:', 'alice') // 'user:alice'const bare = stripPrefix('user:', 'user:alice') // 'alice'Error Handling
KV errors are wrapped with context:
import { wrapKVError, assertKVBinding, assertValidTtl } from '@workkit/kv'
// These are used internally but available for custom KV logic:assertKVBinding(env.MY_KV) // throws BindingNotFoundError if nullassertValidTtl(30) // throws if TTL < 60 (KV minimum)Raw Access
const store = kv<User>(env.USERS_KV)const raw = store.raw // KVNamespace
// Use raw KV API directlyconst value = await raw.get('my-key', 'text')