Skip to main content

⚑ Redis Fundamentals

Your database is the slowest part of most requests. Redis is the fast lane you put in front of it β€” an in-memory store that answers in microseconds. In this lesson you'll learn what Redis actually is, the handful of data types that make it so versatile, and how to drive it from Node.js with the modern node-redis client.

Week 10 · Thursday: Caching Strategies · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what Redis is β€” an in-memory key-value store β€” and why it is so fast
  • Install Redis and connect to it from Node.js using the node-redis v4 async client
  • Use the five core data types: strings, hashes, lists, sets, and sorted sets
  • Set expirations with TTL / EXPIRE and read them back
  • Write your first cache-aside lookup that checks Redis before the database
  • Pick the right data type for a given caching or app problem

Estimated Time: 65 minutes

Practice: Build a page-view counter and a mini leaderboard, then wrap a database call in a Redis cache.

In This Lesson

What Is Redis?

Redis (REmote DIctionary Server) is an open-source, in-memory key-value store. That's the whole idea in five words: it keeps its data in RAM and looks it up by key. Because it never has to reach for a spinning disk or even an SSD on the read path, a typical Redis command completes in well under a millisecond β€” often 50–100× faster than the same lookup against a relational database.

Think of your database as a warehouse: enormous, well-organized, authoritative, but a forklift ride away. Redis is the shelf right next to your desk. You keep the things you reach for constantly on that shelf, and only walk to the warehouse when the shelf doesn't have what you need. The warehouse is still the source of truth β€” Redis is just the fast copy.

graph LR App["Node.js App"] -->|"microseconds"| Redis["Redis
in-memory store"] App -->|"milliseconds"| DB[("Database
on disk")] Redis -.->|"warms from"| DB

Why "in-memory" matters

RAM is roughly a hundred thousand times faster to read than a disk. By keeping the hot data in memory, Redis turns a database query that might take 30 ms into a cache read that takes 0.3 ms. Multiply that across thousands of requests per second and it is the difference between a snappy app and a crawling one.

πŸ’‘ More than a cache

Caching is Redis's most common job in a web stack, and the focus of this lesson. But the same engine also works as a session store, a rate limiter, a message broker (pub/sub), and a real-time leaderboard. One tool, many jobs β€” which is exactly why it shows up in almost every production Node.js deployment.

Key characteristics

  • In-memory storage β€” data lives in RAM for extremely fast access.
  • Rich data structures β€” not just strings, but hashes, lists, sets, sorted sets, and more.
  • Optional persistence β€” Redis can snapshot to disk (RDB) or append every write to a log (AOF) so data survives a restart.
  • Single-threaded & atomic β€” commands run one at a time, so each individual operation is atomic with no locking needed.
  • Time-based expiration β€” every key can carry a TTL and delete itself automatically.

Installing & Connecting

Before we can store anything, we need a running Redis server and a client library to talk to it from Node.

1. Get a Redis server running

# macOS (Homebrew)
brew install redis
brew services start redis

# Ubuntu / Debian
sudo apt update && sudo apt install redis-server

# Anywhere with Docker (great for local dev)
docker run --name redis -p 6379:6379 -d redis:7

Confirm it's alive with the built-in CLI β€” it should answer PONG:

redis-cli ping
# PONG

2. Add the client to your Node project

The official client is node-redis. We use v4, which is fully async/await-based β€” no more callback pyramids.

npm install redis

3. Connect

import { createClient } from 'redis';

// createClient() defaults to redis://localhost:6379
const client = createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379'
});

// ALWAYS attach an error handler β€” a dropped connection
// should never crash your whole process.
client.on('error', (err) => console.error('Redis error:', err));

// v4 requires an explicit connect() before any command.
await client.connect();
console.log('Connected to Redis');

⚠️ The v4 gotcha: you must connect()

In the old v3 client, commands worked the moment you created the client. In v4 you must await client.connect() first, and every command returns a Promise. If you forget, you'll get ClientClosedError: The client is closed. Create and connect once at startup, then reuse that single client everywhere.

The Core Data Types

Redis is often called a "data structure server" because keys don't just hold plain text β€” they hold structures. Choosing the right one is the single biggest lever on how clean and efficient your code is. Here are the five you'll reach for daily.

Redis core data types and a common use case for each String counters, cached JSON Hash objects / user profiles List queues, recent items Set unique tags, membership Sorted Set leaderboards, ranking Every value is stored under a string key like user:1000
The five workhorse types. Match the structure to the shape of your data instead of stuffing everything into strings.

Strings β€” the simplest key-value

A string holds text, a number, or a blob of serialized JSON. It's your default for a plain cached value or a counter.

// Basic set / get
await client.set('greeting', 'Hello, Redis!');
const greeting = await client.get('greeting');   // 'Hello, Redis!'

