api-database-redis · git:20260316.d7ef49c · 2026-03-16 · sha256 b4449741a8033bd9

api-database-redis git:20260316.d7ef49cA

Immutable. This exact content is served forever at /api/v1/blob/b4449741a8033bd9.

---
name: api-database-redis
description: Redis in-memory data store patterns with ioredis and node-redis -- caching, sessions, rate limiting, pub/sub, streams, queues, transactions, cluster
---

# Redis Patterns

> **Quick Guide:** Use Redis as an in-memory data store for caching, session management, rate limiting, pub/sub messaging, and job queues. Use **ioredis** (v5.x) as the primary client for its superior TypeScript support, Cluster/Sentinel integration, auto-pipelining, and Lua scripting. Use **node-redis** (v5.x) only when you need Redis Stack modules (JSON, Search, TimeSeries). Always set `maxRetriesPerRequest: null` for BullMQ workers, use separate connections for Pub/Sub subscribers, and define Lua scripts via `defineCommand` for atomic multi-step operations.

---

<critical_requirements>

## CRITICAL: Before Using This Skill

> **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)

**(You MUST use a SEPARATE Redis connection for Pub/Sub subscribers -- a subscribed connection enters a special mode and cannot execute other commands)**

**(You MUST set `maxRetriesPerRequest: null` on any ioredis connection passed to BullMQ -- BullMQ requires infinite retries and will throw if this is not set)**

**(You MUST use Lua scripts (`defineCommand` or `eval`) for any operation requiring atomicity across multiple Redis commands -- separate commands are NOT atomic even in a pipeline)**

**(You MUST handle the `error` event on every Redis client instance -- unhandled errors crash the Node.js process)**

</critical_requirements>

---

**Detailed Resources:**

- For code examples, see [examples/](examples/) folder:
  - [redis.md](examples/redis.md) - Caching patterns, session storage, rate limiting, pub/sub, job queues
- For decision frameworks, quick reference, and anti-patterns, see [reference.md](reference.md)

---

**Auto-detection:** Redis, ioredis, node-redis, createClient, RedisStore, BullMQ, Queue, Worker, pub/sub, MULTI, EXEC, pipeline, Lua script, defineCommand, xadd, xread, cache-aside, rate limit, session store, connect-redis, Redis.Cluster, Sentinel

**When to use:**

- Caching database queries or API responses (cache-aside, write-through)
- Session storage for Express/Hono/Fastify applications
- Distributed rate limiting (sliding window, token bucket)
- Real-time messaging with Pub/Sub
- Background job processing with BullMQ queues
- Leaderboards, counters, and real-time analytics with sorted sets
- Distributed locks and atomic operations with Lua scripts

**Key patterns covered:**

- ioredis connection setup, configuration, and error handling
- Data structures (strings, hashes, lists, sets, sorted sets, streams)
- Cache-aside and write-through caching with TTL management
- Session storage with connect-redis
- Rate limiting with Lua scripts (sliding window, token bucket)
- Pub/Sub messaging with separate connections
- Redis Streams for persistent message queues
- BullMQ for job queues with retries and scheduling
- Pipelining and transactions (MULTI/EXEC)
- Lua scripting for atomic operations
- Cluster mode and Sentinel for high availability
- Production connection management and reconnection strategies

**When NOT to use:**

- Primary database for relational data (use PostgreSQL/Drizzle)
- Document storage with complex queries (use MongoDB)
- Large binary file storage (use S3/object storage)
- Data that must survive total memory loss without persistence configured

---

<philosophy>

## Philosophy

Redis is an **in-memory data store** used as a cache, message broker, and streaming engine. The core principle: **use Redis for fast, ephemeral, or real-time data -- not as a primary database.**

**Core principles:**

1. **Cache, don't store** -- Redis complements your primary database. Cache frequently accessed data, but always have a source of truth elsewhere.
2. **Atomic operations** -- Use Lua scripts or MULTI/EXEC for operations spanning multiple keys. Individual Redis commands are atomic, but sequences are not.
3. **Separate concerns** -- Use different Redis databases (or key prefixes) for caching, sessions, and queues. Use separate connections for Pub/Sub.
4. **Set TTLs on everything** -- Memory is finite. Every cached key should expire. Use `EX` (seconds) or `PX` (milliseconds) on SET commands.
5. **Fail gracefully** -- Redis is a cache, not a database. If Redis is down, the application should degrade gracefully (bypass cache, use database directly).

**When to use Redis:**

- High-frequency reads that benefit from in-memory speed
- Session data shared across multiple application instances
- Rate limiting in distributed environments
- Real-time features (chat, notifications, live updates)
- Background job processing and task scheduling
- Leaderboards, counters, and analytics requiring atomic increments

**When NOT to use:**

- As a primary database (data loss risk on restart without persistence)
- Complex queries with JOINs or aggregations (use a relational database)
- Storing data larger than available memory
- Long-term data archival

