Skip to content

Redis Deep Dive

Redis is the swiss army knife of backend infrastructure It's not your primary database - it's the in-memory layer that makes everything faster , from caching to rate limiting to real-time messaging

Redis Data Structures

Every data structure is designed for a specific use case

const Redis = require('ioredis')
const redis = new Redis()

// STRING - simple key-value , caching , counters
await redis.set('user:123:name', 'Mahmoud')
await redis.set('visitor:count', 0)
await redis.incr('visitor:count')         // atomic increment
await redis.incrby('visitor:count', 5)    // atomic increment by
const count = await redis.get('visitor:count')

// HASH - objects with multiple fields
await redis.hset('user:123', {
  name: 'Mahmoud',
  email: 'mahmoud@example.com',
  role: 'admin'
})
const name = await redis.hget('user:123', 'name')
const allFields = await redis.hgetall('user:123')
await redis.hincrby('user:123', 'loginCount', 1)

// LIST - queues , recent items
await redis.lpush('notifications:123', 'msg1', 'msg2')
await redis.rpop('notifications:123')     // pop from right
const length = await redis.llen('notifications:123')
const range = await redis.lrange('notifications:123', 0, -1)  // all items

// SET - unique members , tags , presence
await redis.sadd('tags:security', 'sql-injection', 'xss', 'csrf')
const isMember = await redis.sismember('tags:security', 'xss')
const members = await redis.smembers('tags:security')
const count = await redis.scard('tags:security')
const intersect = await redis.sinter('tags:security', 'tags:node')

// SORTED SET - leaderboard , scores , timestamps
await redis.zadd('scores', 100, 'user:1', 85, 'user:2', 95, 'user:3')
const top3 = await redis.zrevrange('scores', 0, 2, 'WITHSCORES')
const rank = await redis.zrevrank('scores', 'user:2')  // 2nd place
await redis.zincrby('scores', 10, 'user:3')            // add 10 points

Pick the right data structure or you'll write application logic that should happen in Redis A sorted set replaces 50 lines of ranking logic with 1 Redis command

Caching Patterns

Cache-Aside (Lazy Loading)

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

  let data = await redis.get(key)
  if (data) return JSON.parse(data)

  data = await db.query('SELECT * FROM users WHERE id = $1', [id])
  if (data) {
    await redis.setex(key, 3600, JSON.stringify(data))
  }

  return data
}

Cache-aside works well for read-heavy workloads Downside: first request after cache expiry hits the database - that's the thundering herd problem

Write-Through

async function updateUser(id, updates) {
  const key = `user:${id}`

  // Update database first
  const user = await db.query(
    'UPDATE users SET name = $1 WHERE id = $2 RETURNING *',
    [updates.name, id]
  )

  // Then update cache
  await redis.setex(key, 3600, JSON.stringify(user[0]))

  return user[0]
}

Write-through keeps cache and database in sync Slower writes because you hit both systems - but reads never return stale data

Cache Invalidation

// Strategy 1: TTL expiry
await redis.setex(key, 3600, JSON.stringify(data))
// Cache auto-expires after 1 hour

// Strategy 2: Delete on update
async function deleteUser(id) {
  await db.query('DELETE FROM users WHERE id = $1', [id])
  await redis.del(`user:${id}`)
}

// Strategy 3: Pattern-based flush
async function invalidateUserCache(userId) {
  const pattern = `user:${userId}:*`
  const keys = await redis.keys(pattern)
  if (keys.length > 0) {
    await redis.del(...keys)
  }
}

KEYS blocks Redis in production - it scans all keys synchronously Use SCAN instead for production , or track cache keys in a set for targeted deletion

Session Storage

const session = require('express-session')
const RedisStore = require('connect-redis')(session)

app.use(session({
  store: new RedisStore({
    client: redis,
    prefix: 'sess:',           // session key prefix
    ttl: 86400,                // 24 hours in seconds
    disableTouch: false        // refresh TTL on access
  }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,              // HTTPS only
    httpOnly: true,             // no JS access
    sameSite: 'strict',        // CSRF protection
    maxAge: 24 * 60 * 60 * 1000  // 24 hours
  }
}))

Redis session storage means your sessions survive server restarts Without it , restarting your Node process logs everyone out - great for maintenance windows

Rate Limiting

Sliding window rate limiter - the standard pattern

const WINDOW_SIZE = 60     // 60 seconds
const MAX_REQUESTS = 100   // 100 requests per window

async function rateLimit(ip) {
  const key = `ratelimit:${ip}`
  const now = Date.now()
  const windowStart = now - WINDOW_SIZE * 1000

  // Remove old entries outside window
  await redis.zremrangebyscore(key, 0, windowStart)

  // Count requests in current window
  const count = await redis.zcard(key)

  if (count >= MAX_REQUESTS) {
    return { allowed: false, remaining: 0 }
  }

  // Add current request
  await redis.zadd(key, now, `${now}:${Math.random()}`)
  await redis.expire(key, WINDOW_SIZE)

  return { allowed: true, remaining: MAX_REQUESTS - count - 1 }
}

// Express middleware
app.use(async (req, res, next) => {
  const result = await rateLimit(req.ip)
  res.set('X-RateLimit-Remaining', result.remaining)

  if (!result.allowed) {
    return res.status(429).json({ error: 'too many requests' })
  }
  next()
})

Sorted sets make sliding window rate limiting trivial - each request is a score entry , old ones expire naturally

Pub/Sub for Real-Time

// Publisher
const Redis = require('ioredis')
const pub = new Redis()

app.post('/api/notify', async (req, res) => {
  const { userId, message } = req.body

  await pub.publish('notifications', JSON.stringify({
    userId,
    message,
    timestamp: Date.now()
  }))

  res.json({ sent: true })
})

// Subscriber (separate process or connection)
const sub = new Redis()
sub.subscribe('notifications')

sub.on('message', (channel, message) => {
  const event = JSON.parse(message)
  // Send via WebSocket to connected client
  wss.clients.forEach(client => {
    if (client.userId === event.userId) {
      client.send(JSON.stringify(event))
    }
  })
})

// Pattern subscribe - match channel patterns
sub.psubscribe('user:*:events')

sub.on('pmessage', (pattern, channel, message) => {
  // channel = "user:123:events"
  console.log(`Matched pattern ${pattern} on ${channel}`)
})

Redis pub/sub delivers messages once with no persistence If your subscriber is down , messages are lost forever - use Redis Streams or a message queue for reliability

Redis Security

Default Redis has no authentication - that's a disaster waiting to happen

# redis.conf - minimum security config
requirepass your-strong-password-here
bind 127.0.0.1                    # only localhost
port 6379
rename-command FLUSHALL ""        # disable dangerous commands
rename-command FLUSHDB ""
rename-command CONFIG ""
rename-command EVAL ""            # disable Lua (if not needed)
aclfile /etc/redis/users.acl      # ACL-based auth
// Connect with password
const redis = new Redis({
  host: 'localhost',
  port: 6379,
  password: process.env.REDIS_PASSWORD,
  tls: process.env.NODE_ENV === 'production' ? {
    rejectUnauthorized: true
  } : undefined
})

// ACL user management
// In redis-cli:
// ACL SETUSER appuser on >secure_password +@read +@write ~*
// ACL SETUSER admin on >admin_password +@all ~*

Unsecured Redis on a public IP gets owned by crypto miners within hours Bind to localhost or use a VPC , put auth on it , and disable dangerous commands

Prerequisites

  • db_04_orms.md - understand DB abstractions before adding caching layers