iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Bootcamp

A 60-minute bootcamp that ships a working Redis-backed feature: a rate limiter, a session store, a cache, and a Streams-based job queue. Run it against a local Redis container.

A 60-minute Redis bootcamp

EXAMPLE
# ===== Objectives =====
# 1. Spin up local Redis with persistence
# 2. Build a rate limiter (fixed window + sliding window)
# 3. Add a session store
# 4. Add a cache with TTL + dogpile protection
# 5. Build a job queue with Streams + consumer group

# ===== 0-5 min: stand up Redis =====
docker run -d --name redis -p 6379:6379 -v redis-data:/data \
  redis:7-alpine redis-server --appendonly yes

# Confirm
redis-cli ping

# Install ioredis in the demo app
npm i ioredis

# ===== 5-15 min: fixed-window rate limiter =====
// rate-limit.ts
import Redis from 'ioredis';
const r = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');

export async function allow(ip: string): Promise<boolean> {
  const window = Math.floor(Date.now() / 60_000);
  const key = \`rl:${ip}:${window}\`;
  const count = await r.incr(key);
  if (count === 1) await r.expire(key, 60);
  return count <= 60;
}

# Test: a loop hammering the function should reject after 60 calls per minute.

# ===== 15-25 min: sliding-window rate limiter =====
// More accurate; costs a bit more.
export async function allowSliding(userId: string, max = 30, windowMs = 60_000): Promise<boolean> {
  const now = Date.now();
  const key = \`slide:${userId}\`;
  const pipe = r.multi();
  pipe.zadd(key, now, \`${now}-${Math.random()}\`);
  pipe.zremrangebyscore(key, 0, now - windowMs);
  pipe.zcard(key);
  pipe.pexpire(key, windowMs);
  const res: any = await pipe.exec();
  return (res[2][1] as number) <= max;
}

# ===== 25-35 min: session store =====
export async function loadSession(sid: string) {
  const data = await r.hgetall(\`sess:${sid}\`);
  if (!Object.keys(data).length) return null;
  await r.expire(\`sess:${sid}\`, 60 * 60 * 24 * 14);   // sliding TTL
  return data;
}

export async function saveSession(sid: string, patch: Record<string, string>) {
  await r.hset(\`sess:${sid}\`, patch);
  await r.expire(\`sess:${sid}\`, 60 * 60 * 24 * 14);
}

export async function killSession(sid: string) {
  await r.del(\`sess:${sid}\`);
}

# ===== 35-45 min: cache with dogpile protection =====
// One slow consumer holds a lock; everyone else waits on the cache key.
export async function cached<T>(key: string, ttl: number, fetcher: () => Promise<T>): Promise<T> {
  const hit = await r.get(key);
  if (hit) return JSON.parse(hit);

  const lockKey = \`lock:${key}\`;
  const ok = await r.set(lockKey, '1', 'PX', 5000, 'NX');
  if (!ok) {
    // wait briefly for the leader to fill
    await new Promise((r) => setTimeout(r, 50));
    const retry = await r.get(key);
    if (retry) return JSON.parse(retry);
    return fetcher();         // still empty; fall through
  }
  try {
    const fresh = await fetcher();
    await r.set(key, JSON.stringify(fresh), 'EX', ttl);
    return fresh;
  } finally {
    await r.del(lockKey);
  }
}

# Usage:
# const data = await cached('home:hero', 300, () => loadHeroFromDb());

# ===== 45-55 min: job queue via Streams =====
// Producer: enqueue a job
await r.xadd('jobs', '*', 'type', 'send_email', 'payload', JSON.stringify({ to: 'a@b' }));

// Once: create the consumer group
try { await r.xgroup('CREATE', 'jobs', 'workers', '$', 'MKSTREAM'); } catch {}

// Worker loop
async function workerLoop(consumer: string) {
  while (true) {
    const res: any = await r.xreadgroup(
      'GROUP', 'workers', consumer,
      'COUNT', 16, 'BLOCK', 5000,
      'STREAMS', 'jobs', '>',
    );
    if (!res) continue;
    for (const [, entries] of res) {
      for (const [id, fields] of entries) {
        try {
          const job = Object.fromEntries(chunk(fields, 2));
          await handleJob(job);
          await r.xack('jobs', 'workers', id);
        } catch (e) {
          // leave pending; XCLAIM will redeliver after timeout
        }
      }
    }
  }
}

function chunk<T>(arr: T[], size: number): T[][] {
  const out: T[][] = [];
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
  return out;
}

async function handleJob(job: Record<string, string>) {
  if (job.type === 'send_email') console.log('email to', JSON.parse(job.payload).to);
}

# ===== 55-60 min: monitor + harden =====
# In one terminal:
redis-cli MONITOR        # WATCH dev only — serialises traffic
# Slow log
redis-cli CONFIG SET slowlog-log-slower-than 10000
redis-cli SLOWLOG GET 5

# Production hardening (see redis/security lesson):
# - require TLS in transit
# - ACLs per app
# - rename-command CONFIG '' / FLUSHALL '' in dangerous environments
# - maxmemory + maxmemory-policy allkeys-lru

# ===== Post-bootcamp checklist =====
# - rate limiter tested end-to-end
# - sessions sliding-TTL verified
# - cache lock prevents dogpile under load (use Artillery / k6 to confirm)
# - Streams consumer group survives a worker crash (start a new consumer, see pending entries)
# - Monitoring: hit rate, evicted_keys, replication_lag

# ===== Pitfalls =====
# - PUBSUB instead of Streams for jobs (no replay)
# - 'KEYS *' to enumerate (O(N) blocking; use SCAN)
# - Storing 1MB blobs in a string (Redis is in-memory; budget bites)
# - No EXPIRE on user-facing keys -> memory grows unbounded
# - Using one client for many tenants without auth scoping

Why it matters

Streams + consumer groups + a TTL on every key are the trio that turns Redis from "fast cache" into "production-grade coordination layer". Build the bootcamp recipe once and the pattern fits half of the "we need a queue / cache / rate limiter" stories you will hit in the next year.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
# 30-day Redis bootcamp in the lesson body.
Try it Yourself »

Discussion

Loading…