// Numbers stored as strings can be incremented atomically β€”
// no read-modify-write race, even with many concurrent requests.
await client.set('page:views', '0');
await client.incr('page:views');                 // 1
await client.incrBy('page:views', 10);           // 11

// Cache a whole object by serializing to JSON:
await client.set('user:1000', JSON.stringify({ name: 'Ada', age: 36 }));
const user = JSON.parse(await client.get('user:1000'));

Hashes β€” objects without the JSON tax

A hash is a key that maps to a set of field/value pairs β€” perfect for an object when you want to read or update one field without deserializing the whole thing.

// Store a user profile as a hash
await client.hSet('user:1000', {
  username: 'ada',
  email: 'ada@example.com',
  visits: 10
});

const email = await client.hGet('user:1000', 'email');   // 'ada@example.com'
const all   = await client.hGetAll('user:1000');
// { username: 'ada', email: 'ada@example.com', visits: '10' }

// Increment just one field β€” no fetch-parse-save round trip
await client.hIncrBy('user:1000', 'visits', 1);          // visits -> 11

βœ… Hash vs. JSON string

A JSON string is fine when you always read the whole object. Reach for a hash when different requests touch different fields β€” updating visits shouldn't require rewriting the entire profile.

Lists β€” ordered, push and pop from either end

// Track the most recently viewed products (newest first)
await client.lPush('recent:products', 'product:789');
await client.lPush('recent:products', 'product:456');

// Keep only the last 5 by trimming after each push
await client.lTrim('recent:products', 0, 4);

const recent = await client.lRange('recent:products', 0, -1);
// ['product:456', 'product:789']

// Used as a simple queue: lPush to enqueue, rPop to dequeue.

Sets β€” unique, unordered membership

// Who is currently online? Duplicates are ignored automatically.
await client.sAdd('online:users', 'user:1000');
await client.sAdd('online:users', 'user:1001');
await client.sAdd('online:users', 'user:1000');   // no-op, already present

await client.sIsMember('online:users', 'user:1000');  // true
await client.sCard('online:users');                   // 2  (count)
await client.sMembers('online:users');                // ['user:1000','user:1001']

Sorted sets β€” sets with a score for ranking

Each member carries a numeric score, and Redis keeps them ordered by it. This is the leaderboard/priority-queue type.

await client.zAdd('leaderboard', [
  { score: 100, value: 'player:1' },
  { score: 75,  value: 'player:2' },
  { score: 150, value: 'player:3' }
]);

// Top 3, highest score first
const top = await client.zRangeWithScores('leaderboard', 0, 2, { REV: true });
// [{ value: 'player:3', score: 150 },
//  { value: 'player:1', score: 100 },
//  { value: 'player:2', score: 75 }]
TypeReach for it when…Signature commands
StringA single value, counter, or cached JSON blobSET, GET, INCR
HashAn object whose fields you update independentlyHSET, HGET, HGETALL
ListOrder matters β€” queues, feeds, recent itemsLPUSH, LRANGE, LTRIM
SetUniqueness and membership testsSADD, SISMEMBER, SMEMBERS
Sorted SetRanking by a numeric scoreZADD, ZRANGE, ZINCRBY

Expiration & TTL

A cache that never forgets isn't a cache β€” it's a slowly poisoning copy of stale data. The fix is TTL (Time To Live): tell Redis to auto-delete a key after N seconds, and stale entries clean themselves up.

// Set a value AND its expiry in one command with the EX option (seconds):
await client.set('session:abc', sessionJson, { EX: 1800 }); // gone in 30 min

// PX for millisecond precision:
await client.set('rate:user123', '1', { PX: 60000 });       // gone in 1 min

// Add or change expiry on an existing key:
await client.expire('daily:stats', 3600);                   // 1 hour from now

// How long is left? TTL returns seconds remaining.
await client.ttl('session:abc');   // e.g. 1785
// Special return values:
//   -2  -> the key does not exist
//   -1  -> the key exists but has NO expiry (lives forever)

// Remove an expiry, making a key permanent again:
await client.persist('daily:stats');

πŸ“– EXPIRE vs. EXPIREAT

EXPIRE key 3600 means "one hour from now." EXPIREAT key <unixTimestamp> means "delete at this exact wall-clock moment" β€” handy for things like "expire at midnight" without recomputing the offset yourself.

TTL is the single most important safety net in caching. Even when you actively delete keys on data changes (next two lessons), a TTL guarantees that any entry you forgot to invalidate can only be wrong for a bounded amount of time.

Your First Cache Lookup

Let's tie it together. The most common caching shape is cache-aside: check Redis first, and only touch the database on a miss β€” then store the result so the next request is a hit. We'll go deep on this pattern next lesson; here's the shape so the data types feel concrete.

