Redis Caching Tutorial for Node.js: Speed Up Your API in 2026

Redis is the most widely used in-memory data store in backend development. For a Node.js API, it solves three major problems: caching expensive database queries, storing sessions without hitting the database on every request, and enforcing rate limits at high throughput. This guide covers all three, with working code you can drop into a real project.

Why Redis?

A database query that takes 200ms can be served in under 1ms from Redis — because Redis stores data in RAM and uses data structures optimised for speed. For endpoints that serve the same data to many users (product listings, blog posts, leaderboards), caching in Redis is the highest-ROI performance optimization available.

Redis is not a database replacement. It's a cache layer that sits in front of your database. The database remains the source of truth; Redis holds frequently-accessed copies that expire automatically.

Installing Redis and ioredis

# Run Redis with Docker (easiest for development)
docker run -d --name redis -p 6379:6379 redis:7-alpine

# Or install locally (Ubuntu/Debian)
sudo apt install redis-server
sudo systemctl start redis
redis-cli ping   # should return PONG

# Install the Node.js client
npm install ioredis
npm install -D @types/ioredis   # if using TypeScript

Connecting to Redis

// lib/redis.ts — singleton client
import Redis from 'ioredis'

const redis = new Redis({
    host:           process.env.REDIS_HOST     || 'localhost',
    port:           Number(process.env.REDIS_PORT) || 6379,
    password:       process.env.REDIS_PASS     || undefined,
    retryStrategy: (times) => Math.min(times * 100, 3000),  // retry with backoff
    maxRetriesPerRequest: 3,
    lazyConnect: true,
})

redis.on('error', (err) => console.error('Redis error:', err))
redis.on('connect', () => console.log('Redis connected'))

export default redis

The Cache-Aside Pattern

Cache-aside (also called lazy-loading) is the most common caching pattern:

  1. Check if the data is in Redis
  2. If yes (cache hit), return it immediately
  3. If no (cache miss), fetch from the database, store in Redis, then return
import { prisma } from './lib/prisma'
import redis from './lib/redis'

async function getPost(id: number) {
    const cacheKey = `post:${id}`

    // 1. Check cache
    const cached = await redis.get(cacheKey)
    if (cached) {
        return JSON.parse(cached)   // cache hit — return immediately
    }

    // 2. Cache miss — fetch from DB
    const post = await prisma.post.findUnique({
        where:   { id },
        include: { author: true, tags: true },
    })

    if (!post) return null

    // 3. Store in cache with 10-minute TTL
    await redis.setex(cacheKey, 600, JSON.stringify(post))

    return post
}

Building a Reusable Cache Helper

Instead of repeating the cache-aside pattern everywhere, wrap it in a utility:

// lib/cache.ts
import redis from './redis'

export async function cached<T>(
    key:     string,
    ttl:     number,       // seconds
    fetcher: () => Promise<T>
): Promise<T> {
    const hit = await redis.get(key)
    if (hit) return JSON.parse(hit) as T

    const data = await fetcher()
    await redis.setex(key, ttl, JSON.stringify(data))
    return data
}

export async function invalidate(key: string | string[]) {
    if (Array.isArray(key)) {
        if (key.length > 0) await redis.del(...key)
    } else {
        await redis.del(key)
    }
}

export async function invalidatePattern(pattern: string) {
    const keys = await redis.keys(pattern)  // e.g., "post:*"
    if (keys.length > 0) await redis.del(...keys)
}

Now your route handlers are clean:

import { cached, invalidate, invalidatePattern } from './lib/cache'
import express from 'express'

const app = express()
app.use(express.json())

// Cached list
app.get('/posts', async (req, res) => {
    const posts = await cached('posts:all', 300, () =>
        prisma.post.findMany({ where: { published: true } })
    )
    res.json(posts)
})

// Cached single post
app.get('/posts/:id', async (req, res) => {
    const post = await cached(`post:${req.params.id}`, 600, () =>
        prisma.post.findUnique({ where: { id: Number(req.params.id) } })
    )
    if (!post) return res.status(404).json({ error: 'Not found' })
    res.json(post)
})

