Introduction

Caching is not optimization. It's a trade-off — you're trading correctness for speed, and you need to know exactly how much correctness you're giving up.

Every cached value is a lie you're telling your application. A useful lie, usually. But a lie that can become dangerous the moment the underlying data changes and the cache doesn't know about it. The question isn't whether to cache. It's what to cache, for how long, and what happens when the cache is wrong.

Before writing a single Redis command, you need a decision framework. What data changes rarely enough to tolerate staleness? What data would cause real bugs if served stale? And how do you choose a TTL that balances freshness against database load? Those questions come first. The Redis commands are the easy part.

The JavaScript examples use node-redis v5 (the redis package, camelCase command methods) as ES modules, the session example uses connect-redis 9 or later, and the Python examples use redis-py 5+.

Redis Data Structures for Caching

Redis ships with purpose-built data structures. Most people treat it as a key-value store and shove JSON into string keys for everything. That works until you need to read one field from a large cached object and you're deserializing the whole thing on every hit. Wrong data structure, wrong access pattern, wasted CPU.

Strings

The default. JSON blobs, serialized objects, counters. SET, GET, attach a TTL inline.

node-strings.js
import { createClient } from 'redis';

const client = createClient({ url: 'redis://localhost:6379' });
await client.connect();

// Cache a JSON response with a 5-minute TTL
const userData = { id: 42, name: 'Jane Doe', role: 'admin' };
await client.set(
  'user:42',
  JSON.stringify(userData),
  { EX: 300 } // expires in 300 seconds
);

// Retrieve the cached value
const cached = await client.get('user:42');
if (cached) {
  const user = JSON.parse(cached);
  console.log(`Cache hit: ${user.name}`);
} else {
  console.log('Cache miss - fetching from database');
}
Terminal running node node-strings.js which prints Cache hit: Jane Doe, then redis-cli GET user:42 returning the JSON string and TTL user:42 returning 297.
Example output. The value comes straight back as a cache hit, and redis-cli shows the JSON string counting down its 300-second TTL.

Hashes

Field-value pairs under one key. User profiles, product records, anything where you need partial reads. HSET writes a field, HGET reads one. No deserialization round-trip. And no, you shouldn't store every object as a hash — if you always read the whole thing, a JSON string is simpler.

Lists

Ordered sequences. Push to the head or tail, pop from either end. Activity feeds, job queues. Constant-time access at both ends.

Sets and Sorted Sets

Sets: unordered unique strings. Sorted sets: same thing but ranked by a numeric score. Leaderboards, priority queues, time-series indexes. The sorted set is the most underrated structure in Redis. Look up the ZADD and ZRANGE syntax when you need it — the important thing is knowing it exists.

hash-and-sorted-set.py
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# Store a product as a hash
r.hset('product:1001', mapping={
    'name': 'Wireless Keyboard',
    'price': '79.99',
    'stock': '245',
    'category': 'electronics'
})
r.expire('product:1001', 600)  # 10-minute TTL

# Read a single field without fetching everything
price = r.hget('product:1001', 'price')
print(f'Price: ${price}')

# Track trending products with a sorted set
r.zadd('trending:products', {
    'product:1001': 150,  # 150 views
    'product:1002': 340,  # 340 views
    'product:1003': 89,   # 89 views
})

# Get top 5 trending products
top_products = r.zrevrange('trending:products', 0, 4, withscores=True)
for product, score in top_products:
    print(f'{product}: {int(score)} views')
Terminal running python hash-and-sorted-set.py: Price: $79.99, then product:1002 340 views, product:1001 150 views and product:1003 89 views; redis-cli HGETALL lists the four hash fields and TTL shows 598.
Example output. HGET reads one field from the hash, and ZREVRANGE returns the products from most to fewest views.

Match the structure to the access pattern. Not to what feels clever.

Cache-Aside Pattern

The application checks the cache first. Hit? Return it. Miss? Query the database, write the result into the cache, then return it. The cache never talks to the database directly — your application code does all the coordination. This is cache-aside, sometimes called lazy loading. Most production caching works this way because it only caches data that someone actually requests. No memory wasted on entries nobody reads. The tradeoff is that every first request pays the full database round trip, but popular keys get warm fast and stay warm. Here is what the flow looks like in code:

cache-aside.js
import express from 'express';
import { createClient } from 'redis';
import db from './database.js'; // your DB module: query(sql, params) -> row or null

