Skip to main content

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.

src/modules/products/product-get.route.ts
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.

OptionDefaultDescription
ttldefaultTtl (300)Seconds; raised to at least 60 (MIN_KV_TTL_SECONDS), the Workers KV minimum
namespacedefaultNamespace (cache)Key prefix
revivenoneRestores a cache hit to T; see below
  • Hits include falsy values. Values are stored wrapped as {value}, so null, 0, false and '' are real hits. An undefined result is returned but never cached.
  • Writes run in the background. The KV put is started without awaiting it and passed to waitUntil when given. A failed write is logged; the response is unaffected.
  • Failed reads fall back to fetchFn (and are logged).
Pass waitUntil inside requests

Without 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, undefined fields are dropped, null is kept;
  • toJSON() is honored, so Date and URL params get distinct keys;
  • Map entries and Set values are sorted; bigint has its own marker (1n and 1 differ).

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:

StoredA hit returns
DateISO string
Map, Set{}
class instanceplain object, prototype lost
undefined fieldsdropped
BigIntcan'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
FieldMeaning
deletedKeys deleted by this call
completetrue when no matching keys remain and every delete succeeded
cursorPass back as options.cursor when complete is false
failedA 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:

src/index.ts
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.

Prefer short TTLs or versioned namespaces

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

MemberDescription
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).