// On update — invalidate the affected caches
app.patch('/posts/:id', async (req, res) => {
    const id = Number(req.params.id)
    const post = await prisma.post.update({ where: { id }, data: req.body })
    await invalidate([`post:${id}`, 'posts:all'])  // clear both
    res.json(post)
})

// On delete — invalidate all post caches
app.delete('/posts/:id', async (req, res) => {
    const id = Number(req.params.id)
    await prisma.post.delete({ where: { id } })
    await invalidate(`post:${id}`)
    await invalidatePattern('posts:*')
    res.status(204).end()
})

Cache Invalidation Strategy

Cache invalidation is the hard part of caching. There are three main approaches:

1. TTL-Based (Eventual Consistency)

Set a short TTL and let data expire naturally. Stale data exists for at most ttl seconds. Works well when slight staleness is acceptable (product listings, blog posts). The simplest approach — no explicit invalidation needed.

2. Explicit Invalidation on Write

Delete the cache key whenever data changes (shown above). Ensures immediate consistency but requires you to track which cache keys need clearing for each mutation. Use a naming convention like resource:id and resource:list to make this manageable.

3. Write-Through Cache

// Update both DB and cache atomically
async function updatePost(id: number, data: Partial<Post>) {
    const post = await prisma.post.update({ where: { id }, data })
    await redis.setex(`post:${id}`, 600, JSON.stringify(post))  // write to cache immediately
    await invalidate('posts:all')
    return post
}

Session Storage with Redis

Storing sessions in Redis instead of memory means sessions survive server restarts and work across multiple server instances:

npm install express-session connect-redis
import session from 'express-session'
import { RedisStore } from 'connect-redis'
import redis from './lib/redis'

app.use(session({
    store:  new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET!,
    resave: false,
    saveUninitialized: false,
    cookie: {
        secure:   process.env.NODE_ENV === 'production',
        httpOnly: true,
        maxAge:   7 * 24 * 60 * 60 * 1000,  // 7 days in ms
    },
}))

// Set session data
app.post('/login', async (req, res) => {
    const user = await validateCredentials(req.body)
    req.session.userId = user.id
    res.json({ success: true })
})

// Read session data
app.get('/me', (req, res) => {
    if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' })
    res.json({ userId: req.session.userId })
})

Rate Limiting with Redis

Redis's atomic increment operations make it ideal for rate limiting:

// middleware/rateLimiter.ts
import { Request, Response, NextFunction } from 'express'
import redis from '../lib/redis'

interface RateLimitOptions {
    windowSeconds: number   // time window in seconds
    maxRequests:   number   // max requests per window
    keyPrefix?:    string
}

export function rateLimiter(opts: RateLimitOptions) {
    return async (req: Request, res: Response, next: NextFunction) => {
        const ip     = req.ip || req.socket.remoteAddress || 'unknown'
        const prefix = opts.keyPrefix || 'ratelimit'
        const key    = `${prefix}:${ip}`

        const count = await redis.incr(key)

        if (count === 1) {
            // First request in this window — set expiry
            await redis.expire(key, opts.windowSeconds)
        }

        const remaining = Math.max(0, opts.maxRequests - count)
        res.setHeader('X-RateLimit-Limit', opts.maxRequests)
        res.setHeader('X-RateLimit-Remaining', remaining)

        if (count > opts.maxRequests) {
            const ttl = await redis.ttl(key)
            res.setHeader('Retry-After', ttl)
            return res.status(429).json({
                error:   'Too many requests',
                retryIn: `${ttl} seconds`,
            })
        }

        next()
    }
}

// Apply per route
app.post('/auth/login',
    rateLimiter({ windowSeconds: 60, maxRequests: 5, keyPrefix: 'login' }),
    loginHandler
)

app.use('/api',
    rateLimiter({ windowSeconds: 60, maxRequests: 100 }),
    apiRouter
)

Redis Data Types Beyond Strings

// Sorted set — leaderboard
await redis.zadd('leaderboard', 1500, 'alice', 1200, 'bob', 900, 'charlie')
const top10 = await redis.zrevrange('leaderboard', 0, 9, 'WITHSCORES')

