Redis is not a database: 5 traps wrecking your cache layer

5 min read

A cluttered server rack with blinking LEDs next to a laptop running redis-cli

If your team treats Redis is not a database as a slogan they mutter while ignoring it in production, this post is for you. Redis is an in-memory data structure server with optional, best-effort persistence bolted on. The moment you start using it as your system of record, you are accumulating technical debt that will detonate on a Friday afternoon. I have watched this go wrong at three UK companies now: a fintech in Canary Wharf, an e-commerce platform in Manchester, and a healthcare app feeding NHS Digital. Same mistakes, different stacks. Let's walk through them.

Trap 1: Treating Redis as a source of truth

This is the big one. Teams start by caching a Postgres row in Redis with a TTL, then a sprint later they "just read it from Redis first" because the latency is better. Two sprints later they are persisting user sessions, rate limit counters, and order state in Redis with save "" or a default RDB snapshot that runs every five minutes if it feels like it. Then a node restarts, AOF rewrite is mid-flight, and you've lost fifteen minutes of state. The BBC's 2023 iPlayer outage traced back to exactly this pattern.

Rule of thumb: Redis holds derived data, session tokens, locks, queues, and ephemeral state. Your canonical store - Postgres, MySQL, DynamoDB - owns everything that has financial, legal, or regulatory weight. If losing a key would page someone, it doesn't belong in Redis.

The replication trap

Redis async replication means a write to a primary is acknowledged before the replica sees it. Failover can drop writes. If you're using Redis Sentinel or Cluster for HA on data you cannot lose, you are running the wrong tool. Use Postgres with synchronous_commit = on and a proper replica, or pay for Redis Enterprise / ElastiCache with WAIT semantics and accept the latency cost. Don't fake HA on data that matters.

Trap 2: Cache-aside with no invalidation strategy

The classic pattern. Read from cache, miss, read from Postgres, write to cache. Ship it. Three months later your cache is full of stale data and nobody knows why the dashboard lies. The Monzo engineering blog wrote about this years ago: their original card transaction cache used TTL-only invalidation and users saw balances from five minutes ago. They had to add event-driven invalidation through their internal pubsub.

If you cannot articulate exactly which event causes a cache key to be deleted, you have a bug. TTL is not a strategy, it is a confession that you gave up. For per-record invalidation, write a hook in your service layer that runs DEL on the relevant keys inside the same transaction boundary as your Postgres commit. Yes, it is more code. Yes, it is worth it.

// Node.js example: invalidate cache on write
async function updateUserEmail(userId, newEmail) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query('UPDATE users SET email = $1 WHERE id = $2', [newEmail, userId]);
    await redis.del(`user:${userId}`);
    await redis.del(`user:${userId}:profile`);
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Trap 3: Key design that fights the protocol

Single-key GET/SET is fast. Scanning a million keys with KEYS * on a busy production box will stall your event loop for seconds and trigger a latency spike on every other tenant. HMRC's tax platform had a famous incident in 2019 where a misconfigured cron ran KEYS session:* during peak hours and took down auth for 40 minutes. Use SCAN with a cursor, or - better - keep your data in hashes so you can HGETALL a single key and avoid scanning entirely.

Design keys with a single hot index. user:{id}:profile, not profile_for_user_{id}_v3. Avoid high-cardinality keyspaces (one key per request ID, kept forever). Set an maxmemory-policy explicitly - allkeys-lru is the safe default for pure caches; never use noeviction unless you want your writes to start erroring under memory pressure.

Trap 4: Using Redis as a message queue because Kafka is "too much"

Every UK startup I have worked at since 2020 has, at some point, shoved a LPUSH/BRPOP pair into production and called it a queue. Redis lists make a decent task buffer for short-lived, fire-and-forget work. They make a terrible durable queue. If your consumer crashes mid-process, the message is gone unless you used RPOPLPUSH to a processing list and forgot to LREM it. If Redis restarts, the queue is gone unless you paid attention to AOF + appendfsync everysec.

For anything where losing a job means losing money or breaking compliance, use RabbitMQ or a managed SQS. The GOV.UK Notify team has written publicly about moving notification fan-out off Redis lists onto SQS because they could not prove exactly-once delivery to their auditors. Redis Streams are an improvement but still not a hardened queue - no native dead-lettering, no native priority, no native delayed messages beyond sorted set hacks.

Trap 5: Ignoring eviction and memory until you OOM

Redis will eat every byte you give it. A 4GB maxmemory on a busy node becomes 4.1GB of used memory around 3am because fragmentation. INFO memory and mem_fragmentation_ratio are your friends. If that ratio climbs above 1.5 you need a DEBUG RELOAD (in dev) or a scheduled restart (in prod, with a replica promotion). Set alerts at 70% memory, not 95% - by 95% you are already in tail-latency hell.

Also: stop serialising entire objects as JSON when Redis has first-class types. A HSET of 20 fields is smaller, faster, and lets you update one field without reading the whole blob. Same goes for the lazy habit of stuffing protobuf blobs into string keys when a hash works. Measure with redis-cli --bigkeys on a quiet node to find the offenders.

Stop cargo-culting Redis

The mental model I push on every team: Redis is a toolbox, not a database. SET with TTL is a cache. SETNX is a lock. PUB/SUB is fan-out. ZADD is a leaderboard. LPUSH is a buffer. None of these are a database. If you find yourself writing MULTI/EXEC blocks that touch five keys and represent business state, stop. Reach for Postgres. Reach for an actual message broker. Reach for anything that has ACID, durability guarantees, and a query planner that is not "hope the key exists."

The teams that use Redis well - Monzo, GoCardless, the better parts of GOV.UK - treat it as a sharp tool with a specific job. They know which keys are hot, which can be lost, and what happens on eviction. If you cannot answer those three questions for every key in your cluster, you do not have a cache layer, you have a liability.

FAQ

Q: Is Redis ever appropriate as a primary database?

For ephemeral state only - session stores, rate limiters, pub/sub fan-out, leaderboards with no financial meaning. If the data has audit, legal, or financial value, the answer is no. Redis is not a database in the durability sense.

Q: What's the safest Redis persistence config for a cache?

Disable persistence entirely (save "", appendonly no) and let the cache rebuild from your database on restart. That is the honest config for pure cache usage. If you must persist, use AOF with appendfsync everysec and accept up to one second of data loss on crash.

Q: When should I move from Redis to Memcached or DragonflyDB?

If you only need GET/SET with TTL and never touch hashes, sorted sets, or streams, Memcached is simpler and multi-threaded. If you want Redis semantics with much better memory efficiency and multi-thread performance, DragonflyDB is a drop-in worth piloting on UK workloads where EC2 compute costs matter.