dev_tools
Back to guides

Redis + Node.js Caching

Add fast in-memory caching to an Express + TypeScript API using ioredis, with a reusable cache middleware, configurable TTLs, and explicit cache invalidation.

Redis
Node.js
ioredis
Caching
Express
TypeScript
1

Run Redis Locally with Docker

The fastest way to get Redis running locally. The --save '' flag disables disk persistence so it starts instantly.

docker run -d --name redis -p 6379:6379 redis:7-alpine redis-server --save ''
💡Skip this step if you're using a cloud Redis instance (Upstash, Redis Cloud, etc.).
2

Install ioredis

ioredis is the most feature-complete Redis client for Node.js with first-class TypeScript support and automatic reconnection.

npm install ioredis && npm install -D @types/node
3

Create a Singleton Redis Client

Create src/lib/redis.ts. Using a singleton prevents opening a new connection pool on every import — critical in environments that hot-reload modules.

src/lib/redis.ts
import Redis from 'ioredis'

const globalForRedis = global as unknown as { redis?: Redis }

export const redis =
  globalForRedis.redis ??
  new Redis({
    host:           process.env.REDIS_HOST     || 'localhost',
    port:           Number(process.env.REDIS_PORT) || 6379,
    password:       process.env.REDIS_PASSWORD || undefined,
    maxRetriesPerRequest: 3,
    enableReadyCheck: true,
    lazyConnect:    false,
  })

if (process.env.NODE_ENV !== 'production') {
  globalForRedis.redis = redis
}

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

Build a Typed Cache Helper

Create src/lib/cache.ts. A thin wrapper around redis.get/set that handles JSON serialisation and expiry in one call. The generic T keeps responses fully typed.

src/lib/cache.ts
import { redis } from './redis'

export async function getCache<T>(key: string): Promise<T | null> {
  const raw = await redis.get(key)
  if (!raw) return null
  try {
    return JSON.parse(raw) as T
  } catch {
    return null
  }
}

export async function setCache<T>(
  key: string,
  value: T,
  ttlSeconds = 60
): Promise<void> {
  await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds)
}

export async function deleteCache(key: string): Promise<void> {
  await redis.del(key)
}

export async function deleteCacheByPattern(pattern: string): Promise<void> {
  const keys = await redis.keys(pattern)
  if (keys.length > 0) await redis.del(...keys)
}
5

Create a Cache Middleware for Express

Create src/middleware/cache.ts. Wrap any route with this middleware and it will serve cached responses instantly — only calling next() (and hitting your DB) on a cache miss.

src/middleware/cache.ts
import { Request, Response, NextFunction } from 'express'
import { getCache, setCache } from '../lib/cache'

export function cacheMiddleware(ttlSeconds = 60) {
  return async (req: Request, res: Response, next: NextFunction) => {
    "text-muted-foreground italic">// Only cache GET requests
    if (req.method !== 'GET') return next()

    const key = `cache:${req.originalUrl}`
    const cached = await getCache(key)

    if (cached) {
      res.setHeader('X-Cache', 'HIT')
      return res.json(cached)
    }

    "text-muted-foreground italic">// Intercept res.json to store the response before sending
    const originalJson = res.json.bind(res)
    res.json = (body) => {
      if (res.statusCode === 200) {
        setCache(key, body, ttlSeconds).catch(console.error)
      }
      res.setHeader('X-Cache', 'MISS')
      return originalJson(body)
    }

    next()
  }
}
6

Apply the Middleware to Routes

Pass cacheMiddleware(ttl) as a second argument to any route you want cached. Routes that mutate data should call deleteCache to invalidate stale entries.

src/routes/users.ts
import { Router } from 'express'
import { cacheMiddleware } from '../middleware/cache'
import { deleteCache, deleteCacheByPattern } from '../lib/cache'

const router = Router()

"text-muted-foreground italic">// Cache the user list for 5 minutes
router.get('/', cacheMiddleware(300), async (_req, res) => {
  const users = await db.user.findMany()  "text-muted-foreground italic">// replace with your real query
  res.json(users)
})

"text-muted-foreground italic">// Cache individual users for 10 minutes
router.get('/:id', cacheMiddleware(600), async (req, res) => {
  const user = await db.user.findUnique({ where: { id: Number(req.params.id) } })
  if (!user) return res.status(404).json({ error: 'Not found' })
  res.json(user)
})

"text-muted-foreground italic">// After creating a user, invalidate the cached list
router.post('/', async (req, res) => {
  const user = await db.user.create({ data: req.body })
  await deleteCacheByPattern('cache:/users*')
  res.status(201).json(user)
})

"text-muted-foreground italic">// After updating, invalidate both the list and the specific user cache
router.patch('/:id', async (req, res) => {
  const id = Number(req.params.id)
  const user = await db.user.update({ where: { id }, data: req.body })
  await Promise.all([
    deleteCache(`cache:/users/${id}`),
    deleteCacheByPattern('cache:/users*'),
  ])
  res.json(user)
})

export default router
7

Add Redis Config to .env

.env
# Local Redis
REDIS_HOST=localhost
REDIS_PORT=6379

# Upstash (cloud) — use REDIS_URL instead
# REDIS_URL=rediss://default:password@hostname:6379
8

Test Cache Hits

Make the same GET request twice and check the X-Cache response header — first request is MISS, second is HIT (served from Redis in ~1ms).

curl -v http://localhost:3000/users 2>&1 | grep -E 'X-Cache|< HTTP'
💡You can also inspect cached keys directly: `docker exec -it redis redis-cli KEYS 'cache:*'`

Found this guide helpful? Check out more tools and guides.

Browse All Guides