KV Storage
Cloudflare Workers KV is a global key-value store optimized for read-heavy workloads. Quickback wires KV in as Better Auth's secondaryStorage (a session cache in front of the auth database), as the JWT-revocation store, and as a general-purpose store for caching and fast lookups.
What is KV?
Workers KV is a distributed key-value store:
- Global distribution - Data replicated to 300+ edge locations
- Low-latency reads - Cached at the edge, sub-millisecond access
- Eventually consistent - Writes propagate globally within 60 seconds
- Simple API - Get, put, delete, list
Use Cases in Quickback
| Use Case | Description |
|---|---|
| Session cache | Wired as Better Auth's secondaryStorage — a read-through cache in front of the auth database |
| JWT revocation | The store behind auth.jwt.revocationCheck: 'kv' |
| Cache | Cache expensive database queries or API responses |
| Rate Limiting | Track request counts per user/IP |
| Feature Flags | Store configuration that changes infrequently |
Namespace Setup
Create a KV namespace via Wrangler:
# Create namespace
wrangler kv namespace create "KV"
# Output:
# Add the following to your wrangler.toml:
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Setting the namespace ID in config
To have the compiler generate wrangler.toml with your KV namespace ID, set it in your provider config:
providers: {
storage: defineStorage("cloudflare-kv", {
binding: "KV", // default
namespaceId: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
}),
}The registered storage provider names are cloudflare-kv, cloudflare-r2, and memory — anything else throws at config time.
The namespaceId can also be set as kvId on the auth provider config, or as kvNamespaceId on the database config, since KV is primarily used by the auth layer. The compiler checks all of them.
Reading and Writing Values
Basic Operations
// Write a value
await env.KV.put("user:123", JSON.stringify({ name: "Alice" }));
// Read a value
const data = await env.KV.get("user:123");
const user = data ? JSON.parse(data) : null;
// Delete a value
await env.KV.delete("user:123");
// Check if key exists
const exists = await env.KV.get("user:123") !== null;With Metadata
KV supports metadata attached to keys:
// Write with metadata
await env.KV.put("user:123", JSON.stringify(userData), {
metadata: { updatedAt: Date.now(), version: 1 },
});
// Read with metadata
const { value, metadata } = await env.KV.getWithMetadata("user:123");List Keys
// List all keys with prefix
const list = await env.KV.list({ prefix: "user:" });
for (const key of list.keys) {
console.log(key.name, key.metadata);
}
// Paginate through results
let cursor = undefined;
do {
const result = await env.KV.list({ prefix: "user:", cursor });
// Process result.keys
cursor = result.cursor;
} while (cursor);Expiration and TTL
Set automatic expiration on keys:
// Expire in 1 hour (3600 seconds)
await env.KV.put("session:abc", sessionData, {
expirationTtl: 3600,
});
// Expire at specific timestamp
await env.KV.put("session:abc", sessionData, {
expiration: Math.floor(Date.now() / 1000) + 3600,
});Common TTL patterns:
| Use Case | TTL |
|---|---|
| Session tokens | 24 hours (86400) |
| API cache | 5 minutes (300) |
| Rate limit counters | 1 minute (60) |
| Feature flags | No expiration |
Session Cache
There is no session.storage option. Configuring a KV storage provider is the
whole switch: the compiler then generates Better Auth's secondaryStorage
adapter over the KV binding, and Better Auth uses it as a read-through cache in
front of the auth database. Keys, values, and TTLs are Better Auth's — Quickback
only supplies get/set/delete (with KV's 60-second minimum TTL applied on
writes).
defineAuth("better-auth", { session: … }) accepts only expiresInDays and
updateAgeInDays:
auth: defineAuth("better-auth", {
session: { expiresInDays: 30, updateAgeInDays: 7 },
}),Caching Patterns
Cache-Aside Pattern
async function getUser(userId: string) {
// Check cache first
const cached = await env.KV.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// Fetch from database
const user = await db.select().from(users).where(eq(users.id, userId));
// Store in cache
await env.KV.put(`user:${userId}`, JSON.stringify(user), {
expirationTtl: 300, // 5 minutes
});
return user;
}Cache Invalidation
// Invalidate on update
async function updateUser(userId: string, data: UserUpdate) {
await db.update(users).set(data).where(eq(users.id, userId));
await env.KV.delete(`user:${userId}`);
}wrangler.toml Reference
The compiler emits one namespace, bound as KV:
[[kv_namespaces]]
binding = "KV"
id = "abc123..."With deployment environments configured, the same block is emitted once per
environment as [[env.<name>.kv_namespaces]] instead.
Limitations
- Value size - Maximum 25 MB per value
- Key size - Maximum 512 bytes
- Write limits - 1 write per second per key
- Eventual consistency - Changes may take up to 60 seconds to propagate
- Not for hot writes - Use Durable Objects for high-write scenarios
KV is optimized for read-heavy workloads. For write-heavy use cases or strong consistency, consider Durable Objects.