Cache

Keeping computed values for a while, locks across servers, and rate limiting.

The cache keeps values you'd rather not compute on every request. It's also where locks and rate limits keep count, which is why unique jobs and batches need it.

Remembering a value

remember returns the cached value, or computes it, stores it for that long, and returns it:

const stats = await c.var.cache.remember(`notes:stats:${userId}`, '10m', async () => {
  const notes = await Note.ownedBy(userId).findMany({ columns: { pinned: true } })
  return { total: notes.length, pinned: notes.filter((n) => n.pinned).length }
})

Durations are seconds or strings like '30s', '10m', '1h', '7d'. Values are stored as JSON.

get(key)The value, or undefined.
put(key, value, ttl?)Store it. Without ttl, it's kept until you forget it.
add(key, value, ttl?)Store it only if the key is empty. true when it stored.
forget(key)Delete it.
pull(key)Get it and delete it.
increment(key, by?), decrement(key, by?)Counters.
remember(key, ttl, compute)Get it, or compute and store it.
rememberForever(key, compute)The same, kept until you forget it.

Outside a request, jobs have it as ctx.services.cache.

Values whose shape changes

What comes back from the cache is whatever was stored, maybe by an older deploy. Pass a schema to check it, and to turn JSON back into dates:

const last = await c.var.cache.get('last-export', {
  schema: z.object({ at: z.coerce.date() }),
})
last?.at.getFullYear()

A value that doesn't match counts as missing, so remember computes it afresh instead of crashing.

Stale while refreshing

flexible takes two durations. For the first minute below, the value is fresh. After that, until ten minutes, callers get the old value at once while one of them refreshes it in the background. Past ten minutes, it's computed again before it's returned:

const count = await c.var.cache.flexible('notes:count', ['1m', '10m'], () => countNotes())

So a busy page waits on the computation only when nobody has asked for ten minutes. On Cloudflare Workers, pass { waitUntil: (p) => c.executionCtx.waitUntil(p) } so the background refresh can finish after the response.

Tags

Tags group keys so you can clear them together, such as everything cached for one user:

const titles = await c.var.cache.tags(`user:${userId}`).remember('titles', '1h', loadTitles)

// After they add a note:
await c.var.cache.tags(`user:${userId}`).flush()

Locks

A lock makes sure only one process does something at a time, across every server and worker:

await c.var.cache.lock('reports:build', '30s').run(async () => {
  await buildReport()
})

run releases the lock when the function finishes, or after 30s if the process dies holding it. If someone else holds it, run throws LockUnavailableError straight away; { wait: '5s' } waits for it first.

Stores

src/config/cache.ts picks the store:

memoryIn the process. Fast, but each server has its own, and it's gone on restart. The default.
redisShared by every server and worker. Used when REDIS_URL is set.
databaseRows in caldera_cache, shared, on any database. On Cloudflare Workers, the default.

With more than one server, use redis or database, or each one keeps its own counts, locks and rate limits. c.var.cache.store('database') uses a store other than the default.

Rate limiting

A limiter says how often someone may do something:

import { defineLimiter, perHour, perMinute } from '@caldera/cache'

const createNotes = defineLimiter('notes.create', { limits: [perMinute(10), perHour(100)] })

Give it to a route as throttle:

.post('store', '/notes', { throttle: createNotes }, async (c) => {
  // …
})

The eleventh note in a minute gets a 429:

{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Try again later.",
    "status": 429,
    "retryAfter": 6
  }
}

with Retry-After and X-RateLimit-Limit and X-RateLimit-Remaining headers. A form posted from a page goes back with the message as an error instead.

Option
limitsperSecond(n), perMinute(n), perHour(n), perDay(n), or every(n, '15m'). All of them apply.
keyWho's counted. By default, the signed-in user, or the IP address for guests. Return a list to combine: (c) => [ipOf(c), email].
count: 'failures'Count only failed requests, such as wrong passwords. The starter's login works this way.
blockForOnce someone hits the limit, refuse them for this long.
messageThe 429's message.
dryRunCount and log, but never refuse: for trying a limit out in production.

Jobs use limiters too, with rateLimited, to stay under another service's limits.

On this page