A query that takes 200ms against your database still takes 200ms the millionth time someone runs it, and every one of those runs competes for the same connections, disk I/O, and CPU. Redis keeps the answer in memory, on a server that does nothing else, so the millionth read costs a network round trip instead of a query plan.

What Redis actually does as a cache

Redis is an in-memory key-value store. Values live in RAM, so reads and writes measure in sub-millisecond time instead of the tens or hundreds of milliseconds a relational query costs once you add joins, indexes under pressure, or a busy connection pool. It sits next to your database, not in place of it: your application asks Redis first and goes to the database only when Redis doesn’t have the answer.

The pattern for that is called cache-aside (also “lazy loading”), and it’s the one most applications reach for first:

  1. The application asks Redis for a key.
  2. Cache hit — Redis has it, return it. The database never sees the request.
  3. Cache miss — Redis doesn’t have it. Query the database, write the result into Redis, then return it.
1
2
3
4
5
Request → Redis? ──hit──→ return value
             │
            miss
             ↓
          Database → write to Redis → return value

The database stays the source of truth. Redis holds a fast, disposable copy of the parts you read often. If Redis restarts empty, nothing is lost; it refills itself on the next round of cache misses.

How to run Redis in Docker

The fastest way to get a Redis instance is the official image. If you haven’t used Docker before, What Is Docker and What Is It Used For covers the basics first.

1
docker run --name redis-cache -d -p 6379:6379 redis:8

That starts Redis 8 in the background, publishing port 6379 on your machine. Connect to it with redis-cli, either installed locally or run from a second container on the same network:

1
docker run -it --rm --network container:redis-cache redis:8 redis-cli
1
2
3
4
5
6
127.0.0.1:6379> SET user:42:name "Elena"
OK
127.0.0.1:6379> GET user:42:name
"Elena"
127.0.0.1:6379> TTL user:42:name
(integer) -1

TTL returning -1 means the key has no expiration: it stays until you delete it or Redis evicts it under memory pressure. For a cache you almost always want an expiration, so stale data ages out on its own.

1
2
3
4
127.0.0.1:6379> SET user:42:name "Elena" EX 300
OK
127.0.0.1:6379> TTL user:42:name
(integer) 297

EX 300 sets the key to expire in 300 seconds. PX does the same in milliseconds, and EXPIRE user:42:name 300 sets a TTL on a key that already exists.

For anything beyond a quick manual check, run Redis through Compose alongside the rest of your stack — see What Is Docker Compose and How to Use It:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
services:
  redis:
    image: redis:8
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: redis-server --save 60 1 --maxmemory 256mb --maxmemory-policy allkeys-lru

  api:
    build: .
    depends_on:
      - redis
    environment:
      - REDIS_URL=redis://redis:6379

volumes:
  redis-data:

api reaches Redis at the hostname redis because Compose puts both services on the same user-defined network, the mechanism covered in What Is a Docker Network and How to Use It. The redis-data volume matters less for a pure cache than for a primary database (see What Is a Docker Volume and How to Use It): losing the cache only means the next reads become cache misses. Keep it anyway if a warm cache after a restart matters to your latency budget.

How to write cache-aside logic in your application

Redis gives you the commands; your application still has to call them in the right order. Wrapping a database read in cache-aside looks like this:

1
2
3
4
5
6
7
8
9
def get_user(user_id):
    key = f"user:{user_id}"
    cached = redis.get(key)
    if cached is not None:
        return json.loads(cached)

    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.set(key, json.dumps(user), ex=300)
    return user

Two details do most of the work here:

  • The TTL (ex=300) is not optional. Without it, a row that changes in the database keeps serving its old value from Redis forever. Set the TTL to how long stale data is acceptable for that specific key, not one global number for the whole app.
  • json.dumps/json.loads. Redis stores strings, plus a handful of richer types like hashes and sorted sets — never your language’s objects. Anything structured has to be serialized going in and parsed coming out.

When the underlying row changes, delete the key instead of waiting for the TTL:

1
2
3
def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.delete(f"user:{user_id}")

The next read after an update is a guaranteed cache miss, which repopulates Redis with the fresh row. Cache-aside never tries to keep the cache in sync in real time. It makes stale entries disappear quickly, by TTL or by explicit deletion on write.

Eviction: what happens when Redis runs out of memory

Redis keeps everything in RAM, so memory is finite in a way disk usually isn’t. Set a hard ceiling with maxmemory, and tell Redis what to do when it hits that ceiling with maxmemory-policy:

PolicyBehaviorUse for
noevictionRefuses new writes once full, returns an errorA datastore that cannot silently lose data
allkeys-lruEvicts the least recently used key, any keyA pure cache — the default sane choice
volatile-lruEvicts the least recently used key among keys with a TTLMixing cache keys and permanent keys in one instance
volatile-ttlEvicts the key with the shortest remaining TTL firstRate limiters, short-lived tokens

For a dedicated cache instance, allkeys-lru is almost always right: every key in it is disposable by definition, so let Redis throw away whatever you’ve touched least recently. noeviction is the wrong policy for a cache, because it turns “Redis is full” into application errors instead of quietly dropping cold entries.

1
2
CONFIG SET maxmemory 256mb
CONFIG SET maxmemory-policy allkeys-lru

Set both in the startup command, as in the Compose file above, rather than only at runtime. A container restart resets a runtime-only CONFIG SET to Redis’s defaults (maxmemory 0, meaning unlimited, capped only by the host).

When Redis caching is the wrong fix

Caching hides a slow path; it doesn’t repair one. Four situations where reaching for Redis first makes things worse:

  • The data changes on every read anyway. A live stock ticker or a counter incremented on every request gains nothing from a cache invalidated as fast as it’s populated. You’ve added a network hop for no hit rate.
  • You need strong consistency. Cache-aside is eventually consistent by design: between a write and the next cache miss there is a window where a stale value can still be served. For an account balance or an inventory count where that window causes real damage, don’t cache it, or cache it with a TTL of seconds and accept the tradeoff explicitly.
  • The real bottleneck is somewhere else. If your database is slow because of a missing index or an N+1 query, fix that first. Caching a badly written query moves the same wrong answer into memory faster.
  • A thundering herd on expiry. When a hot key expires, every concurrent request can miss the cache at once and hammer the database simultaneously. Use SET key value NX EX 30 as a short-lived lock, held by whichever request repopulates the cache, so the rest wait on that one instead of piling onto the database.

Where to start with Redis caching

Put a redis:8 container in front of your slowest, most-repeated read. Wrap it in cache-aside with a TTL you can justify, not a round number picked at random, and set maxmemory-policy allkeys-lru so a full cache degrades instead of erroring. Then measure the hit rate. Write-through caching, pub/sub invalidation and Redis Streams all cost complexity, and none of them is worth paying for until a plain cache-aside setup has shown you exactly where it falls short.