Back to Blog

AWS ElastiCache Redis: Caching Strategies, Session Storage, Rate Limiting, and Pub/Sub

Use AWS ElastiCache Redis for production caching, session storage, rate limiting, and pub/sub in Node.js applications. Covers cache-aside pattern, write-through, Redis Cluster vs Replication Group, Terraform setup, and ioredis TypeScript patterns.

Viprasol Tech Team
May 18, 2027
13 min read

ElastiCache Redis is the managed Redis option on AWS — handles replication, failover, backups, and patching. The question isn't whether to use it, but how: cache-aside vs write-through, single node vs cluster, how to structure cache keys, and what to do when the cache is cold.

This guide covers the production patterns for ElastiCache with TypeScript and ioredis.

Terraform: ElastiCache Replication Group

# terraform/elasticache.tf

# Subnet group for Redis nodes
resource "aws_elasticache_subnet_group" "redis" {
  name       = "${var.app_name}-redis"
  subnet_ids = var.private_subnet_ids  # Redis must be in private subnets

  tags = local.tags
}

# Parameter group for Redis 7 tuning
resource "aws_elasticache_parameter_group" "redis" {
  name   = "${var.app_name}-redis7"
  family = "redis7"

  parameter {
    name  = "maxmemory-policy"
    value = "allkeys-lru"        # Evict least-recently-used keys when memory full
  }

  parameter {
    name  = "activerehashing"
    value = "yes"               # Background rehashing for consistent latency
  }

  parameter {
    name  = "lazyfree-lazy-eviction"
    value = "yes"               # Async eviction (non-blocking)
  }
}

# Replication group: 1 primary + 1 replica (Multi-AZ)
resource "aws_elasticache_replication_group" "redis" {
  replication_group_id = "${var.app_name}-redis"
  description          = "${var.app_name} Redis cache"

  node_type            = "cache.t4g.medium"  # Graviton2: 20% cheaper
  num_cache_clusters   = 2                   # 1 primary + 1 replica
  parameter_group_name = aws_elasticache_parameter_group.redis.name
  subnet_group_name    = aws_elasticache_subnet_group.redis.name
  security_group_ids   = [aws_security_group.redis.id]

  # Redis 7.x
  engine_version       = "7.1"
  port                 = 6379

  # Encryption at rest and in transit
  at_rest_encryption_enabled  = true
  transit_encryption_enabled  = true
  auth_token                   = var.redis_auth_token  # Stored in AWS Secrets Manager

  # Automatic failover: promote replica if primary fails (~30s)
  automatic_failover_enabled = true
  multi_az_enabled           = true

  # Daily backups at 2am UTC, retained 7 days
  snapshot_retention_limit = 7
  snapshot_window          = "02:00-03:00"
  maintenance_window       = "sun:04:00-sun:05:00"

  tags = local.tags
}