</philosophy>

---

<patterns>

## Core Patterns

### Pattern 1: ioredis Connection Setup

Configure ioredis with proper error handling, reconnection strategy, and TypeScript types.

#### Basic Connection

```typescript
// ✅ Good Example - Proper ioredis setup with error handling
import Redis from "ioredis";

const RETRY_DELAY_BASE_MS = 50;
const RETRY_DELAY_MAX_MS = 2000;

function createRedisClient(): Redis {
  const url = process.env.REDIS_URL;
  if (!url) {
    throw new Error("REDIS_URL environment variable is required");
  }

  const client = new Redis(url, {
    maxRetriesPerRequest: 3,
    retryStrategy(times) {
      const delay = Math.min(times * RETRY_DELAY_BASE_MS, RETRY_DELAY_MAX_MS);
      return delay;
    },
    lazyConnect: true,
  });

  client.on("error", (err) => {
    console.error("Redis connection error:", err.message);
  });

  client.on("connect", () => {
    console.log("Redis connected");
  });

  return client;
}

export { createRedisClient };
```

**Why good:** Environment variable validation, named constants for retry delays, `lazyConnect` prevents connection before ready, error event handler prevents process crash, reconnection strategy with exponential backoff capped at max delay

```typescript
// ❌ Bad Example - No error handling, hardcoded config
import Redis from "ioredis";

const redis = new Redis("redis://localhost:6379");
// No error handler -- unhandled errors crash the process
// No retry strategy -- uses default which may not suit your needs
// Hardcoded connection string -- leaks in version control
```

**Why bad:** Missing error event handler crashes Node.js process on connection failure, hardcoded URL prevents environment-specific configuration, no retry strategy customization

#### node-redis Connection (Alternative)

```typescript
// ✅ Good Example - node-redis when you need Redis Stack modules
import { createClient } from "redis";

async function createNodeRedisClient() {
  const url = process.env.REDIS_URL;
  if (!url) {
    throw new Error("REDIS_URL environment variable is required");
  }

  const client = createClient({ url });

  client.on("error", (err) => {
    console.error("Redis client error:", err.message);
  });

  await client.connect();
  return client;
}

export { createNodeRedisClient };
```

**When to use:** Only when you need Redis Stack modules (JSON, Search, TimeSeries) -- ioredis does not support Redis Stack modules natively.

---

### Pattern 2: Data Structures and Basic Operations

Redis provides multiple data structures, each optimized for specific use cases.

#### Strings (Key-Value)

```typescript
import type Redis from "ioredis";

const DEFAULT_TTL_SECONDS = 3600; // 1 hour

async function setWithTTL(
  redis: Redis,
  key: string,
  value: string,
  ttlSeconds: number = DEFAULT_TTL_SECONDS,
): Promise<void> {
  await redis.set(key, value, "EX", ttlSeconds);
}

async function getOrNull(redis: Redis, key: string): Promise<string | null> {
  return redis.get(key);
}

export { setWithTTL, getOrNull };
```

#### Hashes (Object-like)

```typescript
import type Redis from "ioredis";

interface UserProfile {
  name: string;
  email: string;
  role: string;
}

const USER_KEY_PREFIX = "user:";
const USER_TTL_SECONDS = 1800; // 30 minutes

async function setUserProfile(
  redis: Redis,
  userId: string,
  profile: UserProfile,
): Promise<void> {
  const key = `${USER_KEY_PREFIX}${userId}`;
  await redis.hset(key, profile);
  await redis.expire(key, USER_TTL_SECONDS);
}

async function getUserProfile(
  redis: Redis,
  userId: string,
): Promise<UserProfile | null> {
  const key = `${USER_KEY_PREFIX}${userId}`;
  const data = await redis.hgetall(key);
  if (!data || Object.keys(data).length === 0) {
    return null;
  }
  return data as UserProfile;
}

export { setUserProfile, getUserProfile };
```

**Why good:** Key prefix separates concerns, TTL prevents stale data, null check on empty hash response, typed return

#### Sorted Sets (Leaderboards, Rankings)

```typescript
import type Redis from "ioredis";

const LEADERBOARD_KEY = "leaderboard:global";
const TOP_PLAYERS_COUNT = 10;

async function updateScore(
  redis: Redis,
  playerId: string,
  score: number,
): Promise<void> {
  await redis.zadd(LEADERBOARD_KEY, score, playerId);
}

async function getTopPlayers(
  redis: Redis,
): Promise<Array<{ playerId: string; score: number }>> {
  // ZREVRANGE returns highest scores first
  const results = await redis.zrevrange(
    LEADERBOARD_KEY,
    0,
    TOP_PLAYERS_COUNT - 1,
    "WITHSCORES",
  );

  const players: Array<{ playerId: string; score: number }> = [];
  for (let i = 0; i < results.length; i += 2) {
    players.push({
      playerId: results[i],
      score: parseFloat(results[i + 1]),
    });
  }
  return players;
}

async function getPlayerRank(
  redis: Redis,
  playerId: string,
): Promise<number | null> {
  // ZREVRANK returns 0-based rank (highest score = rank 0)
  const rank = await redis.zrevrank(LEADERBOARD_KEY, playerId);
  return rank !== null ? rank + 1 : null; // Convert to 1-based
}

export { updateScore, getTopPlayers, getPlayerRank };
```

