Skip to content

Redis in production: what it earned its place doing

6/7/2026Backend DevelopmentExpress Boilerplate9 min read

title: "Redis in production: what it earned its place doing" slug: engineering-quality-dep-redis pillar: Engineering Quality angle: trade-off audience: CTOs, senior engineers, fellow practitioners status: draft

Redis in production: what it earned its place doing

Why redis@^4.7.1 and ioredis@^5.6.1 are both in this repo — and why ioredis ended up running almost every time a user does something.

This boilerplate's package.json lists two Redis clients: the official redis@^4.7.1 and ioredis@^5.6.1. That duplication is not an oversight. It reflects three separate decisions — background jobs, idempotency enforcement, and short-lived challenge storage — that each pushed in the same direction independently, and then converged on a single Redis instance doing four different things at once. That convergence is the interesting part.

The decision and why it cannot be deferred

Most API projects start without Redis. The first version of any feature — sessions, background jobs, rate limiting — usually gets a simpler implementation: a Postgres-backed job table, a stateful middleware Map, a database-level lock. These work until they don't. The pain arrives in a cluster of moments: the in-memory rate limiter resets across every deployment, the "send confirmation email" function blocks the HTTP response when the mail server is slow, the payment endpoint processes the same request twice because the mobile client retried on a timeout.

For a TypeScript/Node API boilerplate designed to handle production-grade requirements from day one, deferring Redis past the first month would mean re-architecting three separate systems later. BullMQ, which handles background jobs, has no in-memory mode. Its documentation specifies ioredis. If background jobs are in scope — and for any API that sends email, dispatches webhooks, or generates exports, they are — Redis becomes a hard prerequisite rather than an optimization.

The dependency declaration makes the intent explicit:

"bullmq": "^5.52.1",
"redis": "^4.7.1",
"ioredis": "^5.6.1"

Both Redis clients in the same package.json. Both justified by different constraints.

Option A — in-memory state, no external store

Before Redis, every stateful problem in a Node process gets solved with something that lives in the process: a Map, a Set, an LRU cache like lru-cache. This works perfectly well for one instance and one restart cycle.

The failure modes are not subtle. An in-memory idempotency Map evaporates on deploy, meaning a payment retry that arrives one second after a zero-downtime deployment can process twice. An in-memory session store means every horizontal scale event logs out every user. An in-memory challenge store for WebAuthn means a user who takes 90 seconds to touch their security key on a loaded server hits a challenge that no longer exists.

For a boilerplate designed to run on multiple dynos or containers, in-memory state is a design choice that has to be defended specifically — not assumed.

Option B — ioredis as the primary client

BullMQ's requirement is documented and non-negotiable: maxRetriesPerRequest must be null. This tells ioredis to retry indefinitely rather than throwing after a fixed number of attempts, which is what BullMQ's queue workers need when they issue blocking Redis commands.

The boilerplate centralises this into a single module:

// modules/redis/redis.service.ts
import { Redis } from 'ioredis';
import { env } from '@/modules/env';

export const redisConnectionOptions = {
  host: env.REDIS_HOST,
  port: env.REDIS_PORT,
  password: env.REDIS_PASSWORD || '',
  maxRetriesPerRequest: null as null, // Required by BullMQ
};

const redisInstance = new Redis(redisConnectionOptions);

/** Create an independent Redis connection (e.g. for Pub/Sub subscribers) */
export const createRedisConnection = (): Redis => new Redis(redisConnectionOptions);

export default redisInstance;

The createRedisConnection export matters. Pub/Sub subscribers need dedicated connections because a subscriber that has called SUBSCRIBE can only receive messages on that connection — it can't be used for regular get/set operations simultaneously. Exposing a factory rather than re-exporting the singleton makes this constraint explicit at the call site.

Option C — the official redis npm package

The redis@^4.7.1 package (official Node Redis client, v4+) has a cleaner promise-based API and is maintained directly by Redis. Its createClient() API surface is more TypeScript-idiomatic in some respects. The reason it did not become the primary client here: BullMQ does not support it natively. If redis were the only client, you would need a second ioredis instance anyway for BullMQ's connection management.

The practical outcome when both packages are present is that redis handles specific legacy or compatibility scenarios while ioredis handles everything that touches BullMQ and the shared singleton. The key constraint is avoiding a second Redis connection multiplier: BullMQ already creates its own internal ioredis connections per worker; adding a third client library would triple the connection overhead with no corresponding benefit.

The deciding factor in this repo and the artifact that justifies it

BullMQ and the shared ioredis singleton use separate connection configurations intentionally:

// modules/redis/redis.bullmq.ts
import { ConnectionOptions, Queue } from 'bullmq';
import { env } from '@/modules/env';

export function getBullMQConnection(): ConnectionOptions {
  return {
    host: env.REDIS_HOST,
    port: env.REDIS_PORT,
    password: env.REDIS_PASSWORD || undefined,
    maxRetriesPerRequest: null,
  };
}

export function createQueue<T = unknown>(name: string): Queue<T> {
  return new Queue<T>(name, { connection: getBullMQConnection() });
}

getBullMQConnection() returns a plain ConnectionOptions object, not a Redis instance. BullMQ internally creates its own ioredis connections using those options. This separation is deliberate: BullMQ workers issue long-polling commands that would block a shared connection, making it unavailable for regular application reads. The createQueue factory wraps that correctly.

