Caching with Redis (ioredis)

Reviewed & published by Brayan K

Caching stores the result of slow work — like a database query — in fast in-memory storage such as Redis, so repeated requests are served instantly instead of redoing the work every time.

Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll learn why caching matters, how Redis GET/SET with a TTL work, the cache-aside pattern, how to use the ioredis client, and how to invalidate stale entries on writes.

What You'll Learn in This Lesson

1️⃣ GET, SET & TTL

A cache is a key/value store. You SET a key to a value and later GET it. The crucial extra is a TTL (time to live): with Redis you write SET key value EX 60 to make the entry vanish after 60 seconds. The model below behaves the same way — a fresh key is a hit, a missing or expired key is a miss.

// A cache stores the result of slow work so the NEXT request is instant.
// This is a Map-based TTL cache modelling Redis GET/SET with EX (expiry).

const store = new Map();   // key -> { value, expiresAt }

function set(key, value, ttlMs) {
  store.set(key, { value, expiresAt: Date.now() + ttlMs });
}

function get(key) {
  const hit = store.get(key);
  if (!hit) return null;                    // MISS — not cached
  if (Date.now() > hit.expiresAt) {         // expired
    store.delete(key);
    return null;                            // MISS — TTL passed
  }
  return hit.value;                         // HIT
}

set('user:1', { name: 'Ada' }, 1000);        // cache for 1 second (EX 1)
console.log('Right away:', get('user:1'));   // HIT
console.log('Missing key:', get('user:99')); // MISS -> null

2️⃣ The Cache-Aside Pattern

The everyday strategy is cache-aside: check the cache first; on a miss, do the slow work, store the result, and return it; on a hit, return the cached value immediately. The first request "warms" the cache and pays full price; every request after is fast. The runnable example simulates a 50 ms database call so you can measure the difference for real.

// Cache-aside: check cache first; on a miss, do the slow work, then store it.
const cache = new Map();

function slowQuery(id) {
  // pretend this is a 50ms database call
  const start = Date.now();
  while (Date.now() - start < 50) { /* busy wait to simulate latency */ }
  return { id, name: 'Ada' };
}

function getUser(id) {
  if (cache.has(id)) {
    return { source: 'cache', data: cache.get(id) };
  }
  const data = slowQuery(id);        // MISS: pay the cost once
  cache.set(id, data);               // store for next time
  return { source: 'db', data };
}

// A stopwatch never reads the same twice, so report the SHAPE of the
// answer rather than the exact millisecond — "did this take real time, or
// none?" is the question a cache is asked, and it is the same on every
// machine. Printing 50 here and 51 on the next run would only look like a
// broken example.
const slow = (ms) => (ms >= 25 ? "slow (a real lookup)" : "instant (from memory)");

let t = Date.now();
const first = getUser(1);
console.log(`1st call -> ${first.source}, ${slow(Date.now() - t)}`);

t = Date.now();
const second = getUser(1);
console.log(`2nd call -> ${second.source}, ${slow(Date.now() - t)}`);

3️⃣ The Real Thing: ioredis & Invalidation

With ioredis, the same pattern uses real commands: await redis.get(key), then on a miss await redis.set(key, value, 'EX', 60). Because Redis stores strings, you JSON.stringify on the way in and JSON.parse on the way out. Crucially, when data changes, delete its key with redis.del(key) so the next read repopulates fresh — this is invalidation.

// The real thing with ioredis.
// Install once:  npm install ioredis   (and run a Redis server)
const Redis = require('ioredis');
const redis = new Redis();   // defaults to localhost:6379

async function getUser(id) {
  const key = `user:${id}`;

  // 1) Try the cache first (GET).
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);   // HIT — Redis stores strings

  // 2) MISS: hit the slow source.
  const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);

  // 3) Store it with a 60-second TTL ('EX', 60) so it auto-expires.
  await redis.set(key, JSON.stringify(user), 'EX', 60);
  return user;
}

// Invalidate on write so stale data never lingers:
async function updateUser(id, changes) {
  await db.update(id, changes);
  await redis.del(`user:${id}`);          // DEL — next read repopulates
}

Your turn. The memoizer below works once you fill in the single blank marked ___. Follow the 👉 hint, then run it.

// 🎯 YOUR TURN — replace each ___ then run it.
const cache = new Map();

function getOrCompute(key, compute) {
  if (cache.has(key)) return cache.get(key);   // HIT
  const value = compute();                      // MISS -> compute
  cache.___(key, value);                        // 👉 which Map method stores? (set)
  return value;
}

let calls = 0;
const square = (n) => { calls++; return n * n; };

console.log(getOrCompute('5', () => square(5)));  // computes -> 25
console.log(getOrCompute('5', () => square(5)));  // cached  -> 25
console.log(`square() ran ${calls} time(s)`);

// ✅ Expected output:
//    25
//    25
//    square() ran 1 time(s)

Mini-Challenge: Cache-Aside with Invalidation

No blanks — just a brief and an outline. Implement read-through caching plus invalidation: a write must delete the cached key so the next read sees fresh data. Build it, run it, and compare with the example output.

// 🎯 MINI-CHALLENGE: cache-aside WITH invalidation
// 1. Keep a cache (Map) and a db (Map with user 1 = { id:1, name:'Ada' }).
// 2. read(id): return from cache if present (mark from:'cache'); else read db,
//    store it, and return from:'db'.
// 3. update(id, changes): apply to db AND delete the cache key (invalidate).
// 4. read(1) twice (db then cache), update the name, then read(1) again
//    (db again, fresh value).
//
// ✅ Example output:
//    { from: 'db', user: { id: 1, name: 'Ada' } }
//    { from: 'cache', user: { id: 1, name: 'Ada' } }
//    { from: 'db', user: { id: 1, name: 'Ada L.' } }

