Caching
Optional Redis/Valkey caching layer
Caching
This document describes the optional Redis/Valkey caching layer for Respondeo.
Overview
Respondeo uses a cache-aside pattern to reduce database load for frequently accessed, read-heavy data:
- Quiz list - paginated list of quizzes
- Quiz details - individual quiz with questions and answers
- Leaderboards - per-quiz and global rankings
Caching is optional and graceful - the app works without Redis, queries just hit the database directly.
The same connection also backs rate limiting (guest plays and AI generation). That part is not merely an optimization:
Without Redis, rate limit counters live in each server instance's memory. A single long-running
server enforces them correctly, but any multi-instance deployment — including serverless platforms
such as Vercel, where each concurrent function instance is its own process — multiplies the
effective limit by the number of instances. Configure REDIS_URL or VALKEY_URL in production if
you rely on the AI generation limits to cap provider spend.
Quick Start
Local Development with Docker
The compose.yaml includes a Valkey service:
docker compose up -dSet the connection URL in your .env.local:
REDIS_URL=redis://:strongvalkeypassword@localhost:6379Production
Set the REDIS_URL or VALKEY_URL environment variable to your Redis/Valkey instance:
REDIS_URL=redis://username:password@your-redis-host:6379The app checks for either variable. If neither is set, caching is disabled.
Configuration
Environment Variables
| Variable | Description | Default |
|---|---|---|
REDIS_URL | Redis connection URL (checked first) | - |
VALKEY_URL | Valkey connection URL (checked second) | - |
If neither is set, caching is disabled and all queries hit the database, and rate limiting falls back to its per-instance in-memory store.
Rate limit keys
Rate limiting uses fixed-window counters under the ratelimit: prefix, separate
from the cache keys above:
| Key | Window |
|---|---|
ratelimit:guest-play:{ip} | RATE_LIMIT_WINDOW_MS |
ratelimit:ai:user:{userId} | RATE_LIMIT_AI_USER_WINDOW_MS |
ratelimit:ai:global | RATE_LIMIT_AI_GLOBAL_WINDOW_MS |
If a Redis command fails mid-request the limiter logs a warning and falls back to the in-memory store for that request rather than failing the request outright.
TTL (Time To Live)
Cache TTL values are configured in lib/cache/config.ts:
| Data Type | TTL | Rationale |
|---|---|---|
| Quiz list | 5 minutes | New quizzes may be created frequently |
| Quiz details | 10 minutes | Quiz content rarely changes after creation |
| Quiz leaderboard | 5 minutes | Keep rankings relatively fresh |
| Global leaderboard | 5 minutes | Most expensive query, aggregates all attempts |
Adjust these values based on your freshness requirements vs. performance needs.
Architecture
Cache-Aside Pattern
┌─────────────┐ cache hit ┌───────────┐
│ Client │ ───────────────── │ Redis │
└─────────────┘ └───────────┘
│ │
│ cache miss │
▼ │
┌─────────────┐ │
│ Database │ ◄───────────────────────┘
└─────────────┘ write to cache- Check Redis for cached data
- If cache hit, return cached data
- If cache miss, query database
- Store result in Redis with TTL
- Return data to client
Cache Invalidation
Leaderboards use short TTL-based expiry rather than explicit invalidation. When a quiz attempt is submitted, the updated scores will be reflected once the relevant leaderboard cache entries expire and are recomputed on the next request.
Other cached data follows the standard cache-aside pattern and is refreshed on cache miss when the TTL has expired.
Two-Layer Caching
The app uses two caching layers:
- Redis - cross-request caching (configured here)
- React cache() - per-request deduplication (for SSR/metadata)
React's cache() prevents duplicate database queries within the same request (e.g., between generateMetadata and page render). Redis provides caching across requests.
Cache Keys
| Pattern | Description |
|---|---|
quizzes:list:{admin|public}:{page}:{limit} | Paginated quiz list |
quizzes:detail:{quizId} | Individual quiz with questions |
leaderboard:quiz:{quizId}:{page}:{limit} | Per-quiz leaderboard |
leaderboard:global:{page}:{limit} | Global aggregated leaderboard |
Monitoring
The connection reports its state once per process, so an unconfigured
deployment is distinguishable from a configured-but-broken one. Look for a
[cache] line in your runtime logs shortly after a cold start:
# Connected — caching and shared rate limiting are active
[cache] Connected to eu1.example.upstash.io:6379 over TLS — caching and shared rate limiting active.
# Not configured — this is the state to look for if rate limits aren't holding
[cache] No REDIS_URL or VALKEY_URL set — caching is off (queries hit the database) and rate
limits are per-instance only, which does not hold across serverless instances. See https://docs.respondeo.app/docs/features/caching.
# Configured, but the server can't be reached
[cache] Could not connect to eu1.example.upstash.io:6379 over TLS — caching disabled and rate
limits fall back to per-instance. Retrying in 10s. ...
# Was connected, then the connection dropped
[cache] Disconnected from eu1.example.upstash.io:6379 over TLS — falling back to the database
and per-instance rate limits until it reconnects.Only host, port, and TLS are logged — never the credentials embedded in the connection URL.
After a failed connection the client waits 10 seconds before redialing, so an unreachable server costs one connect attempt per 10s rather than one per request. A dropped connection clears the cached client, so the next request after the cooldown reconnects rather than sending commands to a dead socket.
getRedisStatus() from lib/cache/client.ts returns the current state
(unconfigured, connecting, connected, unavailable) if you want to expose
it from a health check.
Individual cache operations log on failure:
[cache] Invalidated 5 keys matching "leaderboard:quiz:abc123:*"
[cache] Read error for key quizzes:list:public:1:30 ...Troubleshooting
Caching not working
Find the [cache] line in your runtime logs from the last cold start — it says
which of these you're in:
No REDIS_URL or VALKEY_URL set— the variable isn't reaching the runtime. On Vercel, confirm it's set for the environment you're testing and redeploy; environment variable changes only apply to new deployments.Could not connect to ...— the variable is set but the server is unreachable. Check the host and port in the message, and verify withredis-cli -u "$REDIS_URL" ping.Connected to ...— the cache is live, so look elsewhere (TTLs, invalidation) for the behaviour you're chasing.
Stale data
If leaderboards show outdated rankings:
- Lower TTL values in lib/cache/config.ts
- Manually flush cache:
redis-cli FLUSHDB
High memory usage
Monitor Redis memory usage:
redis-cli INFO memoryConsider:
- Reducing TTL values
- Using Redis with persistence disabled (cache-only mode)
- Setting
maxmemoryandmaxmemory-policyin Redis config
Valkey vs Redis
This app supports both Redis and Valkey (Redis fork). The Redis client checks:
REDIS_URLVALKEY_URL- Defaults to
redis://localhost:6379
The Docker Compose setup uses the official valkey/valkey:9.0-trixie image for a lightweight, open-source option based on Debian (trixie).