GenerateSaaS

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.

ProviderSDKEnv varscloseRedis()When to use
Redis (self-hosted)ioredis (persistent TCP)REDIS_URLDrains the connection on shutdownLong-running platforms where you want to own the instance
Upstash@upstash/redis (REST)UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKENNo-op - REST has no connection to closeServerless 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.

HelperSignatureUse for
cacheSetcacheSet(key, value, opts?)Promise<boolean>SET with optional ttlSeconds / ttlMilliseconds / ifNotExists. Returns false only when ifNotExists was set and the key existed
cacheGetcacheGet(key)Promise<string | null>GET typed consistently across backends
cacheScancacheScan({ match, count? })AsyncIterable<string>Iterate keys matching a glob via SCAN
cacheEvalcacheEval(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.

KeyTTL / windowUsed for
globalRateLimitwindowMs: 60_000 (1 min)Global hono-rate-limiter window on the API
corsMaxAgettl: 86_400 (24h)CORS preflight max-age - browsers re-validate origins at most daily
emailVerificationExpiryttl: 86_400 (24h)Email-verification token lifetime
uploadRateLimitwindowMs: 60_000, key upload:rate:{ip}Per-IP upload throttle (when config.storage is on)
uploadDailyQuotattl: 86_400, key upload:quota:{userId}:{date}Per-user daily upload cap (when config.storage is on)
aiRateLimitwindowMs: 60_000, limit: 20, key ai:rate:{ip}AI requests per IP (when config.ai is on)
aiToolCallRateLimitwindowMs: 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)
contactRateLimitwindowMs: 900_000 (15 min), limit: 3, key contact:rate:ip:{ip}Contact-form submits per IP
contactEmailRateLimitttl: 86_400, limit: 5, key contact:rate:email:{email}Contact-form submits per email / day
contentViewCountkey {section}:views:{slug}Per-document view counter (when config.content.defaults.viewTracking is on)
contentViewRateLimitwindowMs: 60_000, limit: 30, key blog:view:rate:{ip}Content view-pings per IP
contentViewDedupttl: hours × 3600, key {section}:view:dedup:{slug}:{hash}Suppress repeat view counts within the dedup window
contentViewsDayttl: 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
contentViewsRankingno TTL, maxEntries: 200, key {section}:views:ranking:v1Precomputed 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
contentViewsRankingRefreshttl: 300 (5 min), key {section}:views:ranking:refreshCooldown 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
emailTrackRateLimitwindowMs: 60_000, limit: 60, key email:track:rate:{ip}Open/click tracking pings per IP (when config.email.tracking is on)
runnerRateLimitwindowMs: 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
retentionDaysauditLogs: 90, webhookEvents: 30Cleanup horizons for background jobs
responseCacheenabled: true, keyPrefix: "cache:v1", maxBodyBytes: 1_048_576Master switch + namespace for the cache() and cdnCache() middleware
endpointsper-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 need config.content.defaults.viewTracking.enabled (see Content sections).
  • Disable response caching: set responseCache.enabled to false and both cache() and cdnCache() 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.

On this page