const cache = new Map();
const db = new Map([[1, { id: 1, name: 'Ada' }]]);
// your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Redis Caching (ioredis)

TaskCommand
Read a keyawait redis.get(key)
Write with TTLredis.set(key, v, 'EX', 60)
Invalidateawait redis.del(key)
Store an objectJSON.stringify(value)
Read an objectJSON.parse(cached)
Patterncache-aside (lazy load)
Connectnew Redis()

🎉 Lesson Complete!

Practice quiz

What is the main purpose of caching?

  • To encrypt data
  • To serve repeated reads from fast memory instead of redoing slow work
  • To back up the database
  • To compress files

Answer: To serve repeated reads from fast memory instead of redoing slow work. A cache stores results of expensive work so later identical requests are served instantly.

In the cache-aside pattern, what happens on a cache miss?

  • The request fails
  • Nothing is returned
  • Fetch from the source, store in cache, then return it
  • The cache is cleared

Answer: Fetch from the source, store in cache, then return it. On a miss you load from the real source, store the result with a TTL, and return it.

What does TTL (the Redis EX option) control?

  • How long a cached entry stays valid before auto-deletion
  • The maximum value size
  • The number of clients
  • The connection port

Answer: How long a cached entry stays valid before auto-deletion. TTL is the time to live; after it elapses Redis automatically removes the key.

With ioredis, how do you set a key with a 60-second expiry?

  • redis.set(key, v)
  • redis.expire(key)
  • redis.setex(60)
  • redis.set(key, v, 'EX', 60)

Answer: redis.set(key, v, 'EX', 60). Passing 'EX', 60 to set stores the value and gives it a 60-second TTL.

Because Redis stores strings, what must you do with objects?

  • JSON.stringify before SET and JSON.parse after GET
  • Nothing, Redis stores objects
  • Use redis.object()
  • Convert them to buffers manually

Answer: JSON.stringify before SET and JSON.parse after GET. Redis values are strings, so serialize with JSON.stringify and deserialize with JSON.parse.

Which ioredis command invalidates a cached key on a write?

  • redis.get
  • redis.del
  • redis.set
  • redis.ttl

Answer: redis.del. redis.del removes the key so the next read repopulates fresh data.

Why is Redis preferred over an in-process Map for caching across servers?

  • It is written in JavaScript
  • It is slower but safer
  • It is shared across all processes and servers
  • It never expires data

Answer: It is shared across all processes and servers. An in-process Map is per-instance, while Redis is a shared store all instances can use.

What is the cost of the very first read of each key in cache-aside?

  • It is always a hit
  • It is a miss (a cold cache)
  • It throws an error
  • It returns null forever

Answer: It is a miss (a cold cache). The first read for a key misses and pays full cost; subsequent reads are hits.

What error usually means no Redis server is running?

  • EACCES
  • ENOENT
  • ETIMEDOUT
  • ECONNREFUSED 127.0.0.1:6379

Answer: ECONNREFUSED 127.0.0.1:6379. ECONNREFUSED on the Redis port means nothing is listening; start a Redis server first.

Why should you always set a TTL on cache entries?

  • It encrypts the entry
  • It bounds memory and limits how long stale data can survive
  • It makes reads slower
  • It is required to connect

Answer: It bounds memory and limits how long stale data can survive. A TTL caps memory use and acts as a backstop if invalidation is ever missed.

Continue this course

Frequently asked questions

Why cache at all, and why use Redis specifically?

Caching stores the result of expensive work — a database query, an external API call, a heavy computation — so repeated requests for the same thing are served instantly from memory instead of redoing the work. Redis is the de facto choice because it's an in-memory data store (microsecond reads), it's shared across all your Node processes and servers (unlike an in-process Map, which each instance has its own copy of), and it natively supports TTLs so entries expire on their own. That combination — fast, shared, and self-expiring — is exactly what a cache needs, which is why Redis sits in front of so many databases.

What is the cache-aside pattern?

Cache-aside (also called lazy loading) is the most common caching strategy. On a read, you first ask the cache; if it's a hit, you return the cached value. If it's a miss, you fetch from the real source (the database), store the result in the cache with a TTL, and return it — so the next identical read is a hit. The application, not the cache, is responsible for loading data, which keeps the cache simple and only ever caches data that's actually requested. The trade-off is that the very first read of each key is a miss (a 'cold' cache), and you must invalidate on writes so the cache doesn't serve stale data.

What does TTL (the EX option) do, and how do I pick one?

TTL (time to live) is how long a cached entry stays valid before Redis automatically deletes it. With ioredis you set it as redis.set(key, value, 'EX', 60) for 60 seconds. The TTL is your safety net against stale data: even if you forget to invalidate on a write, the entry self-destructs after the TTL. Choosing a value is a trade-off — a short TTL keeps data fresh but causes more misses (more database load); a long TTL maximizes hit rate but risks serving outdated data for longer. Pick based on how often the data changes and how stale you can tolerate it being: seconds for fast-moving data, minutes or hours for things like reference data.

What's the difference between ioredis and node-redis?

Both are popular Node clients for Redis and cover the same core commands (get, set, del, expire, and so on). ioredis has long been favored for its built-in support for Redis Cluster and Sentinel, a robust Promise-based API, and offline command queuing. node-redis (the official client) modernized significantly in v4 with a clean Promise API and is maintained by Redis itself. For most apps either works well; ioredis is often chosen when you need advanced cluster/sentinel features, while node-redis appeals to teams wanting the official client. The caching patterns in this lesson — GET, SET with EX, and DEL to invalidate — are identical in both.