Skip to content

Caching

Stratal provides a CacheModule that wraps Cloudflare Workers KV with a type-safe API for get, put, delete, and list operations. It supports multiple data types, TTL-based expiration, key metadata, cursor pagination, and multiple KV namespaces.

Add a KV namespace binding to your wrangler.jsonc:

{
"kv_namespaces": [
{
"binding": "CACHE",
"id": "your-kv-namespace-id",
"preview_id": "your-kv-preview-id"
}
]
}

The CACHE binding is expected by default. It is declared in the StratalEnv interface so it is available throughout your application.

The CacheModule is auto-registered as a core module, so you do not need to import it manually. The CacheService is available for injection right away:

import { Transient, inject } from 'stratal/di'
import { CACHE_TOKENS, type CacheService } from 'stratal/cache'
@Transient()
export class SessionService {
constructor(
@inject(CACHE_TOKENS.CacheService) private readonly cache: CacheService,
) {}
async getSession(sessionId: string) {
return this.cache.get<{ userId: string }>(`session:${sessionId}`, 'json')
}
}

Retrieve a value by key. Returns null if the key does not exist:

const value = await cache.get('my-key')

Store a value under a key:

await cache.put('my-key', 'hello world')

Remove a key from the cache. Safe to call even if the key does not exist:

await cache.delete('my-key')

List all keys in the namespace, optionally filtered by prefix:

const result = await cache.list({ prefix: 'session:' })
for (const key of result.keys) {
console.log(key.name)
}

The get method supports four return types. Pass the type as the second argument:

TypeCallReturn typeUse case
textget(key) or get(key, 'text')string | nullPlain text, HTML, XML
jsonget<T>(key, 'json')T | nullStructured data with full type safety
arrayBufferget(key, 'arrayBuffer')ArrayBuffer | nullBinary data, images
streamget(key, 'stream')ReadableStream | nullLarge files, streaming responses
// Text (default)
const html = await cache.get('page:home')
// JSON with type inference
interface UserProfile {
id: string
name: string
email: string
}
const profile = await cache.get<UserProfile>('user:123', 'json')
// ArrayBuffer
const image = await cache.get('image:logo', 'arrayBuffer')
// Stream
const file = await cache.get('file:report', 'stream')

Control how long a cached value lives using either a relative TTL or an absolute expiration timestamp:

// Expire in 1 hour (TTL in seconds)
await cache.put('key', 'value', { expirationTtl: 3600 })
// Expire at a specific Unix timestamp (seconds)
const tomorrow = Math.floor(Date.now() / 1000) + 86400
await cache.put('key', 'value', { expiration: tomorrow })

If neither option is set, the value persists indefinitely.

You can attach arbitrary JSON metadata to any cached value. Metadata is stored alongside the value and can be retrieved without reading the value itself.

await cache.put('session:abc', JSON.stringify(sessionData), {
expirationTtl: 3600,
metadata: { userId: 'user_123', createdAt: Date.now() },
})

Use getWithMetadata to get both the value and its metadata:

interface SessionMeta {
userId: string
createdAt: number
}
const result = await cache.getWithMetadata<string, SessionMeta>(
'session:abc',
'text',
)
if (result.value) {
console.log('User:', result.metadata?.userId)
console.log('TTL remaining:', result.cacheStatus?.ttl)
}

The getWithMetadata method supports the same data type overloads as get (text, json, arrayBuffer, stream).

The list method returns keys with optional metadata and supports cursor-based pagination:

const result = await cache.list({
prefix: 'user:',
limit: 100,
})
for (const key of result.keys) {
console.log(key.name, key.expiration, key.metadata)
}

When there are more keys than the limit, list_complete is false and a cursor is provided for the next page:

let cursor: string | undefined
const allKeys: string[] = []
do {
const result = await cache.list({ prefix: 'log:', limit: 100, cursor })
allKeys.push(...result.keys.map(k => k.name))
cursor = result.list_complete ? undefined : result.cursor
} while (cursor)
OptionTypeDescription
prefixstring?Filter keys that start with this string
limitnumber?Maximum keys per response (default 1000)
cursorstring?Pagination cursor from a previous response

By default, CacheService uses the CACHE binding. To work with additional KV namespaces, use the withBinding method:

import { Transient, inject, DI_TOKENS } from 'stratal/di'
import { CACHE_TOKENS, type CacheService } from 'stratal/cache'
@Transient()
export class MultiCacheService {
private readonly sessionsCache: CacheService
private readonly configCache: CacheService
constructor(
@inject(CACHE_TOKENS.CacheService) private readonly cache: CacheService,
@inject(DI_TOKENS.CloudflareEnv) private readonly env: Env,
) {
this.sessionsCache = cache.withBinding(this.env.SESSIONS_KV)
this.configCache = cache.withBinding(this.env.CONFIG_KV)
}
async getSessionData(id: string) {
return this.sessionsCache.get<{ userId: string }>(`session:${id}`, 'json')
}
async getConfig(key: string) {
return this.configCache.get(key)
}
}

Add the additional KV bindings to your wrangler.jsonc:

{
"kv_namespaces": [
{ "binding": "CACHE", "id": "main-kv-id" },
{ "binding": "SESSIONS_KV", "id": "sessions-kv-id" },
{ "binding": "CONFIG_KV", "id": "config-kv-id" }
]
}

And declare them in your environment types:

declare module 'stratal' {
interface StratalEnv {
SESSIONS_KV: KVNamespace
CONFIG_KV: KVNamespace
}
}

Cache operations throw specific error types when they fail. The raw error is logged internally, and the thrown error contains only the key name to avoid leaking sensitive data:

Error classThrown byDescription
CacheGetErrorget, getWithMetadataFailed to read from KV
CachePutErrorputFailed to write to KV
CacheDeleteErrordeleteFailed to delete from KV
CacheListErrorlistFailed to list keys
import { CacheGetError } from 'stratal/cache'
try {
const value = await cache.get('key')
} catch (error) {
if (error instanceof CacheGetError) {
// Handle cache read failure
}
}

All cache errors extend ApplicationError and use the INFRASTRUCTURE_ERROR error code, so they are logged automatically by the global error handler.