const app = express();
const redis = createClient({ url: 'redis://localhost:6379' });
redis.on('error', (err) => console.error('Redis error', err));
await redis.connect();

async function getUser(userId) {
  const cacheKey = `user:${userId}`;

  // Step 1: Check the cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log('Cache hit');
    return JSON.parse(cached);
  }

  // Step 2: Cache miss - query the database
  console.log('Cache miss - querying database');
  const user = await db.query(
    'SELECT * FROM users WHERE id = $1',
    [userId]
  );

  if (!user) return null;

  // Step 3: Populate the cache for future requests
  await redis.set(cacheKey, JSON.stringify(user), { EX: 600 });

  return user;
}

// Usage in an Express route
app.get('/api/users/:id', async (req, res) => {
  const user = await getUser(req.params.id);
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});

app.listen(3000);
Terminal with the cache-aside server running: the first curl to /api/users/42 logs Cache miss, the second logs Cache hit with the same JSON, and an unknown id returns User not found with status 404.
Example output. The first request misses and queries the database; the repeat request is served from Redis.

The danger here is the thundering herd. Popular key expires. Dozens of requests arrive simultaneously. All miss. All slam the database at once.

Fix: a lock. The first request acquires a short-lived Redis lock with SET ... NX EX, fetches from the database, repopulates the cache. Everyone else waits briefly and retries. But you probably won't need this until you're handling real traffic spikes — and when you do need it, you'll know because your database monitoring will scream.

Write-Through and Write-Behind Patterns

Write-Through

Every write goes to the database and then immediately to the cache, so readers usually see fresh data without waiting for a TTL. It is not atomic: these are two separate systems. If the cache write fails after the database commit, or two concurrent updates finish in a different order on each side, the cache can hold an older value. Keep a TTL as a safety net, and for data where a stale read is unacceptable (balances, stock levels used for checkout), read from the database. Slower too — two writes per mutation.

Write-Behind (Write-Back)

Flip it. Write to the cache, return immediately, flush to the database later in batches. Fast. But if Redis dies before the flush? Data is gone. RDB snapshots and AOF logging shrink the window, but they don't close it. Be honest about whether your application can lose a few seconds of writes.

write-through.js
// Uses the `redis` client and `db` module from cache-aside.js
async function updateUserProfile(userId, updates) {
  const cacheKey = `user:${userId}`;

  // Write-through: update the DB first, then the cache (two separate steps)
  const updatedUser = await db.query(
    `UPDATE users SET name = $1, email = $2
     WHERE id = $3 RETURNING *`,
    [updates.name, updates.email, userId]
  );

  // Immediately update the cache with fresh data
  await redis.set(
    cacheKey,
    JSON.stringify(updatedUser),
    { EX: 600 }
  );

  return updatedUser;
}

// Write-behind: queue writes for async processing
async function trackPageView(pageId, userId) {
  const event = JSON.stringify({
    pageId, userId, timestamp: Date.now()
  });

  // Write to Redis immediately (fast path)
  await redis.lPush('analytics:queue', event);
  await redis.incr(`pageviews:${pageId}`);
}

// Background worker flushes to the database in batches
async function flushAnalytics() {
  const batchSize = 100;
  const events = [];

  for (let i = 0; i < batchSize; i++) {
    const event = await redis.rPop('analytics:queue');
    if (!event) break;
    events.push(JSON.parse(event));
  }

  if (events.length > 0) {
    await db.batchInsert('page_views', events);
  }
}

Most applications mix all three. Cache-aside for bulk reads. Write-through for data where staleness causes real bugs. Write-behind for high-volume fire-and-forget stuff like analytics events. Pick per data type, not per application.

Start with write-through or cache-aside plus explicit invalidation, and move to write-behind only when measurements show database writes are the bottleneck. Write-behind adds operational complexity: a queue to monitor, retries, ordering and possible data loss.

TTL Strategies and Cache Eviction

Every cached value needs an expiration time. No exceptions. A cached entry with no TTL is a bug waiting to surface weeks later when a customer tries to buy a product that was delisted months ago.