**Why good:** Named constants for key and count, ZREVRANGE for descending order, WITHSCORES returns scores alongside members, 1-based rank conversion for user display

#### Lists (Queues, Recent Items)

```typescript
import type Redis from "ioredis";

const RECENT_ITEMS_KEY = "recent:items";
const MAX_RECENT_ITEMS = 50;

async function addRecentItem(redis: Redis, item: string): Promise<void> {
  await redis
    .pipeline()
    .lpush(RECENT_ITEMS_KEY, item)
    .ltrim(RECENT_ITEMS_KEY, 0, MAX_RECENT_ITEMS - 1)
    .exec();
}

async function getRecentItems(redis: Redis): Promise<string[]> {
  return redis.lrange(RECENT_ITEMS_KEY, 0, MAX_RECENT_ITEMS - 1);
}

export { addRecentItem, getRecentItems };
```

**Why good:** Pipeline groups push and trim into single round-trip, LTRIM caps list size preventing unbounded growth, named constants for key and limit

---

### Pattern 3: Cache-Aside Pattern

The most common caching strategy: check cache first, fall back to database on miss, populate cache for next time.

```typescript
import type Redis from "ioredis";

const CACHE_TTL_SECONDS = 300; // 5 minutes
const CACHE_KEY_PREFIX = "cache:";

interface CacheOptions {
  ttlSeconds?: number;
  keyPrefix?: string;
}

async function cacheAside<T>(
  redis: Redis,
  key: string,
  fetcher: () => Promise<T>,
  options: CacheOptions = {},
): Promise<T> {
  const { ttlSeconds = CACHE_TTL_SECONDS, keyPrefix = CACHE_KEY_PREFIX } =
    options;
  const cacheKey = `${keyPrefix}${key}`;

  // 1. Check cache
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }

  // 2. Cache miss -- fetch from source
  const data = await fetcher();

  // 3. Populate cache (don't await -- fire and forget is fine for cache writes)
  redis.set(cacheKey, JSON.stringify(data), "EX", ttlSeconds).catch((err) => {
    console.error(`Cache write failed for ${cacheKey}:`, err.message);
  });

  return data;
}

export { cacheAside };
```

#### Usage

```typescript
// ✅ Good Example - Cache-aside with database fetcher
import { cacheAside } from "./cache";

const PRODUCT_CACHE_TTL = 600; // 10 minutes

async function getProduct(productId: string): Promise<Product> {
  return cacheAside(
    redis,
    `product:${productId}`,
    () => db.query.products.findFirst({ where: eq(products.id, productId) }),
    { ttlSeconds: PRODUCT_CACHE_TTL },
  );
}

export { getProduct };
```

**Why good:** Generic `cacheAside<T>` works with any data type, fire-and-forget cache write prevents cache failure from blocking response, configurable TTL per use case, key prefix separates cache keys from other Redis data

```typescript
// ❌ Bad Example - Manual cache-aside with no error handling
const data = await redis.get("product:123");
if (data) {
  return JSON.parse(data);
}
const product = await db.getProduct("123");
await redis.set("product:123", JSON.stringify(product));
// Missing TTL -- data never expires
// Missing error handling -- cache failure blocks response
// Hardcoded key -- no prefix separation
return product;
```

**Why bad:** No TTL causes stale data forever, cache write failure blocks the response, no key prefix risks collisions, not reusable

---

### Pattern 4: Cache Invalidation

Invalidating cache when data changes.

#### Write-Through with Invalidation

```typescript
import type Redis from "ioredis";

const PRODUCT_CACHE_PREFIX = "cache:product:";
const PRODUCT_LIST_CACHE_KEY = "cache:products:list";
const PRODUCT_CACHE_TTL = 600; // 10 minutes

async function updateProduct(
  redis: Redis,
  productId: string,
  updates: Partial<Product>,
): Promise<Product> {
  // 1. Update database (source of truth)
  const updated = await db
    .update(products)
    .set(updates)
    .where(eq(products.id, productId))
    .returning();

  // 2. Invalidate specific cache entry
  await redis.del(`${PRODUCT_CACHE_PREFIX}${productId}`);

  // 3. Invalidate list cache (stale after update)
  await redis.del(PRODUCT_LIST_CACHE_KEY);

  return updated[0];
}

async function deleteProduct(redis: Redis, productId: string): Promise<void> {
  await db.delete(products).where(eq(products.id, productId));

  // Invalidate all related cache keys using pattern
  const keys = await redis.keys(`${PRODUCT_CACHE_PREFIX}${productId}*`);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

export { updateProduct, deleteProduct };
```