# Security group: only allow access from app security group
resource "aws_security_group" "redis" {
  name   = "${var.app_name}-redis"
  vpc_id = var.vpc_id

  ingress {
    from_port       = 6379
    to_port         = 6379
    protocol        = "tcp"
    security_groups = [var.app_security_group_id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = local.tags
}

# Output: primary endpoint for app configuration
output "redis_endpoint" {
  value     = aws_elasticache_replication_group.redis.primary_endpoint_address
  sensitive = false
}

Redis Client Setup with ioredis

// lib/redis.ts
import Redis from "ioredis";

let redis: Redis | null = null;

export function getRedis(): Redis {
  if (!redis) {
    redis = new Redis({
      host:     process.env.REDIS_HOST!,       // ElastiCache primary endpoint
      port:     parseInt(process.env.REDIS_PORT ?? "6379"),
      password: process.env.REDIS_AUTH_TOKEN,  // TLS auth token
      tls:      process.env.NODE_ENV === "production" ? {} : undefined,

      // Connection pooling and retry
      maxRetriesPerRequest:     3,
      retryStrategy(times: number) {
        if (times > 5) return null; // Stop retrying after 5 attempts
        return Math.min(times * 100, 2000); // Exponential backoff: 100ms, 200ms, 400ms...
      },
      enableOfflineQueue: true,  // Queue commands while reconnecting

      // Key namespace (prevents collisions if sharing a Redis instance)
      keyPrefix: `${process.env.REDIS_PREFIX ?? "app"}:`,
    });

    redis.on("error",       (err) => console.error("[redis] Error:", err));
    redis.on("connect",     ()    => console.log("[redis] Connected"));
    redis.on("reconnecting",()    => console.log("[redis] Reconnecting..."));
  }
  return redis;
}

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

Pattern 1: Cache-Aside

Read from cache; on miss, read from DB and populate cache.

// lib/cache/workspace.ts
import { getRedis } from "@/lib/redis";
import { prisma } from "@/lib/prisma";

const WORKSPACE_TTL = 300; // 5 minutes

interface WorkspaceData {
  id:   string;
  name: string;
  plan: string;
  slug: string;
}

export async function getWorkspace(id: string): Promise<WorkspaceData | null> {
  const redis = getRedis();
  const key   = `workspace:${id}`;

  // 1. Try cache
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached) as WorkspaceData;
  }

  // 2. Cache miss: read from DB
  const workspace = await prisma.workspace.findUnique({
    where:  { id },
    select: { id: true, name: true, plan: true, slug: true },
  });

  if (!workspace) return null;

  // 3. Populate cache with TTL
  await redis.setex(key, WORKSPACE_TTL, JSON.stringify(workspace));

  return workspace;
}

// Invalidate cache when workspace is updated
export async function invalidateWorkspace(id: string): Promise<void> {
  const redis = getRedis();
  await redis.del(`workspace:${id}`);
}

Pattern 2: Cache Stampede Prevention with Lock

When the cache is cold and many requests arrive simultaneously, they all hit the DB at once. Use a Redis lock to let only one request regenerate:

// lib/cache/with-lock.ts
import { getRedis } from "@/lib/redis";