Two questions decide TTL length: how often does this data change, and how bad is it if someone sees a stale version?

  • Site configuration, feature flags — 5 to 30 minutes. Changes rarely, staleness is invisible.
  • User profiles, preferences — 5 to 10 minutes. People expect their own changes to show up quickly.
  • Live data (stock prices, scores) — 5 to 30 seconds, or skip TTL and use pub/sub for real-time invalidation.
  • Aggregated data (dashboard stats, reports) — 1 to 15 minutes, proportional to computation cost.

Cache Eviction Policies

Redis hits its memory limit. Something has to go.

allkeys-lru — evicts least recently used keys across everything. Safest default. volatile-lru — same but only targets keys with a TTL set. allkeys-lfu — least frequently used, better when popularity varies widely. noeviction — returns errors when full. Only if you're certain the dataset fits.

Use allkeys-lru. Always set maxmemory explicitly, below the machine's RAM. Leave headroom for the OS, replication buffers and the extra copy-on-write memory used by RDB/AOF rewrites; how much depends on your write rate and persistence settings, so watch INFO memory under real load.

Cache Invalidation Approaches

The database has the truth. The cache has a copy. The copy goes stale. This is where caching gets genuinely hard, and where most caching bugs live.

TTL-Based Expiration

The lazy approach. Set a TTL, let entries vanish on schedule, next request fetches fresh data. Maximum staleness equals your TTL. Good enough when eventual consistency is acceptable — which is more often than people think.

Explicit Deletion

Application writes to the database, then deletes the corresponding cache key. More precise. The cache clears the moment data changes, not minutes later.

invalidation.py
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)


def update_product(db, product_id, new_data):
    """db is a DB-API connection (e.g. psycopg). Invalidate caches after the commit."""
    with db.cursor() as cur:
        # Read the current category first: if it changes, both lists are stale
        cur.execute("SELECT category FROM products WHERE id = %s", (product_id,))
        row = cur.fetchone()
        old_category = row[0] if row else None

        cur.execute(
            "UPDATE products SET name=%s, price=%s, category=%s WHERE id=%s",
            (new_data['name'], new_data['price'], new_data['category'], product_id)
        )
    db.commit()

    # Invalidate the specific cache entry and every list it appears in
    keys = {f'product:{product_id}', 'homepage:featured_products',
            f'category:{new_data["category"]}:products'}
    if old_category:
        keys.add(f'category:{old_category}:products')
    r.delete(*keys)


def invalidate_pattern(pattern):
    """Invalidate all keys matching a pattern.
    SCAN walks the keyspace in small batches, so it doesn't block Redis,
    but a full pass is still O(N). Use sparingly."""
    cursor = 0
    while True:
        cursor, keys = r.scan(cursor, match=pattern, count=100)
        if keys:
            r.delete(*keys)
        if cursor == 0:
            break


if __name__ == '__main__':
    # Maintenance task: clear all cached product data
    invalidate_pattern('product:*')

Version-Based Invalidation

Embed a version number in cache keys. Bump it when data changes. Old entries become orphans — evicted by memory pressure or TTL, whichever comes first. No hunting down individual keys.

Belt and suspenders: TTL on everything as a safety net, explicit deletion on top for data where staleness actually matters to users. If the explicit invalidation fails — and it will, eventually, because distributed systems — the TTL prevents stale data from living forever.

Pub/Sub for Real-Time Events

Four application servers, each with a local in-memory cache layered on top of Redis. One server updates the database and deletes a Redis key. The other three still have stale copies in their local caches. Pub/sub fixes that.

Subscribe, publish, handle the message. Standard pattern. It won't replace RabbitMQ or Kafka for durable workloads, but for broadcasting cache invalidation signals across app servers it's hard to beat.

pubsub-invalidation.js
import { createClient } from 'redis';

// Subscriber: listens for cache invalidation events
const subscriber = createClient({ url: 'redis://localhost:6379' });
await subscriber.connect();

const localCache = new Map();

await subscriber.subscribe('cache:invalidate', (message) => {
  const { key, action } = JSON.parse(message);
  console.log(`Invalidation received: ${action} ${key}`);

  // Clear from local in-memory cache
  if (key.includes('*')) {
    // Pattern invalidation: clear matching keys
    for (const k of localCache.keys()) {
      if (k.startsWith(key.replace('*', ''))) {
        localCache.delete(k);
      }
    }
  } else {
    localCache.delete(key);
  }
});

// Publisher: broadcasts invalidation after a write
const publisher = createClient({ url: 'redis://localhost:6379' });
await publisher.connect();

