KV caching
KVCache is a read-through cache on top of a Workers KV namespace. Datasources create one automatically when the Worker has a KV binding (see Drizzle D1 datasources); you can also use it directly.
import {getRequestLogger, getWaitUntil, KVCache} from '@ltv/cwb';
// inside a handler, with Hono<{Bindings: Env}>
const cache = new KVCache(c.env.KV, {
defaultNamespace: 'products', // default 'cache'
defaultTtl: 300, // seconds, default 300
waitUntil: getWaitUntil(c), // keeps background writes alive after the response
logger: getRequestLogger(c), // default: root logger
});
const product = await cache.memoize('byId', {id}, () => loadProduct(id), {ttl: 60});
memoize
memoize(cacheKey, params, fetchFn, options?) returns the cached value, or runs fetchFn and caches its result.
| Option | Default | Description |
|---|---|---|
ttl | defaultTtl (300) | Seconds; raised to at least 60 (MIN_KV_TTL_SECONDS), the Workers KV minimum |
namespace | defaultNamespace (cache) | Key prefix |
revive | none | Restores a cache hit to T; see below |
- Hits include falsy values. Values are stored wrapped as
{value}, sonull,0,falseand''are real hits. Anundefinedresult is returned but never cached. - Writes run in the background. The KV
putis started without awaiting it and passed towaitUntilwhen given. A failed write is logged; the response is unaffected. - Failed reads fall back to
fetchFn(and are logged).
waitUntil inside requestsWithout waitUntil, the Workers runtime may cancel the background put once the response is sent. getWaitUntil(c) returns executionCtx.waitUntil for a Hono context, or undefined where there is no execution context (for example in some tests).
Keys
Keys look like {namespace}:{cacheKey}:{sha256(params)}, with cacheKey URI-encoded. buildKey(cacheKey, params, namespace?) returns the key without touching KV.
Params are serialized deterministically before hashing:
- object keys are sorted recursively,
undefinedfields are dropped,nullis kept; toJSON()is honored, soDateandURLparams get distinct keys;Mapentries andSetvalues are sorted;biginthas its own marker (1nand1differ).
memoize throws a TypeError for circular params and for class instances without toJSON() (two different instances would otherwise share a key).
await cache.buildKey('list', {page: 2, filter: {status: 'open'}});
// 'products:list:<64 hex characters>'
Values are JSON-only
Values go through JSON.stringify, so a hit returns only what JSON can represent:
| Stored | A hit returns |
|---|---|
Date | ISO string |
Map, Set | {} |
| class instance | plain object, prototype lost |
undefined fields | dropped |
BigInt | can't be stored: the background write fails and is logged |
A miss returns the original value, so without care the type differs between a cold and a warm cache. Cache plain JSON data, or pass revive to restore the type on hits:
import {z} from 'zod';
const Product = z.object({id: z.string(), price: z.number(), updatedAt: z.coerce.date()});
const product = await cache.memoize('byId', {id}, () => loadProduct(id), {
revive: (cached) => Product.parse(cached), // Date restored on hits
});
revive is applied to hits only. If it throws, the hit is treated as a miss and fetchFn runs.
Invalidation within the KV operation budget
Workers allow 1,000 KV operations per invocation, and every list page and delete counts. invalidate and clearAll stop at a budget (default 900, DEFAULT_MAX_KV_OPERATIONS) and tell you whether they finished.
const result = await cache.invalidate('products:byId:');
// {deleted: 412, complete: true, cursor: undefined, failed: false}
await cache.clearAll('products'); // same as invalidate('products:')
await cache.clearAll(); // every key in the KV namespace
| Field | Meaning |
|---|---|
deleted | Keys deleted by this call |
complete | true when no matching keys remain and every delete succeeded |
cursor | Pass back as options.cursor when complete is false |
failed | A list or delete call failed; after failed deletes, cursor points back to where the call started so those keys are retried |
Options: {cursor?, maxOperations?}. A maxOperations below 2 throws a RangeError (one list call plus at least one delete is needed to make progress).
Continuing in later invocations
Calling invalidate again in the same invocation would exceed the per-invocation limit. Continue in a new invocation instead, for example from a Queue consumer that re-enqueues itself with the cursor:
import {KVCache} from '@ltv/cwb';
import {app} from './app';
interface PurgeMessage {
prefix: string;
cursor?: string;
}
// Env from `wrangler types`: KV binding plus a PURGE_QUEUE producer binding.
// Configure the consumer with max_batch_size: 1, so each invocation handles one purge step.
export default {
fetch: app.fetch,
async queue(batch, env) {
const cache = new KVCache(env.KV);
for (const message of batch.messages) {
const {prefix, cursor} = message.body;
const result = await cache.invalidate(prefix, {cursor});
if (!result.complete) {
await env.PURGE_QUEUE.send(
{prefix, cursor: result.cursor},
{delaySeconds: result.failed ? 30 : 0} // back off after KV failures
);
}
message.ack();
}
},
} satisfies ExportedHandler<Env, PurgeMessage>;
Start a purge from a request with await c.env.PURGE_QUEUE.send({prefix: 'products:'}). A Cron Trigger that stores the cursor between runs works the same way.
Eventual consistency
KV is eventually consistent. list results and deletes can take about 60 seconds to reach every location, so other locations may still serve removed keys for a while. A request that read stale data before an invalidation can also re-cache it through its background write, for the full TTL.
For data that must refresh promptly, keep TTLs short, or change the namespace (for example products:v2) when the data shape or source changes, instead of relying on invalidation.
Reference
| Member | Description |
|---|---|
new KVCache(kv, options?) | options: defaultNamespace, defaultTtl, waitUntil, logger (KVCacheOptions) |
buildKey(cacheKey, params, namespace?) | The KV key for a call |
memoize(cacheKey, params, fetchFn, options?) | Read-through caching (CacheOptions<T>) |
invalidate(prefix, options?) | Budgeted delete by prefix; takes InvalidateOptions, resolves to InvalidateResult |
clearAll(namespace?, options?) | Budgeted delete of a namespace, or of everything |
Constants: MIN_KV_TTL_SECONDS (60), DEFAULT_MAX_KV_OPERATIONS (900).