**Why good:** Database is always updated first (source of truth), both specific and list caches invalidated, pattern-based deletion for related keys

**When to use:** Write-heavy applications where consistency matters more than cache hit rate.

**When not to use:** Read-heavy applications with rare writes -- cache-aside with TTL is simpler and sufficient.

---

### Pattern 5: Pipelining and Transactions

Batch multiple commands to reduce network round-trips.

#### Pipelining (Non-Atomic Batching)

```typescript
import type Redis from "ioredis";

const USER_KEY_PREFIX = "user:";
const USER_TTL_SECONDS = 3600;

async function cacheMultipleUsers(
  redis: Redis,
  users: Array<{ id: string; name: string; email: string }>,
): Promise<void> {
  const pipeline = redis.pipeline();

  for (const user of users) {
    const key = `${USER_KEY_PREFIX}${user.id}`;
    pipeline.hset(key, { name: user.name, email: user.email });
    pipeline.expire(key, USER_TTL_SECONDS);
  }

  const results = await pipeline.exec();
  if (!results) {
    throw new Error("Pipeline execution returned null");
  }

  // Check for errors in pipeline results
  for (const [err] of results) {
    if (err) {
      throw new Error(`Pipeline command failed: ${err.message}`);
    }
  }
}

export { cacheMultipleUsers };
```

**Why good:** Single network round-trip for all commands, error checking on each result, named constants for prefix and TTL

#### Transactions (MULTI/EXEC -- Atomic)

```typescript
import type Redis from "ioredis";

const BALANCE_KEY_PREFIX = "balance:";

async function transferBalance(
  redis: Redis,
  fromUserId: string,
  toUserId: string,
  amount: number,
): Promise<boolean> {
  const fromKey = `${BALANCE_KEY_PREFIX}${fromUserId}`;
  const toKey = `${BALANCE_KEY_PREFIX}${toUserId}`;

  // WATCH for optimistic locking
  await redis.watch(fromKey);

  const currentBalance = await redis.get(fromKey);
  if (!currentBalance || parseFloat(currentBalance) < amount) {
    await redis.unwatch();
    return false; // Insufficient balance
  }

  // MULTI/EXEC -- atomic execution
  const results = await redis
    .multi()
    .decrby(fromKey, amount)
    .incrby(toKey, amount)
    .exec();

  // results is null if WATCH detected a change (optimistic lock failure)
  if (!results) {
    return false; // Retry needed -- another client modified the key
  }

  return true;
}

export { transferBalance };
```

**Why good:** WATCH provides optimistic locking, MULTI/EXEC ensures atomicity, null check handles concurrent modification, clear return value for retry logic

```typescript
// ❌ Bad Example - Non-atomic balance transfer
await redis.decrby("balance:user1", 100);
await redis.incrby("balance:user2", 100);
// If the process crashes between these two commands,
// money disappears from user1 but never reaches user2
```

**Why bad:** Two separate commands are not atomic, crash between them causes data inconsistency, no optimistic locking for concurrent access

---

### Pattern 6: Lua Scripting for Atomic Operations

Lua scripts execute atomically on the Redis server -- no other command can run between script steps.

#### Defining Custom Commands

```typescript
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

// Define a rate limiter as a custom command
redis.defineCommand("rateLimit", {
  numberOfKeys: 1,
  lua: `
    local key = KEYS[1]
    local limit = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])

    -- Remove expired entries
    redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

    -- Count current requests
    local count = redis.call('ZCARD', key)

    if count < limit then
      -- Add current request
      redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
      redis.call('EXPIRE', key, window)
      return 1 -- Allowed
    else
      return 0 -- Rate limited
    end
  `,
});

// TypeScript declaration for the custom command
declare module "ioredis" {
  interface RedisCommander<Context> {
    rateLimit(
      key: string,
      limit: string,
      windowMs: string,
      nowMs: string,
    ): Promise<number>;
  }
}

export { redis };
```

**Why good:** `defineCommand` uses EVALSHA internally for performance (sends script hash instead of full script on subsequent calls), declare module extends TypeScript types for custom commands, Lua script is atomic -- no race conditions between ZREMRANGEBYSCORE and ZADD

#### Using the Custom Command

```typescript
const MAX_REQUESTS = 100;
const WINDOW_SECONDS = 60;

async function checkRateLimit(
  redis: Redis,
  identifier: string,
): Promise<boolean> {
  const key = `ratelimit:${identifier}`;
  const now = Date.now();

  const allowed = await redis.rateLimit(
    key,
    String(MAX_REQUESTS),
    String(WINDOW_SECONDS * 1000),
    String(now),
  );

  return allowed === 1;
}

export { checkRateLimit };
```

