Caching & Rate Limiting
Cache, rate-limit, and coordinate work across instances with Redis through @repo/runtime and the cacheConfig table.
Redis is the backend's shared coordination layer. @repo/runtime owns the client (packages/runtime/src/redis.ts) and cacheConfig (packages/config/src/cache.ts) centralizes every TTL, window, and limit. cacheConfig is a named export of @repo/config, not a config.* feature flag, so it is set once and every surface behaves identically.
Choosing a provider
You pick the provider at init; the CLI writes the matching env vars and swaps the redis/mutex source. The redis API and cacheConfig are identical either way, so application code never changes - the choice is about hosting.
| Provider | SDK | Env vars | closeRedis() | When to use |
|---|---|---|---|---|
| Redis (self-hosted) | ioredis (persistent TCP) | REDIS_URL | Drains the connection on shutdown | Long-running platforms where you want to own the instance |
| Upstash | @upstash/redis (REST) | UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN | No-op - REST has no connection to close | Serverless targets, or whenever you'd rather not operate Redis yourself |
Self-hosted Redis is incompatible with serverless deployment. A persistent TCP connection can't survive there, so that combination is blocked at init - use Upstash. See the deployment guide.
Redis client
@repo/runtime exports a redis object. Four helpers cover the calls whose signatures differ between backends; everything else (zadd, hset, pub/sub) is the native SDK surface, used directly.
| Helper | Signature | Use for |
|---|---|---|
cacheSet | cacheSet(key, value, opts?) → Promise<boolean> | SET with optional ttlSeconds / ttlMilliseconds / ifNotExists. Returns false only when ifNotExists was set and the key existed |
cacheGet | cacheGet(key) → Promise<string | null> | GET typed consistently across backends |
cacheScan | cacheScan({ match, count? }) → AsyncIterable<string> | Iterate keys matching a glob via SCAN |
cacheEval | cacheEval(script, keys, args) → Promise<unknown> | Run a Lua script atomically via EVAL |
import { redis } from "@repo/runtime";
await redis.cacheSet("geo:1.2.3.4", body, { ttlSeconds: 300 });
const cached = await redis.cacheGet("geo:1.2.3.4");
for await (const key of redis.cacheScan({ match: "upload:rate:*" })) {
// every native SDK method (zadd, hset, …) is also available on `redis`
}cacheConfig entries
Every TTL, window, and limit lives in one object. Public endpoints and feature-gated counters get their own keyed entries.
| Key | TTL / window | Used for |
|---|---|---|
globalRateLimit | windowMs: 60_000 (1 min) | Global hono-rate-limiter window on the API |
corsMaxAge | ttl: 86_400 (24h) | CORS preflight max-age - browsers re-validate origins at most daily |
emailVerificationExpiry | ttl: 86_400 (24h) | Email-verification token lifetime |
uploadRateLimit | windowMs: 60_000, key upload:rate:{ip} | Per-IP upload throttle (when config.storage is on) |
uploadDailyQuota | ttl: 86_400, key upload:quota:{userId}:{date} | Per-user daily upload cap (when config.storage is on) |
aiRateLimit | windowMs: 60_000, limit: 20, key ai:rate:{ip} | AI requests per IP (when config.ai is on) |
aiToolCallRateLimit | windowMs: 60_000, limit: 240, key ai:rate:tool:{userId} | Single tool calls per signed-in user on POST /ai/tools/call - one request per tool call, so it gets its own budget instead of spending the 20-turn AI window (when config.ai is on) |
contactRateLimit | windowMs: 900_000 (15 min), limit: 3, key contact:rate:ip:{ip} | Contact-form submits per IP |
contactEmailRateLimit | ttl: 86_400, limit: 5, key contact:rate:email:{email} | Contact-form submits per email / day |
contentViewCount | key {section}:views:{slug} | Per-document view counter (when config.content.defaults.viewTracking is on) |
contentViewRateLimit | windowMs: 60_000, limit: 30, key blog:view:rate:{ip} | Content view-pings per IP |
contentViewDedup | ttl: hours × 3600, key {section}:view:dedup:{slug}:{hash} | Suppress repeat view counts within the dedup window |
contentViewsDay | ttl: 7_862_400 (91d), days: 91, key {section}:views:d:{yyyymmdd} | Per-UTC-day hash of slug -> views, written beside contentViewCount and read only by the hourly ranking job |
contentViewsRanking | no TTL, maxEntries: 200, key {section}:views:ranking:v1 | Precomputed popularity windows (d7/d30/d90/all) that listing pages read with one GET; the all window covers documents viewed in the last 91 days - see background jobs |
contentViewsRankingRefresh | ttl: 300 (5 min), key {section}:views:ranking:refresh | Cooldown on the public "rebuild this ranking" request, so a cold-start page can ask for one without letting a flood enqueue a job per request |
emailTrackRateLimit | windowMs: 60_000, limit: 60, key email:track:rate:{ip} | Open/click tracking pings per IP (when config.email.tracking is on) |
runnerRateLimit | windowMs: 60_000, per-route limits keyed runner:rate:{route}:{runnerId}, plus an ip floor of 2000/min keyed runner:rate:ip:{ip} | Per-runner budgets on the daemon transport, over an address-keyed floor that also covers requests which never authenticate (when config.runner is on) - see per-runner request budgets |
retentionDays | auditLogs: 90, webhookEvents: 30 | Cleanup horizons for background jobs |
responseCache | enabled: true, keyPrefix: "cache:v1", maxBodyBytes: 1_048_576 | Master switch + namespace for the cache() and cdnCache() middleware |
endpoints | per-route ttl (geo: 300, publicHealth: 10) | Cache windows for individual public endpoints |
- Feature-gated keys are inert when their flag is off - upload keys need
config.storage.enabled(see Storage), view-tracking keys needconfig.content.defaults.viewTracking.enabled(see Content sections). - Disable response caching: set
responseCache.enabledtofalseand bothcache()andcdnCache()become passthroughs. - Invalidate on deploy: bump the version suffix in
responseCache.keyPrefix(e.g.cache:v2) to drop all cached responses.
Distributed mutex
For work that must run exactly once across instances (credit grants, webhook handling), @repo/runtime exports withMutex(key, fn, options?) (packages/runtime/src/mutex.ts). It acquires a distributed lock, runs fn, and always releases - throwing MutexTimeoutError if the lock can't be acquired. The lock library matches the provider (redis-semaphore or @upstash/lock); both release with an atomic Lua compare-and-delete.
import { withMutex } from "@repo/runtime";
// options: { lockTimeout?: number; acquireTimeout?: number } - default lockTimeout 10000ms
await withMutex(`credits:grant:${userId}`, async () => {
// exactly-once work
});Frequently asked questions
Do I need Redis for local development?
Yes - the backend won't boot without a reachable Redis. Self-hosted needs REDIS_URL (run a local container via pnpm infra); Upstash needs UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN.
When do I use the cache* helpers vs the native SDK?
Use the helpers for SET/GET/SCAN/EVAL, whose signatures differ between ioredis and Upstash. Everything else is available directly on redis with native syntax.
How do I change a rate limit or TTL?
Edit the entry in cacheConfig (packages/config/src/cache.ts). It is plain configuration, not a feature flag, so the new value applies on the next restart.
Background Jobs
Run durable, scheduled, and event-driven work with Inngest functions in @repo/api, each registered behind its own config flag.
Performance Monitoring
Log every API request's method, path, status, duration, and cache outcome via a Hono timing middleware, gated by config.performanceMonitor.