GenerateSaaS

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.

ProviderSDKSurfacecloseRedis()
Redis (self-hosted)ioredisNative ioredis (zadd, hset, eval, pub/sub) + helpersDrains the persistent connection on shutdown
Upstash@upstash/redisNative Upstash REST client + helpersNo-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.

HelperSignatureUse for
cacheSetcacheSet(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
cacheGetcacheGet(key)Promise<string | null>GET typed as string | null, consistent across backends
cacheScancacheScan({ 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.

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
cacheDefaultTtlttl: 3600 (1h)Default TTL for cached responses
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)
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
blogViewCountkey blog:views:{slug}Per-post view counter (when config.blog is on)
blogViewRateLimitwindowMs: 60_000, limit: 30, key blog:view:rate:{ip}Blog view-pings per IP (when config.blog is on)
blogViewDedupttl: hours × 3600, key blog:view:dedup:{slug}:{hash}Suppress repeat view counts within the dedup window
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 / swr (geo, publicContent, publicHealth, publicIp)Cache windows for individual public endpoints
  • Feature-gated: upload keys are written only when config.storage.enabled is true (see Storage); blog keys only when config.blog.enabled is true (see Blog). With a flag off, those counters are never touched.
  • Disable response caching: set responseCache.enabled to false and both the cache() and cdnCache() 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.

ProviderSDKEnv varsWhen to use
Redis (self-hosted)ioredis (persistent TCP)REDIS_URLLong-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_TOKENServerless 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.

On this page