---

### Pattern 7: Pub/Sub Messaging

Redis Pub/Sub requires **separate connections** for subscribing and publishing.

```typescript
// ✅ Good Example - Separate connections for pub/sub
import Redis from "ioredis";

const NOTIFICATION_CHANNEL = "notifications";

function createPubSubClients() {
  const url = process.env.REDIS_URL!;

  // Publisher can be your regular Redis client
  const publisher = new Redis(url);
  publisher.on("error", (err) => {
    console.error("Publisher error:", err.message);
  });

  // Subscriber MUST be a separate connection
  const subscriber = new Redis(url);
  subscriber.on("error", (err) => {
    console.error("Subscriber error:", err.message);
  });

  return { publisher, subscriber };
}

// Subscribe to channels
async function setupSubscriber(subscriber: Redis): Promise<void> {
  await subscriber.subscribe(NOTIFICATION_CHANNEL);

  subscriber.on("message", (channel, message) => {
    const data = JSON.parse(message);
    console.log(`Received on ${channel}:`, data);
    handleNotification(data);
  });
}

// Publish messages
async function publishNotification(
  publisher: Redis,
  notification: { userId: string; type: string; message: string },
): Promise<number> {
  // Returns number of subscribers that received the message
  return publisher.publish(NOTIFICATION_CHANNEL, JSON.stringify(notification));
}

export { createPubSubClients, setupSubscriber, publishNotification };
```

**Why good:** Separate connections for pub and sub (required by Redis protocol), error handlers on both, typed notification payload, publish returns subscriber count for observability

```typescript
// ❌ Bad Example - Using same connection for pub and sub
const redis = new Redis();
await redis.subscribe("channel");
await redis.set("key", "value"); // ERROR: connection is in subscriber mode
```

**Why bad:** A subscribed connection enters a special mode and cannot execute non-pub/sub commands -- `set` will throw an error

#### Pattern Subscriptions

```typescript
// Subscribe to all channels matching a pattern
await subscriber.psubscribe("notifications:*");

subscriber.on("pmessage", (pattern, channel, message) => {
  // pattern: "notifications:*"
  // channel: "notifications:user:123" (actual channel)
  // message: the published data
  console.log(`Pattern ${pattern} matched channel ${channel}`);
});
```

---

### Pattern 8: Redis Streams

Streams provide persistent, ordered message logs with consumer groups for reliable processing.

```typescript
import Redis from "ioredis";

const STREAM_KEY = "events:orders";
const CONSUMER_GROUP = "order-processors";
const BLOCK_TIMEOUT_MS = 5000;
const BATCH_SIZE = 10;

// Producer: add events to stream
async function publishOrderEvent(
  redis: Redis,
  event: { orderId: string; action: string; data: string },
): Promise<string> {
  // '*' auto-generates the entry ID (timestamp-based)
  const entryId = await redis.xadd(
    STREAM_KEY,
    "*",
    "orderId",
    event.orderId,
    "action",
    event.action,
    "data",
    event.data,
  );
  return entryId;
}

// Create consumer group (run once at startup)
async function createConsumerGroup(redis: Redis): Promise<void> {
  try {
    await redis.xgroup("CREATE", STREAM_KEY, CONSUMER_GROUP, "0", "MKSTREAM");
  } catch (err) {
    // Group already exists -- safe to ignore
    if (!(err instanceof Error) || !err.message.includes("BUSYGROUP")) {
      throw err;
    }
  }
}

// Consumer: read and acknowledge events
async function consumeOrderEvents(
  redis: Redis,
  consumerName: string,
): Promise<void> {
  while (true) {
    const results = await redis.xreadgroup(
      "GROUP",
      CONSUMER_GROUP,
      consumerName,
      "COUNT",
      String(BATCH_SIZE),
      "BLOCK",
      String(BLOCK_TIMEOUT_MS),
      "STREAMS",
      STREAM_KEY,
      ">", // Read only new messages
    );

    if (!results) continue; // Timeout, no new messages

    for (const [, messages] of results) {
      for (const [id, fields] of messages) {
        try {
          // Process the event
          await processOrderEvent(fields);

          // Acknowledge successful processing
          await redis.xack(STREAM_KEY, CONSUMER_GROUP, id);
        } catch (err) {
          console.error(`Failed to process event ${id}:`, err);
          // Message remains pending -- will be redelivered
        }
      }
    }
  }
}

export { publishOrderEvent, createConsumerGroup, consumeOrderEvents };
```

**Why good:** MKSTREAM creates the stream if it doesn't exist, BUSYGROUP error handling for idempotent group creation, XACK confirms processing (unacked messages redeliver), BLOCK prevents busy-waiting, named constants for all configuration

---

### Pattern 9: BullMQ Job Queues

BullMQ provides robust job queuing built on Redis with retries, scheduling, and priorities.

