Skip to main content

Command Palette

Search for a command to run...

Redis Patterns Every Backend Engineer Should Know

Updated
•6 min read•View as Markdown
Redis Patterns Every Backend Engineer Should Know

Redis gets reached for because it's fast. That's often the wrong reason.

Speed isn't a pattern — it's a property. Using Redis correctly means understanding what each pattern is actually good at and, just as importantly, where it breaks. Most engineers learn the patterns. Fewer learn the failure modes.

This post covers both.


1. Caching — the Pattern Everyone Gets Wrong

Caching is Redis's most common use case and the one most frequently implemented incorrectly. Two mistakes dominate.

No TTL. A cache entry without an expiry lives forever. When the underlying data changes, the cache serves stale values indefinitely. Every cached key needs a TTL — even if it's a long one.

Cache stampede. When a high-traffic key expires, multiple requests simultaneously find a cache miss and all go to the database. The database gets a spike of identical queries. For popular keys, this can cause a cascading failure.

The right pattern uses a lock to prevent simultaneous regeneration:

import aioredis
import json
from typing import Optional, Callable, Any

redis = aioredis.from_url(settings.redis_url)

async def get_cached(
    key: str,
    ttl: int,
    fetch_fn: Callable[[], Any],
    lock_timeout: int = 5
) -> Any:
    # Try cache first
    cached = await redis.get(key)
    if cached:
        return json.loads(cached)

    # Acquire lock to prevent stampede
    lock_key = f"lock:{key}"
    acquired = await redis.set(lock_key, "1", nx=True, ex=lock_timeout)

    if acquired:
        try:
            value = await fetch_fn()
            await redis.set(key, json.dumps(value), ex=ttl)
            return value
        finally:
            await redis.delete(lock_key)
    else:
        # Another process is regenerating — wait briefly and retry from cache
        await asyncio.sleep(0.1)
        cached = await redis.get(key)
        return json.loads(cached) if cached else await fetch_fn()

When it helps: read-heavy data that's expensive to recompute, relatively stable values, any data that can tolerate brief staleness.

When it doesn't: data that must be immediately consistent after a write (user balances, inventory counts), or data so large that Redis memory becomes a concern before database load does.


2. Idempotency Keys — Deduplication Before Processing

Covered in the PulseCart series, but worth repeating as a standalone pattern. When a system delivers messages at-least-once (Pub/Sub, webhooks, retried HTTP calls), you need deduplication before any side-effecting operation.

IDEMPOTENCY_TTL = 86400   # 24 hours

async def is_duplicate(event_id: str) -> bool:
    return await redis.exists(f"processed:{event_id}") == 1

async def mark_processed(event_id: str) -> None:
    await redis.set(f"processed:{event_id}", "1", ex=IDEMPOTENCY_TTL)

The TTL mistake: set it shorter than the delivery retry window of your message system and duplicates slip through. Pub/Sub's default retry window with exponential backoff can run for days. Your TTL needs to be longer than the maximum time between the first and last delivery attempt.

When it helps: any consumer of an at-least-once delivery system — Pub/Sub consumers, webhook handlers, payment callbacks.

When it doesn't: when the operation itself is naturally idempotent (writing the same value to a database row with an upsert). Adding Redis deduplication on top of an already-idempotent operation adds latency and complexity for no benefit.


3. Rate Limiting — Fixed Window vs Sliding Window

The naive implementation:

# ❌ Fixed window — has a boundary exploit
async def is_rate_limited_fixed(user_id: str, limit: int = 100) -> bool:
    key = f"ratelimit:{user_id}:{datetime.utcnow().strftime('%Y-%m-%dT%H')}"
    count = await redis.incr(key)
    if count == 1:
        await redis.expire(key, 3600)
    return count > limit

The problem: a user can make 100 requests at 11:59pm and 100 more at 12:00am — 200 requests in two minutes, both within their hourly limit.

The sliding window using a sorted set:

# ✅ Sliding window — no boundary exploit
async def is_rate_limited(user_id: str, limit: int = 100, window_seconds: int = 3600) -> bool:
    now = time.time()
    window_start = now - window_seconds
    key = f"ratelimit:{user_id}"

    pipe = redis.pipeline()
    pipe.zremrangebyscore(key, 0, window_start)        # remove old entries
    pipe.zadd(key, {str(now): now})                    # add current request
    pipe.zcard(key)                                    # count requests in window
    pipe.expire(key, window_seconds)
    results = await pipe.execute()

    return results[2] > limit

The sorted set uses timestamps as both member and score. zremrangebyscore removes entries outside the window on every request. The count is always accurate to the sliding window, not a fixed clock boundary.

When it helps: API rate limiting per user or API key, preventing abuse of expensive endpoints.

When it doesn't: very high-throughput rate limiting (millions of requests per second) where the sorted set operations become a bottleneck. At that scale, an approximate fixed window with counters is often the better tradeoff.


4. Distributed Locks — One Command, Not Two

The wrong way:

# ❌ Race condition — GET then SET is not atomic
async def acquire_lock_wrong(lock_key: str) -> bool:
    existing = await redis.get(lock_key)
    if not existing:
        await redis.set(lock_key, "locked", ex=30)
        return True
    return False

Between the GET and the SET, another process can acquire the same lock. You have a race condition.

The right way — one atomic command:

# ✅ Atomic — SET with NX and EX in a single command
import uuid

async def acquire_lock(lock_key: str, timeout: int = 30) -> Optional[str]:
    lock_value = str(uuid.uuid4())   # unique value per holder
    acquired = await redis.set(lock_key, lock_value, nx=True, ex=timeout)
    return lock_value if acquired else None

async def release_lock(lock_key: str, lock_value: str) -> None:
    # Only release if we still hold it — use a Lua script for atomicity
    script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    await redis.eval(script, 1, lock_key, lock_value)

The unique lock_value per holder prevents a lock expiry + accidental release scenario: if your lock times out and another process acquires it, your release call won't delete their lock.

When it helps: preventing concurrent execution of a task that must run once (job scheduling, cache regeneration, payment processing).

When it doesn't: when you need strong distributed consensus guarantees. Redis's single-instance lock is sufficient for most use cases. For critical financial operations where a split-brain scenario would be catastrophic, a proper distributed consensus system (etcd, Zookeeper) is the right tool.


5. When Not to Use Redis

Session storage for large sessions. Storing a 50KB session object in Redis on every request is slower than it sounds when multiplied across all users. For large sessions, store a session ID in Redis and the session data in the database.

Primary data store. Redis is in-memory and durable only with AOF or RDB persistence enabled — and even then, a crash can lose the last few seconds of writes. Never treat Redis as your primary data store for anything you can't afford to lose.

Job queues where order and durability matter. Redis lists can work as a simple queue, but they lack dead-letter handling, retry policies, and acknowledgement semantics. Cloud Tasks, Pub/Sub, or a proper queue (SQS, RabbitMQ) are better choices for production job queues.

Counters you need to query historically. Redis is excellent for current counters (rate limits, budget caps). For historical analytics ("how many API calls did this user make last Tuesday?"), write to a database and let Redis handle only the current window.


The right question before reaching for Redis isn't "is this fast enough without it?" — it's "does this pattern actually fit what Redis is good at?" Usually it does. When it doesn't, the alternative is almost always simpler.