async function invalidateAndBroadcast(key) {
  // Delete from Redis
  await publisher.del(key);

  // Notify all application servers
  await publisher.publish('cache:invalidate', JSON.stringify({
    key,
    action: 'delete',
    source: process.env.SERVER_ID
  }));
}

Caveat: fire-and-forget. Subscriber disconnected when the message goes out? Missed. No queue, no replay. For cache invalidation that's usually fine — TTLs catch anything that slips through.

If you need guaranteed delivery, use Redis Streams instead. Append-only log. Persistent. Consumer acknowledgments. But most cache invalidation doesn't need that level of reliability.

Session Storage and Rate Limiting

Two of Redis's most common production roles have nothing to do with caching database queries.

Session Storage

Sticky sessions are a scaling headache. Put sessions in Redis instead. Every app server can handle every request because the session data lives in a shared store, not pinned to one server's memory.

sessions-and-rate-limiting.js
import express from 'express';
import session from 'express-session';
import { RedisStore } from 'connect-redis'; // named export since connect-redis 8
import { createClient } from 'redis';
import { randomUUID } from 'node:crypto';

const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.on('error', (err) => console.error('Redis error', err));
await redisClient.connect();

const app = express();
app.set('trust proxy', 1); // behind a proxy/load balancer: real client IP + secure cookies

// Redis-backed session storage
app.use(session({
  store: new RedisStore({ client: redisClient, prefix: 'sess:' }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24 // 24 hours
  }
}));

// Sliding-window rate limiter using a sorted set
async function rateLimiter(req, res, next) {
  const key = `ratelimit:${req.ip}`;
  const now = Date.now();
  const windowMs = 60000; // 1-minute window
  const maxRequests = 100;
  const member = `${now}-${randomUUID()}`; // unique even within the same millisecond

  // MULTI/EXEC runs all four commands atomically, in one round trip,
  // so concurrent requests can't all read the same count
  const [, , requestCount] = await redisClient
    .multi()
    .zRemRangeByScore(key, 0, now - windowMs) // drop entries outside the window
    .zAdd(key, { score: now, value: member }) // record this request
    .zCard(key)                               // count, including this request
    .pExpire(key, windowMs)
    .exec();

  if (requestCount > maxRequests) {
    await redisClient.zRem(key, member); // rejected requests don't use up quota
    return res.status(429).json({
      error: 'Too many requests',
      retryAfter: Math.ceil(windowMs / 1000)
    });
  }

  res.set('X-RateLimit-Remaining', String(maxRequests - requestCount));
  next();
}

app.use('/api', rateLimiter);
app.listen(3000);
Terminal: the first request shows X-RateLimit-Remaining 99, a loop of 100 more requests yields 99 responses with 200 and one 429, the next request returns HTTP 429 with error Too many requests and retryAfter 60, and ZCARD on the rate-limit key is 100.
Example output. After 100 requests inside a minute the sliding-window limiter answers 429 with retryAfter: 60.

Rate Limiting

Why sorted sets instead of a simple counter? Fixed-window counters have a boundary problem. A user fires 100 requests at the end of one window, 100 more at the start of the next. Effectively doubles their throughput in a short burst. The sliding window uses timestamps as scores, pruning expired entries each request, so there is no window boundary to exploit. Two details matter. Each member must be unique (here a timestamp plus a UUID); using only the timestamp collapses requests that arrive in the same millisecond into one entry. And the prune, add and count steps run inside one MULTI transaction, so concurrent requests can't each read a stale count and slip past the limit. A Lua script works too if you need more logic server-side.

If that level of precision doesn't matter, INCR with a TTL gives you a fixed-window counter in two commands.

One note on the session store: since connect-redis 8, RedisStore is a named export (import { RedisStore } from 'connect-redis'). Older tutorials that use a default import fail on current versions, and connect-redis 9+ supports only the redis client (v5+), not ioredis.

On sessions: set TTLs. Rotate session IDs on login. Encrypt at rest if compliance requires it.

Where Caching Bugs Actually Come From

The number one caching bug isn't stale data — it's forgetting that the cache exists and debugging the database when the cache is serving old responses. You'll stare at query logs, check indexes, add profiling, and everything looks fine because the database is returning the right data. The cache sitting between the database and the user, quietly serving a response from forty minutes ago, never shows up in your database monitoring. Always check the cache first when something looks wrong.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.