#### Queue and Worker Setup

```typescript
// ✅ Good Example - BullMQ with proper connection config
import { Queue, Worker, type Job } from "bullmq";
import Redis from "ioredis";

const QUEUE_NAME = "email-queue";
const MAX_RETRY_ATTEMPTS = 3;
const BACKOFF_DELAY_MS = 1000;
const CONCURRENCY = 5;

interface EmailJobData {
  to: string;
  subject: string;
  body: string;
}

// Shared connection config
function createBullMQConnection(): Redis {
  return new Redis(process.env.REDIS_URL!, {
    maxRetriesPerRequest: null, // REQUIRED for BullMQ
  });
}

// Queue: add jobs
const emailQueue = new Queue<EmailJobData>(QUEUE_NAME, {
  connection: createBullMQConnection(),
  defaultJobOptions: {
    attempts: MAX_RETRY_ATTEMPTS,
    backoff: {
      type: "exponential",
      delay: BACKOFF_DELAY_MS,
    },
    removeOnComplete: { count: 1000 }, // Keep last 1000 completed
    removeOnFail: { count: 5000 }, // Keep last 5000 failed
  },
});

// Worker: process jobs
const emailWorker = new Worker<EmailJobData>(
  QUEUE_NAME,
  async (job: Job<EmailJobData>) => {
    const { to, subject, body } = job.data;
    await sendEmail(to, subject, body);
    return { sent: true, to };
  },
  {
    connection: createBullMQConnection(),
    concurrency: CONCURRENCY,
  },
);

emailWorker.on("completed", (job) => {
  console.log(`Job ${job.id} completed: email sent to ${job.data.to}`);
});

emailWorker.on("failed", (job, err) => {
  console.error(`Job ${job?.id} failed:`, err.message);
});

export { emailQueue, emailWorker };
```

**Why good:** `maxRetriesPerRequest: null` is required for BullMQ (it retries internally), typed job data with `Queue<EmailJobData>`, exponential backoff for retries, cleanup policies prevent unbounded Redis memory growth, separate connection per Queue/Worker (BullMQ requirement)

```typescript
// ❌ Bad Example - BullMQ without required config
import { Queue, Worker } from "bullmq";
import Redis from "ioredis";

const connection = new Redis(); // Missing maxRetriesPerRequest: null
const queue = new Queue("emails", { connection });
// BullMQ will throw: "maxRetriesPerRequest must be null"
```

**Why bad:** BullMQ requires `maxRetriesPerRequest: null` -- without it, ioredis gives up retrying after a set number of attempts, but BullMQ expects to retry forever

#### Adding Jobs with Scheduling

```typescript
const REPORT_DELAY_MS = 60000; // 1 minute
const HIGH_PRIORITY = 1;
const LOW_PRIORITY = 10;

// Immediate job
await emailQueue.add("welcome-email", {
  to: "user@example.com",
  subject: "Welcome!",
  body: "Thanks for signing up.",
});

// Delayed job
await emailQueue.add(
  "reminder-email",
  { to: "user@example.com", subject: "Reminder", body: "Don't forget!" },
  { delay: REPORT_DELAY_MS },
);

// Priority job (lower number = higher priority)
await emailQueue.add(
  "urgent-email",
  { to: "admin@example.com", subject: "Alert!", body: "System alert." },
  { priority: HIGH_PRIORITY },
);

// Repeatable job (cron schedule)
await emailQueue.add(
  "daily-digest",
  { to: "user@example.com", subject: "Daily Digest", body: "..." },
  { repeat: { pattern: "0 9 * * *" } }, // Every day at 9 AM
);
```

---

### Pattern 10: Cluster Mode

ioredis supports Redis Cluster for horizontal scaling and high availability.

```typescript
import Redis from "ioredis";

const CLUSTER_RETRY_BASE_MS = 100;
const CLUSTER_RETRY_MAX_MS = 2000;
const MAX_REDIRECTIONS = 16;

const cluster = new Redis.Cluster(
  [
    { host: "redis-node-1", port: 6379 },
    { host: "redis-node-2", port: 6379 },
    { host: "redis-node-3", port: 6379 },
  ],
  {
    clusterRetryStrategy(times) {
      return Math.min(times * CLUSTER_RETRY_BASE_MS, CLUSTER_RETRY_MAX_MS);
    },
    maxRedirections: MAX_REDIRECTIONS,
    scaleReads: "slave", // Read from replicas, write to master
    redisOptions: {
      password: process.env.REDIS_PASSWORD,
    },
  },
);

cluster.on("error", (err) => {
  console.error("Cluster error:", err.message);
});

// Use cluster exactly like a regular Redis client
await cluster.set("key", "value");
const value = await cluster.get("key");

export { cluster };
```

**Why good:** Multiple seed nodes for discovery, `scaleReads: "slave"` offloads reads to replicas, retry strategy with backoff, password from environment variable