Once Redis is running for BullMQ, the incremental cost of using it for other short-lived state is near zero. The idempotency module is the clearest example:

// modules/redis_idempotency/redis_idempotency.service.ts
import redis from '@/modules/redis';

const TTL_SECONDS = 86400; // 24 hours

export class IdempotencyKey {
  private static key(idempotencyKey: string): string {
    return `idempotency:${idempotencyKey}`;
  }

  static async setPending(idempotencyKey: string): Promise<void> {
    const record: IdempotencyRecord = { status: 'pending' };
    await redis.set(IdempotencyKey.key(idempotencyKey), JSON.stringify(record), 'EX', TTL_SECONDS);
  }

  static async setCompleted(
    idempotencyKey: string,
    response: { body: unknown; statusCode: number },
  ): Promise<void> {
    const record: IdempotencyRecord = { status: 'completed', response };
    await redis.set(IdempotencyKey.key(idempotencyKey), JSON.stringify(record), 'EX', TTL_SECONDS);
  }
}

Twenty-four hours of TTL. No cleanup cron, no expired-record scan, no migration to add a processed_at column to a payment_requests table. The TTL is set to match a payment processor's typical retry window — long enough that a legitimate retry within 24 hours hits the idempotency guard; short enough that a key from yesterday does not accumulate indefinitely.

The WebAuthn module uses the same ioredis singleton for challenge storage, with a much shorter TTL:

// modules/user_security/user_security.passkey.constants.ts
export const PASSKEY_REG_CHALLENGE_KEY = (userId: string) => `passkey:reg:challenge:${userId}`;
export const PASSKEY_AUTH_CHALLENGE_KEY = (userId: string) => `passkey:auth:challenge:${userId}`;
export const PASSKEY_EMAIL_CHALLENGE_KEY = (email: string) => `passkey:email:challenge:${email}`;
export const PASSKEY_CHALLENGE_TTL_SECONDS = 300; // 5 minutes
export const PASSKEY_MAX_PER_USER = 10;

Five minutes. A user has that window to complete the authenticator ceremony. If the session times out or the browser closes mid-flow, the challenge evicts itself. No orphaned rows, no manual cleanup. The key naming convention (passkey:reg:challenge:{userId}) makes it immediately debuggable via redis-cli KEYS passkey:*.

Session invalidation follows the same pattern:

// modules/user_session/user_session.cache.service.ts
static async clearUserSessionCache(userId: string): Promise<void> {
  try {
    const pattern = `session:${userId}:*`;
    const keys = await redis.keys(pattern);
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  } catch (error) {
    Logger.error(`Failed to clear session cache for user ${userId}`);
  }
}

Pattern-based key deletion means all sessions for a user — across all devices, all tokens — can be invalidated with a single call regardless of how many are active. An equivalent Postgres implementation would require an indexed user_id column on a sessions table, a DELETE query, and a decision about whether to archive or hard-delete.

CTA

If your API already uses BullMQ or any job queue that requires ioredis, the Redis operational cost is already on the bill. The question is whether you are getting full value from it. Before adding a separate Postgres table for idempotency keys or a dedicated session storage service, look at whether the ioredis instance you already have can absorb those responsibilities cleanly.

The right test: if the data in question is short-lived, needs TTL-based expiry, and does not need to be queried relationally — it probably belongs in Redis. If it needs to be audited, reported on, or joined against other tables, it belongs in Postgres.

Trade-off

This approach concentrates dependency on a single Redis instance. If Redis goes down, BullMQ, idempotency enforcement, WebAuthn challenges, and session invalidation fail simultaneously. That single fault domain is actually simpler to reason about than four independent services — but it means your Redis availability floor sets the ceiling for all of those features.

There is no simple answer here. Redis Sentinel provides failover; Redis Cluster provides horizontal scale; managed Redis (ElastiCache, Upstash, Redis Cloud) provides SLA guarantees at the cost of vendor lock-in. The boilerplate ships without specifying which, because the right answer depends on the deployment target. What it does is name the dependency explicitly in redis.service.ts rather than burying it in BullMQ configuration — which makes the availability question visible when it matters.

Business impact

Idempotency enforcement and session invalidation are the two Redis-backed features with direct business consequences. A payment endpoint without idempotency is exposed to duplicate charges when a mobile client retries a timed-out request. A session store that survives service restarts means users are not logged out on every deployment. Neither feature makes the news when it works correctly; both generate support escalations when they don't.

The TTL-based challenge storage for WebAuthn has a subtler business implication: it removes a class of support requests entirely. When a passkey registration fails because the user took too long, the system returns a clean "challenge expired" error rather than an ambiguous database state that requires manual investigation.

What to do next

If you are evaluating whether to add Redis to an existing TypeScript/Node API: start with BullMQ's job queue requirements. If any of your current synchronous operations take more than 500ms or have non-trivial failure rates — email delivery, webhook dispatch, report generation — they belong in a queue. That queue requirement brings ioredis with it. Once ioredis is present, the incremental cost of idempotency and TTL-based state management is configuration, not infrastructure.

Check whether your current implementation has an idempotency guard on payment or order creation endpoints. If not, redis_idempotency.service.ts in this boilerplate is a direct copy candidate.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox