What caching actually buys you
A cache trades a little staleness for a lot of speed. Instead of hitting your database (or an expensive computation, or a slow upstream API) on every request, you store the result in fast in-memory storage and serve subsequent requests from there. Redis is the default choice: in-memory, microsecond-fast, and rich with data structures beyond plain key-value.
The hard part of caching isn't reading, it's knowing *what* to cache and *when to throw it away*.
The cache-aside pattern
The most common and robust pattern is cache-aside (lazy loading): the application checks the cache first, and on a miss, loads from the source and populates the cache.
async function getUser(id) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached); // cache hit
const user = await db.getUser(id); // cache miss -> source
await redis.set(key, JSON.stringify(user), 'EX', 300); // populate, 5 min TTL
return user;
}The app owns the cache logic, which keeps the cache from becoming a single point of failure: if Redis is down, you fall back to the database (degraded but working).
Always set a TTL
The simplest, most effective invalidation strategy is a time-to-live. Every cached entry expires after some window, bounding how stale data can get without any manual invalidation. Choose TTLs by how tolerant the data is of staleness:
| Data | Suggested TTL | Rationale |
|---|---|---|
| User profile | 5-15 min | Changes occasionally |
| Product catalog | 1-24 hours | Rarely changes |
| Pricing / inventory | Seconds to 1 min | Must be fresh |
| Computed dashboard | 1-5 min | Expensive, tolerates lag |
When in doubt, start with a short TTL. It's the safest form of invalidation.
Active invalidation on writes
TTLs bound staleness; for data that must update immediately after a write, also invalidate (or update) the cache on mutation:
async function updateUser(id, changes) {
const user = await db.updateUser(id, changes);
await redis.del(`user:${id}`); // invalidate; next read repopulates
return user;
}Combine TTL (safety net) with write-time invalidation (freshness). Relying on invalidation alone is fragile, miss one write path and you serve stale data forever.
Key design matters
Good keys are descriptive and namespaced so you can reason about and selectively clear them:
user:1234 # a single user
user:1234:orders # related collection
product:567:v2 # include a version to bust caches on schema change
org:42:dashboard:summary # tenant-scopedNamespacing by tenant/org is essential in multi-tenant apps, you never want one tenant's cached data leaking to another.
The cache stampede problem
Here's a failure mode that bites at scale: a popular key expires, and suddenly hundreds of concurrent requests all miss the cache and hammer the database at once, a stampede (or "thundering herd"). Mitigations:
- Locking / single-flight: the first request to miss acquires a short lock, fetches, and populates; others wait briefly and read the fresh value.
- Stale-while-revalidate: serve the slightly-stale value while one request refreshes in the background.
- Jittered TTLs: add randomness to expiry so keys don't all expire simultaneously.
// Jittered TTL avoids synchronized expiry
const ttl = 300 + Math.floor(Math.random() * 60); // 300-360s
await redis.set(key, value, 'EX', ttl);What not to cache
- Highly volatile, must-be-exact data (e.g., a bank balance you're about to debit).
- Per-request unique data that's never reused, no hit rate, just overhead.
- Sensitive data without thinking through expiry and access, a cache is still a data store.
Measure your hit rate. A cache with a 10% hit rate is mostly adding latency and complexity. Aim high; if you can't, you're probably caching the wrong things.
Beyond key-value: Redis data structures
Redis is more than GET/SET. Useful patterns:
- Rate limiting with
INCR+EXPIRE. - Leaderboards with sorted sets (
ZADD/ZRANGE). - Session storage with hashes and TTLs.
- Simple queues with lists (though dedicated queues are better for real workloads).
Running Redis with PandaStack
You can run a managed Redis instead of operating one yourself. PandaStack offers managed Redis (via KubeBlocks on GKE) alongside PostgreSQL, MySQL, and MongoDB. Add it to your project and connect your app via the provided connection details. Since PandaStack auto-wires managed databases by injecting connection info into the service environment, your app reads the connection from env vars rather than hardcoded hosts.
// App connects to managed Redis via an injected/configured env var
const redis = new Redis(process.env.REDIS_URL);A common architecture on PandaStack: a container web service for your app, a managed Postgres for primary data (auto-wired DATABASE_URL), and a managed Redis for caching and sessions, all in one project with one bill. Note that free-tier databases are sized for dev/hobby use, so for production cache workloads you'll want a paid plan with more memory and connections.
Conclusion
Redis caching is high-leverage when done with discipline: use cache-aside, always set a TTL, invalidate on writes, namespace your keys, and protect against stampedes with jitter or single-flight. Then measure your hit rate and cache the things that actually repeat. Done right, it's the cheapest big latency win available.
PandaStack's managed Redis lets you add a cache layer without operating it, right next to your app and database. Spin one up on the free tier at https://dashboard.pandastack.io.
References
- Redis documentation: https://redis.io/docs/latest/
- Redis key expiration (TTL): https://redis.io/docs/latest/develop/use/keyspace/
- Cache-aside pattern (Microsoft): https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside
- Redis data types: https://redis.io/docs/latest/develop/data-types/