NodeJSRedis & Caching

Redis & Caching

Redis is an in-memory key–value store famous for its speed — reads and writes in microseconds. Because it holds data in RAM, it's rarely the primary database; instead it's the workhorse for caching, sessions, rate-limit counters, queues, and pub/sub. This page covers connecting from Node, the core data types, the cache-aside pattern, expiration/TTL, and the pitfalls (stale data, the thundering herd) that caching introduces.

What Redis is good for

Use case

Why Redis

Caching DB/API results

Sub-millisecond reads offload your database

Sessions

Fast, shared across instances (sessions)

Rate limiting

Atomic counters with expiry (rate limiting)

Queues / jobs

Lists + BullMQ for background work

Pub/Sub & real-time

Channels for fan-out messaging

Leaderboards

Sorted sets ranked by score

Connecting

Bash
npm install redis        # or: npm install ioredis

redis.js — one client at startup

JS
import { createClient } from 'redis'

export const redis = createClient({ url: process.env.REDIS_URL })
redis.on('error', (err) => console.error('Redis error', err))
await redis.connect()        // ONCE at startup; reuse everywhere
Core data types

JS
// String (the workhorse — often JSON):
await redis.set('user:1', JSON.stringify(user))
const raw = await redis.get('user:1')

// Numbers — atomic increment (counters, rate limits):
await redis.incr('views:home')

// Hash — field/value map (a record without serializing the whole thing):
await redis.hSet('user:1', { name: 'Ada', age: '36' })

// List — queue/stack:  Set — unique members:  Sorted set — ranked:
await redis.lPush('jobs', 'send-email')
await redis.sAdd('online', 'user:1')
await redis.zAdd('leaderboard', { score: 100, value: 'user:1' })
Values are bytes — serialize objects, and namespace your keys
Redis stores strings/bytes, so objects must be serialized (`JSON.stringify`) — or use a **hash** to store fields individually. Adopt a key-naming convention (`user:1`, `session:abc`, `cache:posts:page2`) so keys are discoverable and you can target groups. Numbers via `incr`/`decr` are **atomic**, which is exactly why Redis is ideal for counters and [rate limiting](/nodejs/rate-limiting).
The cache-aside pattern

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

  // 1. Try the cache first:
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)        // cache HIT

  // 2. Miss → read the source of truth:
  const user = await db.users.findById(id)
  if (!user) return null

  // 3. Populate the cache with a TTL, then return:
  await redis.set(key, JSON.stringify(user), { EX: 300 })   // expire in 5 min
  return user
}
Cache-aside: check cache → miss → load → backfill with a TTL
The most common caching strategy: the application reads the cache, and on a miss loads from the database and writes the result back with an expiry. Subsequent reads hit the cache until it expires. It's simple and resilient (if Redis is down, you still hit the DB). Always set a **TTL** so stale data eventually self-corrects.
Expiration (TTL)

JS
await redis.set('key', 'val', { EX: 60 })   // expire in 60 seconds
await redis.expire('key', 60)               // set/refresh TTL on an existing key
await redis.ttl('key')                      // seconds remaining (-1 = no expiry)
await redis.set('lock', '1', { NX: true, EX: 10 })  // set only if absent (locks)
Always set a TTL — unbounded keys leak memory until Redis evicts or OOMs
Redis lives in RAM, which is finite. Keys without an expiry accumulate forever; eventually Redis hits `maxmemory` and either starts **evicting** keys (per your eviction policy) or **rejecting writes** — both surprising in production. Give cache entries a TTL, configure a sensible `maxmemory-policy` (e.g. `allkeys-lru`), and never treat Redis as durable storage for data you can't regenerate.
Cache invalidation — the hard part

JS
async function updateUser(id, data) {
  const user = await db.users.update(id, data)
  await redis.del(`user:${id}`)        // invalidate so the next read reloads
  return user
}
Stale cache after a write — invalidate on every mutation
The classic caching bug: you update the database but the cache still serves the old value until its TTL expires, so users see stale data. On every write that changes cached data, **delete (or update) the affected keys**. This gets genuinely hard when one entity appears in many cached views — which is why "there are only two hard things in CS: cache invalidation and naming things." Keep what you cache, and its invalidation paths, deliberate and minimal.
The thundering herd / cache stampede
When a hot key expires, many requests hit the DB at once
If a popular key expires and 1,000 concurrent requests all miss simultaneously, they *all* hammer the database to rebuild it — a **cache stampede** that can topple the very DB caching was protecting. Mitigations: add small random jitter to TTLs so keys don't expire in lockstep; use a short lock (`SET key val NX`) so only one request rebuilds while others wait or serve stale; or refresh hot keys proactively before they expire. Plan for this on high-traffic keys.
What to cache — and what not to
  • Good: expensive query results, rarely-changing reference data, computed aggregates, rendered fragments, session data.

  • Be careful: per-user data (key by user; mind privacy), anything that must be perfectly fresh.

  • Don't: treat Redis as your only copy of data you can't recompute — it's a cache, not durable truth.

  • Cache the result, not the database connection; measure hit rate to confirm the cache is earning its keep.

Caching adds complexity — reach for it when a real bottleneck exists
A cache is not free: it introduces invalidation logic, a new failure mode, and potential staleness. Add it when profiling shows a genuine hot path (a slow query hit constantly), not preemptively. When you do, the payoff — offloading your database and shaving latency to microseconds — is substantial. Redis also underpins [sessions](/nodejs/sessions) and distributed [rate limiting](/nodejs/rate-limiting), so it often earns its place beyond caching.
Next
Manage database connections efficiently under load: [Connection Pooling](/nodejs/connection-pooling).