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.

Frequently Asked Questions

Is Redis a database or a cache? It can be either, and the difference is configuration plus your own expectations. With persistence enabled and no eviction policy it can act as a primary store for suitable workloads. As a cache you deliberately set a memory limit, an eviction policy and TTLs, and you write your application so that losing every key causes a slow request rather than a wrong answer. Mixing both roles in one instance is where people get hurt, because eviction will happily discard the data you thought was permanent.
How long should a TTL be? Long enough that the key is reused before it expires, short enough that stale data is tolerable for that window. Start from how wrong the data is allowed to be: a course listing can be five minutes stale, a profile page perhaps one minute, a payment status not at all. Combine a TTL with explicit deletion on write, so the TTL is the safety net rather than the main mechanism.
What is the difference between cache-aside and write-through? In cache-aside your application reads the cache, falls back to the database on a miss, and populates the cache itself. In write-through the cache sits in the write path and updates itself when you write. Cache-aside is simpler, tolerates the cache being down, and is what most web applications use. Write-through keeps the cache warmer but couples your write path to the cache being available and correct.
Why did my cached object lose its dates and undefined fields? Because you serialised it with JSON. JSON has no date type, so Date objects become ISO strings and stay strings after parsing, and keys whose value is undefined are dropped entirely. The result is that a cache hit returns a differently shaped object from a cache miss. Either convert those fields back explicitly after parsing, or cache the final serialised response so both paths return the same thing.
Do I need Redis for a small project? Usually not. A single-process application with a properly indexed database and HTTP caching on responses will handle far more traffic than most student projects ever see. Add Redis when you have a measured hot path, when you need sessions shared across multiple processes, or when you need rate limiting or a job queue. Adding it before there is a problem gives you an extra service to run and an extra place for bugs to hide.