#### Sentinel Setup

```typescript
import Redis from "ioredis";

const sentinel = new Redis({
  sentinels: [
    { host: "sentinel-1", port: 26379 },
    { host: "sentinel-2", port: 26379 },
    { host: "sentinel-3", port: 26379 },
  ],
  name: "mymaster", // Sentinel group name
  sentinelRetryStrategy(times) {
    return Math.min(times * 10, 1000);
  },
  failoverDetector: true, // Detect failover and reconnect automatically
});

sentinel.on("error", (err) => {
  console.error("Sentinel error:", err.message);
});

export { sentinel };
```

**Why good:** Multiple sentinel nodes for redundancy, `failoverDetector: true` for automatic master failover handling, retry strategy for sentinel connectivity

</patterns>

---

<performance>

## Performance Optimization

### Auto-Pipelining

ioredis can automatically batch commands issued during the same event loop tick:

```typescript
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!, {
  enableAutoPipelining: true,
});

// These three commands are automatically batched into one pipeline
const [name, email, role] = await Promise.all([
  redis.get("user:name"),
  redis.get("user:email"),
  redis.get("user:role"),
]);

export { redis };
```

**When to use:** High-throughput applications issuing many independent commands per request. Auto-pipelining reduces network round-trips without changing application code.

**When NOT to use:** When commands depend on each other's results (sequential logic), or when using WATCH/MULTI for transactions.

---

### Key Expiration Strategies

```typescript
const TTL_SHORT_SECONDS = 60; // 1 minute -- volatile data
const TTL_MEDIUM_SECONDS = 300; // 5 minutes -- API response cache
const TTL_LONG_SECONDS = 3600; // 1 hour -- user profiles
const TTL_SESSION_SECONDS = 86400; // 24 hours -- sessions

// SET with TTL (preferred -- atomic)
await redis.set("key", "value", "EX", TTL_MEDIUM_SECONDS);

// SET with millisecond TTL
await redis.set("key", "value", "PX", 500);

// SET only if key doesn't exist (distributed lock pattern)
const acquired = await redis.set("lock:resource", "owner-id", "EX", 30, "NX");
// Returns "OK" if lock acquired, null if already locked

// Update TTL on existing key
await redis.expire("key", TTL_LONG_SECONDS);
```

---

### Scanning Instead of KEYS

Never use `KEYS` in production -- it blocks the Redis server while scanning all keys.

```typescript
import type Redis from "ioredis";

const SCAN_BATCH_SIZE = 100;

async function findKeysByPattern(
  redis: Redis,
  pattern: string,
): Promise<string[]> {
  const allKeys: string[] = [];

  const stream = redis.scanStream({
    match: pattern,
    count: SCAN_BATCH_SIZE,
  });

  return new Promise((resolve, reject) => {
    stream.on("data", (keys: string[]) => {
      allKeys.push(...keys);
    });
    stream.on("end", () => resolve(allKeys));
    stream.on("error", (err) => reject(err));
  });
}

export { findKeysByPattern };
```

**Why good:** `scanStream` iterates incrementally without blocking Redis, `count` is a hint for batch size (not a guarantee), stream-based API handles large keyspaces

</performance>

---

<decision_framework>

## Decision Framework

### Which Redis Client?

```
Which Redis client should I use?
├─ Need Redis Stack modules (JSON, Search, TimeSeries)? → node-redis (v5.x)
├─ Using BullMQ for job queues? → ioredis (BullMQ requires it)
├─ Need Cluster or Sentinel support? → ioredis (built-in, battle-tested)
├─ Need auto-pipelining? → ioredis (enableAutoPipelining option)
└─ General caching/sessions/pub-sub? → ioredis (recommended default)
```

### Which Caching Strategy?

```
How should I cache this data?
├─ Read-heavy, tolerates brief staleness? → Cache-aside with TTL
├─ Needs strong consistency after writes? → Write-through (update DB + invalidate cache)
├─ Write-heavy, can tolerate brief data loss? → Write-behind (async cache update)
└─ Data changes rarely? → Cache-aside with long TTL + manual invalidation
```

### Which Data Structure?

```
What Redis data structure should I use?
├─ Simple key-value (cache, sessions)? → Strings (GET/SET)
├─ Object with multiple fields? → Hashes (HSET/HGET)
├─ Ordered ranking/leaderboard? → Sorted Sets (ZADD/ZRANGE)
├─ Queue (FIFO/LIFO)? → Lists (LPUSH/RPOP)
├─ Unique collection (tags, categories)? → Sets (SADD/SMEMBERS)
├─ Persistent message log with consumers? → Streams (XADD/XREAD)
└─ Rate limiting (sliding window)? → Sorted Sets + Lua script
```

### Which Messaging Pattern?