export async function cachedWithLock<T>(
  key:       string,
  ttl:       number,
  fetchFn:   () => Promise<T>,
  lockTtl =  10  // seconds to hold the lock
): Promise<T> {
  const redis   = getRedis();
  const lockKey = `lock:${key}`;

  // 1. Try cache first
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as T;

  // 2. Try to acquire lock (SET NX = only if not exists)
  const acquired = await redis.set(lockKey, "1", "EX", lockTtl, "NX");

  if (acquired === "OK") {
    try {
      // We hold the lock — fetch from DB
      const data = await fetchFn();
      await redis.setex(key, ttl, JSON.stringify(data));
      return data;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // Another request is fetching — poll until cache is populated
    for (let i = 0; i < 20; i++) {
      await new Promise((r) => setTimeout(r, 100));
      const result = await redis.get(key);
      if (result) return JSON.parse(result) as T;
    }
    // Fallback: fetch directly
    return fetchFn();
  }
}

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

Pattern 3: Rate Limiting with Lua

// lib/rate-limit/redis-limiter.ts
import { getRedis } from "@/lib/redis";

const RATE_LIMIT_SCRIPT = `
  local key      = KEYS[1]
  local limit    = tonumber(ARGV[1])
  local window   = tonumber(ARGV[2])
  local current  = redis.call('INCR', key)
  if current == 1 then
    redis.call('EXPIRE', key, window)
  end
  if current > limit then
    local ttl = redis.call('TTL', key)
    return {0, current, ttl}   -- {allowed=0, count, retry_after}
  end
  return {1, current, window}  -- {allowed=1, count, window}
`;

export interface RateLimitResult {
  allowed:      boolean;
  count:        number;
  retryAfter:   number;
}

export async function checkRateLimit(
  identifier: string,   // e.g. `ip:${ip}` or `user:${userId}`
  limit:      number,   // Max requests
  windowSec:  number    // Time window in seconds
): Promise<RateLimitResult> {
  const redis = getRedis();
  const key   = `ratelimit:${identifier}`;

  const result = await redis.eval(
    RATE_LIMIT_SCRIPT,
    1,
    key,
    limit.toString(),
    windowSec.toString()
  ) as [number, number, number];

  return {
    allowed:    result[0] === 1,
    count:      result[1],
    retryAfter: result[0] === 0 ? result[2] : 0,
  };
}

// Usage in Next.js route handler
export async function withRateLimit(
  req:        Request,
  identifier: string,
  limit =     60,
  windowSec = 60
): Promise<Response | null> {
  const result = await checkRateLimit(identifier, limit, windowSec);

  if (!result.allowed) {
    return new Response("Too Many Requests", {
      status: 429,
      headers: {
        "Retry-After":            result.retryAfter.toString(),
        "X-RateLimit-Limit":      limit.toString(),
        "X-RateLimit-Remaining":  "0",
        "X-RateLimit-Reset":      (Date.now() / 1000 + result.retryAfter).toString(),
      },
    });
  }

  return null; // Allowed
}

Pattern 4: Pub/Sub for Real-Time Events

// lib/pubsub/redis-pubsub.ts
import Redis from "ioredis";

// Pub/Sub requires a separate connection (subscriber cannot send regular commands)
let publisher:  Redis | null = null;
let subscriber: Redis | null = null;

function createRedisConnection(): Redis {
  return new Redis({
    host:     process.env.REDIS_HOST!,
    port:     parseInt(process.env.REDIS_PORT ?? "6379"),
    password: process.env.REDIS_AUTH_TOKEN,
    tls:      process.env.NODE_ENV === "production" ? {} : undefined,
  });
}

export function getPublisher(): Redis {
  if (!publisher) publisher = createRedisConnection();
  return publisher;
}

export function getSubscriber(): Redis {
  if (!subscriber) subscriber = createRedisConnection();
  return subscriber;
}

// Publish an event to a channel
export async function publish(channel: string, data: unknown): Promise<void> {
  await getPublisher().publish(channel, JSON.stringify(data));
}

// Subscribe to a channel (long-lived; set up once per process)
export function subscribe(
  channel:  string,
  handler:  (data: unknown) => void
): () => void {
  const sub = getSubscriber();

  sub.subscribe(channel, (err) => {
    if (err) console.error(`[pubsub] Subscribe error on ${channel}:`, err);
  });

  const messageHandler = (ch: string, message: string) => {
    if (ch === channel) {
      try {
        handler(JSON.parse(message));
      } catch (err) {
        console.error("[pubsub] Message parse error:", err);
      }
    }
  };

  sub.on("message", messageHandler);

  // Return unsubscribe function
  return () => {
    sub.unsubscribe(channel);
    sub.off("message", messageHandler);
  };
}

// Usage: broadcast workspace events to all connected users
// publish(`workspace:${workspaceId}:events`, { type: "member.added", userId });
// subscribe(`workspace:${workspaceId}:events`, handleEvent);

Cost Estimates (2026 Pricing)

Node TypevCPURAMPrice/Month
cache.t4g.micro20.5 GB~$12
cache.t4g.small21.4 GB~$24
cache.t4g.medium23.1 GB~$48
cache.r7g.large213 GB~$130
cache.r7g.xlarge426 GB~$260

Add ~50% for Multi-AZ replica. Savings Plans available for 1-year commitment (20% discount).

See Also


Working With Viprasol

ElastiCache configuration details compound — wrong eviction policy, missing TLS auth, no reconnect strategy — and they're invisible until traffic spikes. Our team provisions ElastiCache with Graviton2 nodes, allkeys-lru eviction, TLS + auth token from Secrets Manager, and Multi-AZ failover. Application patterns include cache-aside with stampede prevention (SET NX lock), atomic Lua rate limiting, and separate pub/sub connections.

What we deliver:

  • Terraform: replication group, subnet group, parameter group (allkeys-lru, lazyfree), security group, daily backups
  • getRedis() singleton with keyPrefix, retry strategy, TLS, and offline queue
  • cachedWithLock: SET NX lock to prevent cache stampede, poll-then-fallback
  • checkRateLimit: atomic Lua INCR+EXPIRE script, returns allowed/count/retryAfter
  • withRateLimit middleware with Retry-After/X-RateLimit-* headers
  • publish/subscribe helpers with dedicated pub/sub connection

Talk to our team about your Redis caching architecture →

Or explore our cloud infrastructure services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.