One Data Structure Server, a Dozen System-Design Superpowers
Redis for Node Engineers
A Redis deep dive through ioredis — core data structures, pipelines and transactions, pub/sub, why single-threaded is a feature, and the patterns it unlocks: cache, session store, rate limiter, leaderboard.
What you'll learn
- Use Redis's core data structures from Node with ioredis
- Apply pipelines, transactions, and pub/sub correctly
- Map Redis patterns to caching, sessions, rate limiting, and leaderboards
Redis has shown up in every lesson of this section — as the shared session store, the cache, the pub/sub bus, the lock. It’s the Swiss Army knife of the scaling toolkit, and for Node engineers it’s the single most useful piece of infrastructure to know cold. This lesson goes deep on what Redis actually is and how you wield it from Node with ioredis, the de-facto Node client.
Redis is best understood not as a cache but as an in-memory data structure server: it keeps your data in RAM (so it’s microsecond-fast) and exposes it not as opaque blobs but as real data structures — strings, hashes, lists, sets, sorted sets — each with rich, atomic operations. That structure is what makes one tool serve so many roles.
Why single-threaded is a feature, not a bug
Redis executes commands on one thread, one at a time. That sounds like a
limitation until you realize what it buys you: every command is atomic. There
are no locks, no race conditions, no half-applied operations — while INCR runs,
nothing else can interleave with it. A counter incremented by a thousand
concurrent clients lands at exactly the right number, for free.
And it’s fast because it’s simple: no lock contention, no context-switching between threads, no coordination overhead. Everything is in RAM and the single thread just rips through commands. This is the same lesson the Node event loop teaches — a single thread doing non-blocking work can outperform a thread pool fighting over locks. Redis and Node are spiritual siblings.
The core data structures
Each Redis type maps to a system-design job. Here’s the working vocabulary, with ioredis calls.
| Type | Think of it as | Classic use |
|---|---|---|
| String | A value (text, number, JSON, bytes) | Cache entry, counter, flag |
| Hash | An object / Map of fields | Session, user record |
| List | A linked list / deque | Queue, recent-items feed |
| Set | A unique, unordered collection | Tags, “who liked this”, dedup |
| Sorted Set | A set where each member has a score | Leaderboard, rate limiter, priority queue |
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// String — values & atomic counters
await redis.set('page:home', html, 'EX', 60); // cache w/ TTL
await redis.incr('views:home'); // atomic ++
// Hash — store an object without serializing the whole thing
await redis.hset('user:42', { name: 'Ada', plan: 'pro' });
await redis.hget('user:42', 'plan'); // 'pro'
// List — a queue (push one end, pop the other)
await redis.lpush('jobs', JSON.stringify({ task: 'email' }));
const job = await redis.rpop('jobs'); // FIFO
// Set — uniqueness & membership
await redis.sadd('post:9:likes', 'u1', 'u2', 'u1'); // u1 counted once
await redis.scard('post:9:likes'); // 2 unique likers
// Sorted Set — members ranked by score
await redis.zadd('leaderboard', 5000, 'Ada', 4200, 'Lin');
await redis.zrevrange('leaderboard', 0, 2, 'WITHSCORES'); // top 3 Pipelines and transactions
Each Redis command is a network round trip (~0.5ms in-datacenter). Fire a hundred commands one at a time and you pay a hundred round trips serially. Pipelining batches them into a single round trip — the commands still execute in order, you just send and receive once.
A transaction (MULTI/EXEC) goes further: it groups commands so they run
atomically as a unit, with nothing else interleaved between them. In ioredis,
.multi() both pipelines and wraps the batch in MULTI/EXEC.
// PIPELINE — one round trip instead of three. Faster, not atomic.
const results = await redis.pipeline()
.set('a', 1)
.incr('a')
.get('a')
.exec(); // [[null,'OK'], [null,2], [null,'2']]
// MULTI/EXEC — atomic: these run as an indivisible unit.
// Useful when several writes must all land together or not at all.
await redis.multi()
.incr('user:42:posts')
.sadd('active:today', '42')
.expire('active:today', 86400)
.exec(); For conditional atomic logic (“increment only if under the limit”), you’d reach
for a Lua script via redis.eval(...), which runs server-side as one atomic
operation — the standard trick for correct rate limiters.
Pub/Sub
Redis can also be a message bus: clients SUBSCRIBE to channels, others
PUBLISH to them, and every subscriber gets every message. This is fire-and-forget
(no persistence, no delivery guarantee) — it’s how the socket.io Redis adapter
from the statelessness lesson lets every app instance broadcast to every connected
client.
// A subscriber connection must be dedicated — it can't run normal commands.
const sub = new Redis(process.env.REDIS_URL);
const pub = new Redis(process.env.REDIS_URL);
sub.subscribe('chat:room1');
sub.on('message', (channel, message) => {
console.log(`[${channel}] ${message}`); // every instance receives this
});
await pub.publish('chat:room1', JSON.stringify({ from: 'Ada', text: 'hi' })); Four patterns you’ll reach for constantly
The data structures above compose into a handful of go-to system-design patterns:
1. Cache. Strings with TTLs — the cache-aside pattern from two lessons back.
GET/SET key value EX seconds.
2. Session store. A hash per session (or a serialized string), reachable by
every stateless instance — the connect-redis setup from the statelessness lesson.
3. Rate limiter. A counter per user per window, incremented atomically and expired automatically — the single-threaded atomicity makes this correct under any concurrency:
// Allow `limit` requests per `windowSec` per user. Atomic and lock-free.
async function allow(userId, limit = 100, windowSec = 60) {
const key = `rl:${userId}:${Math.floor(Date.now() / 1000 / windowSec)}`;
const count = await redis.incr(key); // atomic ++
if (count === 1) await redis.expire(key, windowSec); // set TTL on first hit
return count <= limit; // false → rate limited
} 4. Leaderboard. A sorted set — members ranked by score, with O(log N) updates and instant top-N and rank queries. This is the canonical Redis showcase:
// Record a score (ZADD updates in place if the member exists).
await redis.zadd('game:scores', 9001, 'player:7');
// Top 10, highest first, with scores.
const top10 = await redis.zrevrange('game:scores', 0, 9, 'WITHSCORES');
// A specific player's rank (0-based) — O(log N), no full scan.
const rank = await redis.zrevrank('game:scores', 'player:7');
console.log(`You are #${rank + 1}`); Each of these is a few lines because Redis ships the right data structure and the single-threaded guarantee that makes concurrent access correct. That combination — the right structures, atomic by construction, microsecond-fast — is why Redis is the backbone of nearly every scaling pattern in this track.
That closes out the Scaling Building Blocks. You can now spread load across many stateless servers, front them with proxies and CDNs, and cache aggressively with Redis. Next we go down a layer to where the durable truth lives — choosing between SQL and NoSQL for your data.