```
How should I implement real-time messaging?
├─ Fire-and-forget broadcast? → Pub/Sub (no persistence)
├─ Need message persistence and replay? → Streams with consumer groups
├─ Need reliable job processing with retries? → BullMQ (built on Redis)
└─ Need request-reply pattern? → Pub/Sub with correlation IDs
```

### Atomicity Decision

```
Do I need atomicity across multiple commands?
├─ YES → Are the commands on the same key?
│   ├─ YES → Use a single atomic command (INCR, SETNX, etc.)
│   └─ NO → Use Lua script (defineCommand)
├─ NO, but I want batching → Use pipeline (non-atomic, single round-trip)
└─ Need optimistic locking? → Use WATCH + MULTI/EXEC
```

</decision_framework>

---

<integration>

## Integration Guide

**Works with:**

- **Drizzle/Prisma** -- Cache database queries using cache-aside pattern; invalidate cache on writes
- **Express/Hono/Fastify** -- Session storage via connect-redis, rate limiting middleware
- **BullMQ** -- Job queues built on Redis (requires ioredis with `maxRetriesPerRequest: null`)
- **Socket.IO** -- Redis adapter for scaling WebSocket connections across multiple servers
- **Docker/Kubernetes** -- Redis containers for development, Redis Cluster for production

**Replaces / Conflicts with:**

- **In-memory caches (node-cache, lru-cache)** -- Redis provides distributed caching across multiple app instances; in-memory caches are per-process only
- **Database-backed sessions** -- Redis sessions are faster and reduce database load
- **RabbitMQ/Kafka** (partially) -- Redis Streams and BullMQ cover most queue use cases; use dedicated message brokers for complex routing or massive throughput

</integration>

---

<red_flags>

## RED FLAGS

**High Priority Issues:**

- Using the same connection for Pub/Sub subscribe and regular commands -- subscribed connections cannot execute non-pub/sub commands
- Missing `maxRetriesPerRequest: null` on BullMQ connections -- BullMQ throws immediately without this setting
- Using `KEYS` command in production -- blocks the entire Redis server while scanning all keys
- No `error` event handler on Redis client -- unhandled errors crash the Node.js process
- Storing large objects (> 1 MB) in Redis -- degrades performance and wastes memory; store a reference and fetch from object storage

**Medium Priority Issues:**

- Missing TTL on cached keys -- causes unbounded memory growth until Redis runs out of memory
- Using `del` with many keys instead of `unlink` -- `del` blocks Redis; `unlink` frees memory asynchronously
- Not using pipelining for batch operations -- each command is a separate network round-trip
- Serializing/deserializing complex objects without error handling -- malformed JSON in cache crashes on parse
- Sharing a single Redis connection across BullMQ Queue and Worker -- each needs its own connection

**Common Mistakes:**

- Assuming pipeline commands are atomic -- pipelines batch for network efficiency but do not provide atomicity (use MULTI/EXEC or Lua)
- Forgetting that `hgetall` returns an empty object `{}` for non-existent keys (not `null`) -- check `Object.keys(result).length === 0`
- Using `MULTI/EXEC` without `WATCH` for conditional updates -- transactions execute unconditionally unless you WATCH keys first
- Not handling `null` returns from `GET` -- cache misses return `null`, not `undefined`
- Connecting to Redis without TLS in production -- credentials sent in plaintext over the network

**Gotchas & Edge Cases:**

- Redis `HGETALL` returns all values as strings -- numbers stored with `HSET` come back as strings, requiring explicit parsing
- `EXPIRE` resets when a key is overwritten with `SET` -- if you `SET` a key that already has a TTL, the TTL is removed unless you include `EX`/`PX` in the `SET` command
- Pub/Sub messages are fire-and-forget -- if no subscriber is listening when a message is published, it is lost forever (use Streams for persistence)
- Redis Cluster does not support multi-key operations across different hash slots -- use `{hash-tag}` prefix to force related keys to the same slot
- `WATCH` is connection-scoped -- concurrent requests sharing a connection will interfere with each other's WATCH state
- ioredis auto-pipelining does not work with `WATCH`/`MULTI` or blocking commands (`BRPOP`, `BLPOP`, `XREAD BLOCK`)

</red_flags>

---

<critical_reminders>

## CRITICAL REMINDERS

> **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)

**(You MUST use a SEPARATE Redis connection for Pub/Sub subscribers -- a subscribed connection enters a special mode and cannot execute other commands)**

**(You MUST set `maxRetriesPerRequest: null` on any ioredis connection passed to BullMQ -- BullMQ requires infinite retries and will throw if this is not set)**

**(You MUST use Lua scripts (`defineCommand` or `eval`) for any operation requiring atomicity across multiple Redis commands -- separate commands are NOT atomic even in a pipeline)**

**(You MUST handle the `error` event on every Redis client instance -- unhandled errors crash the Node.js process)**

**Failure to follow these rules will cause pub/sub failures, BullMQ connection errors, race conditions, and application crashes.**

</critical_reminders>