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.
Discussion
Loading…