Respondeo
Features

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 -d

Set the connection URL in your .env.local:

REDIS_URL=redis://:strongvalkeypassword@localhost:6379

Production

Set the REDIS_URL or VALKEY_URL environment variable to your Redis/Valkey instance:

REDIS_URL=redis://username:password@your-redis-host:6379

The app checks for either variable. If neither is set, caching is disabled.

Configuration

Environment Variables

VariableDescriptionDefault
REDIS_URLRedis connection URL (checked first)-
VALKEY_URLValkey 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:

KeyWindow
ratelimit:guest-play:{ip}RATE_LIMIT_WINDOW_MS
ratelimit:ai:user:{userId}RATE_LIMIT_AI_USER_WINDOW_MS
ratelimit:ai:globalRATE_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 TypeTTLRationale
Quiz list5 minutesNew quizzes may be created frequently
Quiz details10 minutesQuiz content rarely changes after creation
Quiz leaderboard5 minutesKeep rankings relatively fresh
Global leaderboard5 minutesMost 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
  1. Check Redis for cached data
  2. If cache hit, return cached data
  3. If cache miss, query database
  4. Store result in Redis with TTL
  5. 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:

  1. Redis - cross-request caching (configured here)
  2. 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

PatternDescription
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:

  1. 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.
  2. Could not connect to ... — the variable is set but the server is unreachable. Check the host and port in the message, and verify with redis-cli -u "$REDIS_URL" ping.
  3. Connected to ... — the cache is live, so look elsewhere (TTLs, invalidation) for the behaviour you're chasing.

Stale data

If leaderboards show outdated rankings:

  1. Lower TTL values in lib/cache/config.ts
  2. Manually flush cache: redis-cli FLUSHDB

High memory usage

Monitor Redis memory usage:

redis-cli INFO memory

Consider:

  • Reducing TTL values
  • Using Redis with persistence disabled (cache-only mode)
  • Setting maxmemory and maxmemory-policy in Redis config

Valkey vs Redis

This app supports both Redis and Valkey (Redis fork). The Redis client checks:

  1. REDIS_URL
  2. VALKEY_URL
  3. 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).

On this page