sequenceDiagram participant App as Application participant Cache as Redis participant DB as Database App->>Cache: Look up user by key alt Cache hit Cache->>App: Return cached user else Cache miss Cache->>App: Report nothing stored App->>DB: Query the user DB->>App: Return the user row App->>Cache: Store user with a TTL end
async function getUserById(userId) {
  const cacheKey = `user:${userId}`;

  // 1. Try the cache first
  const cached = await client.get(cacheKey);
  if (cached) {
    console.log('Cache HIT', userId);
    return JSON.parse(cached);
  }

  // 2. Miss β€” go to the source of truth
  console.log('Cache MISS', userId);
  const user = await db.users.findOne({ _id: userId });

  // 3. Populate the cache for next time (1-hour TTL as a safety net)
  if (user) {
    await client.set(cacheKey, JSON.stringify(user), { EX: 3600 });
  }
  return user;
}

Output over three calls

getUserById('1000')  // Cache MISS 1000   (hits the DB, then caches)
getUserById('1000')  // Cache HIT 1000    (served from Redis, ~0.3ms)
getUserById('1000')  // Cache HIT 1000    (still fast until the TTL expires)

That's the entire value proposition: the first request pays the database cost, and every request after it β€” until the TTL lapses β€” is answered from memory.

Practice & Quiz

πŸ‹οΈ Exercise 1: A page-view counter

Goal: Write recordView(pageId) that atomically increments a per-page counter and returns the new total. Counters are a textbook Redis string use case.

async function recordView(pageId) {
    // TODO: increment `views:${pageId}` and return the new count
}
console.log(await recordView('home'));  // 1
console.log(await recordView('home'));  // 2
πŸ’‘ Hint

client.incr(key) creates the key at 0 if it doesn't exist, increments it, and returns the new value β€” all atomically. No get-then-set needed.

βœ… Solution
async function recordView(pageId) {
    return client.incr(`views:${pageId}`);
}

Because INCR is atomic, two requests arriving at the same millisecond can never both read "5" and both write "6" β€” Redis serializes them.

πŸ‹οΈ Exercise 2: A mini leaderboard

Goal: Use a sorted set to record scores and fetch the top 3 players, highest first.

βœ… Solution
async function addScore(player, score) {
    await client.zAdd('leaderboard', [{ score, value: player }]);
}

async function topThree() {
    return client.zRangeWithScores('leaderboard', 0, 2, { REV: true });
}

await addScore('ada', 120);
await addScore('grace', 200);
await addScore('linus', 90);
console.log(await topThree());
// [{ value: 'grace', score: 200 },
//  { value: 'ada', score: 120 },
//  { value: 'linus', score: 90 }]

🎯 Quick Quiz

Question 1: What best describes Redis?

Question 2: Which data type would you use for a leaderboard ranked by score?

Question 3: await client.ttl('some:key') returns -1. What does that mean?

Best Practices & Pitfalls

βœ… Do

  • Create one client at startup, connect() it, and reuse it β€” don't open a client per request.
  • Use a consistent, colon-namespaced key convention: user:1000, product:42:reviews.
  • Set a TTL on cache entries as a safety net, even when you also invalidate them explicitly.
  • Match the structure to the data β€” a hash for objects, a sorted set for rankings.
  • Always attach an 'error' handler so a dropped connection can't crash the process.

❌ Don't

  • Run KEYS * in production β€” it scans every key and blocks the single-threaded server. Use SCAN instead.
  • Store giant blobs (multi-megabyte values); large keys hurt latency for everyone.
  • Cache data that must be perfectly consistent (like an account balance mid-transaction) without a clear invalidation plan.
  • Forget to await β€” every v4 command is a Promise.

⚠️ KEYS is a footgun

// ❌ Blocks Redis while it walks the entire keyspace
const keys = await client.keys('user:*');

// βœ… SCAN iterates in small, non-blocking chunks
for await (const key of client.scanIterator({ MATCH: 'user:*', COUNT: 100 })) {
    console.log(key);
}

Redis runs your commands one at a time on a single thread. A slow KEYS on a big database freezes every other client until it finishes.

Summary

πŸŽ‰ Key Takeaways

  • Redis is an in-memory key-value store β€” microsecond reads because the data lives in RAM.
  • It's a data-structure server: strings, hashes, lists, sets, and sorted sets each fit a different shape of data.
  • The node-redis v4 client is async β€” createClient(), await connect(), then await every command.
  • TTL / EXPIRE auto-delete keys and are your primary defense against stale data.
  • The classic caching move is cache-aside: check Redis, fall back to the DB on a miss, then store the result.

πŸ“š Additional Resources

πŸš€ What's Next?

You can now store and expire data in Redis. Next we turn that into a discipline: Caching Patterns & Best Practices β€” cache-aside, read-through, write-through, write-behind, and how to survive a thundering herd.

⚑ Fast lane installed!

You've got a working cache and the vocabulary to reason about it. Now let's use it wisely.