Caching & Rate Limiting
Cache, rate-limit, and coordinate work across instances with Redis through @repo/runtime and cacheConfig.
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 frontend gets identical behavior.
Redis client
@repo/runtime exports a redis object whose underlying SDK depends on the cache provider you chose at init (see Choosing a provider). Your application code is identical either way: the same redis API, the same three portable helpers, the same cacheConfig.
| Provider | SDK | Surface | closeRedis() |
|---|---|---|---|
| Redis (self-hosted) | ioredis | Native ioredis (zadd, hset, eval, pub/sub) + helpers | Drains the persistent connection on shutdown |
| Upstash | @upstash/redis | Native Upstash REST client + helpers | No-op - REST has no connection to close |
The three helpers cover the SDK calls whose signatures differ between backends; everything else is the native SDK surface, used directly.
| Helper | Signature | Use for |
|---|---|---|
cacheSet | cacheSet(key, value, opts?) → Promise<boolean> | SET with optional ttlSeconds / ttlMilliseconds / ifNotExists in one call. Returns false only when ifNotExists was set and the key existed |
cacheGet | cacheGet(key) → Promise<string | null> | GET typed as string | null, consistent across backends |
cacheScan | cacheScan({ match, count? }) → AsyncIterable<string> | Iterate keys matching a glob via SCAN |
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, eval, …) is also available on `redis`
}A persistent self-hosted Redis connection can't survive on serverless platforms, so that combination is blocked at init. On serverless targets use Upstash (HTTP-friendly REST). See Choosing a provider and the deployment guide.
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 |
cacheDefaultTtl | ttl: 3600 (1h) | Default TTL for cached responses |
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) |
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 |
blogViewCount | key blog:views:{slug} | Per-post view counter (when config.blog is on) |
blogViewRateLimit | windowMs: 60_000, limit: 30, key blog:view:rate:{ip} | Blog view-pings per IP (when config.blog is on) |
blogViewDedup | ttl: hours × 3600, key blog:view:dedup:{slug}:{hash} | Suppress repeat view counts within the dedup window |
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 / swr (geo, publicContent, publicHealth, publicIp) | Cache windows for individual public endpoints |
- Feature-gated: upload keys are written only when
config.storage.enabledistrue(see Storage); blog keys only whenconfig.blog.enabledistrue(see Blog). With a flag off, those counters are never touched. - Disable response caching: set
responseCache.enabledtofalseand both thecache()andcdnCache()middleware 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 on self-hosted Redis, @upstash/lock on Upstash. Both use atomic Lua compare-and-delete on release.
import { withMutex } from "@repo/runtime";
// options: { lockTimeout?: number; acquireTimeout?: number } - default lockTimeout 10000ms
await withMutex(`credits:grant:${userId}`, async () => {
// exactly-once work
});Choosing a provider
You pick the provider at init; the CLI writes the matching env vars and swaps the redis/mutex source for you. The redis API and cacheConfig are identical either way, so your application code never changes. The choice is about hosting: run your own Redis, or use a managed HTTP-friendly service.
| Provider | SDK | Env vars | When to use |
|---|---|---|---|
| Redis (self-hosted) | ioredis (persistent TCP) | REDIS_URL | Long-running platforms where a persistent connection is fine and you want to own the instance |
| Upstash | @upstash/redis (REST) | UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN | 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 on a serverless platform, so that combination is blocked at init. On serverless, use Upstash.
Frequently asked questions
Do I need Redis for local development?
Yes - the backend won't boot without a reachable Redis. On the self-hosted variant REDIS_URL is required (run a local Redis container, e.g. via pnpm infra); on the Upstash variant set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN.
When should I use cacheSet/cacheGet vs the native SDK methods?
Use the cache* helpers for the SET/GET/SCAN calls whose signatures differ between ioredis and Upstash. Everything else (zadd, hset, eval, pub/sub) is available directly on redis with native syntax.
How do I change a rate limit or TTL?
Edit the relevant 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.