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
}
}

KV reads are eventually consistent: a get can return an edge-cached value for up to roughly 60 seconds after a put. For keys where you need a value to be readable immediately after writing it on the same isolate, inject TieredCacheService instead of CacheService. It layers an in-memory L1 over KV, giving isolate-local read-after-write coherence while KV remains the source of truth.

import { Transient, inject } from 'stratal/di'
import { CACHE_TOKENS, type TieredCacheService } from 'stratal/cache'
@Transient()
export class IdempotencyService {
constructor(
@inject(CACHE_TOKENS.TieredCacheService) private readonly cache: TieredCacheService,
) {}
async claim(id: string): Promise<boolean> {
if (await this.cache.get(`claim:${id}`)) {
return false
}
await this.cache.put(`claim:${id}`, '1', { expirationTtl: 86400 })
return true
}
}

TieredCacheService exposes the same API as CacheService (get, getWithMetadata, put, delete, list), plus binding(name) to bind to another KV namespace by its binding name. Each binding gets its own isolate-local L1.

Reach for the tiered cache when a key is set once and then read often, such as idempotency claims or immutable lookups. A write made through an isolate becomes immediately and consistently visible to later reads on that same isolate, which closes the read-after-write gap that plain KV leaves open.

Cache operations throw CacheError 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
CacheErrorget, getWithMetadata, put, delete, listA KV operation failed
import { CacheError } from 'stratal/cache'
try {
const value = await cache.get('key')
} catch (error) {
if (error instanceof CacheError) {
// Handle cache operation failure
}
}

CacheError extends ApplicationError, so it is logged automatically by the global error handler.