// Increment score
await redis.zincrby('leaderboard', 100, 'charlie')

// Hash — user profile (update individual fields without re-serializing)
await redis.hset(`user:42`, { name: 'Alice', email: 'alice@example.com', plan: 'pro' })
const email = await redis.hget(`user:42`, 'email')
const profile = await redis.hgetall(`user:42`)

// List — activity feed
await redis.lpush(`feed:42`, JSON.stringify({ type: 'post', postId: 1 }))
await redis.ltrim(`feed:42`, 0, 49)           // keep only 50 most recent
const feed = await redis.lrange(`feed:42`, 0, 19)  // get latest 20

// Set — tracking online users (unique)
await redis.sadd('online_users', userId)
await redis.expire('online_users', 300)         // 5-minute TTL per session ping
const count = await redis.scard('online_users') // count online

Monitoring Redis in Production

# Connect to Redis CLI
redis-cli -h your-host -p 6379 -a your-password

# Key stats
INFO memory       # memory usage
INFO stats        # hit rate, commands/sec
INFO clients      # connected clients

# Monitor all commands in real time (use sparingly)
MONITOR

# Count keys by pattern
redis-cli --scan --pattern "post:*" | wc -l

# Check TTL on a key
TTL post:42

# Flush specific keys (not entire DB!)
redis-cli --scan --pattern "posts:*" | xargs redis-cli del

Production Checklist

  • Set maxmemory and maxmemory-policy allkeys-lru in redis.conf — prevents Redis from using unlimited RAM and evicts least-recently-used keys when full
  • Enable persistence (appendonly yes) if session data or queued jobs cannot be lost on restart
  • Use a password (requirepass yourpassword) and bind to localhost or private network only
  • Use Upstash (serverless Redis) or Redis Cloud for managed hosting — eliminates ops overhead
  • Never store sensitive data (raw passwords, PII) in Redis without encryption — memory is not encrypted at rest by default

Summary

Adding Redis caching to a Node.js API is one of the highest-impact performance improvements you can make. Start with the cache-aside pattern on your most-queried endpoints, set conservative TTLs, and add explicit invalidation on writes. Then layer on session storage and rate limiting. The entire setup from zero to working cache takes under two hours for a typical Express or Fastify API.

Frequently Asked Questions

What's the difference between Redis and Memcached?

Memcached is purely a key-value cache — it stores strings, and that's it. Redis supports multiple data structures (strings, lists, sets, sorted sets, hashes, streams), has optional persistence, supports pub/sub messaging, and includes scripting with Lua. For new projects in 2026, there's almost no reason to choose Memcached over Redis. Redis does everything Memcached does and significantly more, with comparable performance for simple caching workloads.

How do I choose the right TTL for cached data?

Base TTL on how often data changes and how stale data is acceptable. Reference data that almost never changes (country list, category tree) can have a TTL of hours or days. Product listings that update frequently should be 1–5 minutes. User-specific data should usually not be cached (or cached for 30–60 seconds max). A good default for most API responses is 60–300 seconds. When in doubt, start with a shorter TTL and increase it if cache hit rates are low — a 30% hit rate is often worth it even at 60 seconds.

Should I cache at the application level or use a CDN?

Both serve different purposes and are complementary. A CDN (Cloudflare, Vercel Edge) caches HTTP responses at the network edge — closest to the user geographically. This eliminates the round trip to your server entirely. Application-level Redis caching reduces database load even when the CDN misses (first request, POST data, authenticated requests CDNs can't cache). For public, unauthenticated content: use CDN caching. For dynamic, authenticated, or user-specific data: use Redis. Most production apps use both.

Is ioredis better than the official `redis` package?

Both are production-quality. The official redis package (formerly node-redis) is maintained by Redis Ltd. ioredis is a community package with a long track record. Key differences: ioredis supports cluster mode, Sentinel, and pipelining more seamlessly; node-redis v4+ is promise-first and well-maintained. For most applications, both work fine. ioredis has slightly better cluster support; node-redis is more conservative and actively maintained by the company behind Redis. The choice rarely matters for typical use cases.