What you'll learn
Quick Answer
Redis is an in-memory key-value store you put in front of a slower data source. The standard pattern is cache-aside: check Redis, and on a miss read the database, store the result with a TTL, and return it. Every key should have an expiry, writes should delete the key rather than rewrite it, and you should assume any cached value can vanish at any moment. Caching helps when reads repeat; it hurts when they do not.
What Redis actually is
Redis is a server that keeps data structures in memory and answers over the network. Not just strings: hashes, lists, sets, sorted sets, counters. You talk to it with small commands like GET, SET, DEL, INCR and EXPIRE.
Two properties shape everything you do with it. First, the data lives in RAM. It is fast because of that, and it is also finite and volatile in a way a disk-backed database is not. Persistence is available through snapshots and an append-only log, but a cache should be treated as data you can lose.
Second, Redis executes commands one at a time. There is no query planner working around your bad query. A command that scans a million keys blocks every other client for as long as it runs. Which brings us to the first thing people do wrong:
KEYS user:* # scans the entire keyspace, blocks the server. Never in production.
SCAN 0 MATCH user:* COUNT 100 # cursor-based, returns in small batches
KEYS appears in every tutorial and is safe only on a laptop with fifty keys. The same warning applies to FLUSHALL, to fetching a list of a million elements with LRANGE key 0 -1, and to storing one enormous value where many small ones would do.
The mental model that helps: Redis is a shared dictionary that several of your processes can reach, with expiry built in. Everything below is just discipline about what you put in it and when you take it out.
The cache-aside pattern
Cache-aside is the pattern you will use ninety per cent of the time. Your application, not the cache, owns the logic: look in Redis, and on a miss go to the database and put the answer back.
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
async function getCourse(id) {
const key = `course:${id}`;
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached); // hit
const { rows } = await db.query('SELECT * FROM courses WHERE id = $1', [id]);
const course = rows[0]; // the row itself, not the driver's result object
if (course) {
await redis.set(key, JSON.stringify(course), { EX: 300 }); // 5 minutes
}
return course;
}
Three details in that snippet matter more than the shape.
cached !== null rather than a truthy check. An empty string, a zero or the string "false" are all legitimate cached values, and if (cached) treats them as misses. You then hit the database on every request for the one row that caches badly.
JSON is lossy. This is the gotcha that produces the strangest bug reports. JSON.stringify turns a Date into a string, drops undefined, and cannot represent a Map or a BigInt. So course.createdAt.getFullYear() works on a cache miss and throws TypeError on a hit. Intermittent, environment-dependent, and impossible to reproduce until you realise the two paths return different types. Either revive the fields after parsing or store the already-serialised response shape.
Missing rows. If a lookup finds nothing and you cache nothing, every request for a deleted or nonexistent id goes straight through to the database. Scrapers and broken clients will find that path. Caching a small sentinel for a short time, say sixty seconds, closes it.
A key naming convention pays for itself immediately. course:42, user:7:profile, courses:list:page:2. Colons are the convention, and the prefix is what lets you reason about the keyspace later.
TTL, expiry and what happens when memory fills
A cache entry without an expiry is not a cache entry. It is a permanent second copy of your data that nobody backs up and nobody updates. Set a TTL on every key you write.
SET course:42 "{...}" EX 300 # expires in 300 seconds
TTL course:42 # seconds left, -1 = no expiry, -2 = no such key
If TTL returns -1 for a cache key, that key is a leak. A very common way to create one by accident:
SET course:42 "{...}" EX 300
SET course:42 "{updated}" # TTL is now gone. The key lives forever.
SET course:42 "{updated}" KEEPTTL # keeps the remaining expiry
A plain SET clears any existing TTL. So one code path that rewrites a key without repeating EX quietly converts your cache into permanent storage, one key at a time, and you notice months later when memory runs out. KEEPTTL arrived in Redis 6.0, so on an older server repeat the EX instead.
What happens then depends on maxmemory and maxmemory-policy. If maxmemory is unset, Redis keeps growing until the operating system kills the process, which on a small VPS also takes your application down with it. If it is set with the default noeviction policy, Redis starts rejecting writes with an out-of-memory error while reads keep working, so your application half-works in a way that is confusing to debug. For a pure cache, set a memory limit and an eviction policy such as allkeys-lru, which discards least-recently-used keys to make room. That is the behaviour people assume they already have.
One more habit: add jitter. If a nightly job warms ten thousand keys with the same 3600-second TTL, they all expire in the same second and the database takes the entire load at once. A random spread, say 3600 plus up to 300 seconds, prevents that.
Invalidation that actually works
The cached copy is wrong the moment someone updates the underlying row. You have two options and only one of them is reliable.
Delete the key, do not rewrite it. Rewriting means computing the new cached value at write time, which duplicates logic and races with other writers. Deleting means the next read misses and rebuilds from the source of truth, which is the same code path you already tested.
async function updateCourse(id, fields) {
await db.query('UPDATE courses SET title = $1 WHERE id = $2', [fields.title, id]);
await redis.del(`course:${id}`); // after the write commits, not before
}
Order matters. Delete before the database write and a concurrent reader can miss, read the old row, and repopulate the cache with stale data that now has a fresh TTL. Delete after the commit and the window shrinks to something a short TTL will clean up anyway. If you are inside a database transaction, do the delete after the commit succeeds, not inside the transaction, because a rollback would leave you with a needlessly cold cache and, worse, a delete that already happened for a write that did not.
The harder case is derived keys. Updating one course should also invalidate courses:list:page:1, courses:list:page:2 and every filtered variant. Scanning for them is slow and fragile. The standard trick is a version counter baked into the key:
// WRITE path: bump the version once, after the write commits
await redis.incr('courses:ver');
// READ path: read the version, never increment it here
const v = (await redis.get('courses:ver')) ?? '0';
const key = `courses:v${v}:list:page:${page}`; // old keys are now unreachable
Keep those two halves straight. Calling INCR on the read path is an easy slip and it is fatal: every request would compute a brand new key, so nothing is ever read back and your hit rate sits at zero while memory fills with single-use keys. Reads use GET, only writes use INCR.
Old keys become unreachable instantly and expire on their own schedule. No scan, no key list to maintain. Accept the imprecision: you are throwing away more cache than strictly necessary in exchange for never serving a stale list.
Sessions, and when caching makes things worse
Session storage is one of the cleanest uses of Redis. Sessions are small, keyed by a random id, read on nearly every request, and they are supposed to expire, so the TTL you needed anyway is also the business rule. Once you run more than one application process, in-memory sessions stop working entirely because request two lands on a process that has never heard of the user. Redis fixes that without a database table.
SET sess:9f2c1b '{"userId":7,"role":"student"}' EX 1800
EXPIRE sess:9f2c1b 1800 # slide the window on each request
The trade-off is honest: Redis becomes a hard dependency. Restart it without persistence and every user is logged out at once. That is survivable for a learning platform and unacceptable for some systems, so decide deliberately rather than by default. Never put anything in a session you cannot afford to lose, and never put anything in it you would not want read by whoever gets access to that server.
Now the part tutorials skip. Caching makes things worse in several real situations.
- Low hit rate. If each page is unique to one user and rarely revisited, you have added a network round trip and a serialisation cost to every request and saved nothing.
- You are caching a fast query. A primary-key lookup on an indexed table is already quick. Wrapping it in a Redis call can be slower once you count the round trip, and it is certainly more code. Fix the missing index first; caching a bad query hides the problem until traffic doubles.
- Data that must be correct now. Wallet balances, seats remaining, exam results. A cache is an eventually-correct copy. Do not put one in front of a number people will argue about.
- Stampedes. When a very popular key expires, every in-flight request misses at once and they all rebuild it together. The database sees a spike precisely when the cache was supposed to be helping. Guard the rebuild with a short lock, or serve the stale value while one worker refreshes.
The rule underneath all of it: a cache adds a second place where truth can live. Every one you add is a bug you will one day have to reproduce with the cache turned off.
