TrueSpec

Redis Caching for API Responses

Cache-aside pattern, TTL, invalidation, and cache stampede prevention

What You’ll Build

After following this guide, you will have a working implementation of redis caching for api responses in your project. Speed up your API by 10-100x with Redis caching. Implements the cache-aside (lazy-loading) pattern: check Redis first, fall back to database, then store in cache. Includes TTL expiration, manual invalidation on writes, and cache stampede prevention with locking.

Use Cases & Problems Solved

  • Set up a production-ready database layer with type-safe queries
  • Manage schema changes through migrations without data loss
  • Avoid raw SQL injection risks and inconsistent data access patterns

Prerequisites

  • Redis server running
  • Node.js 18+
  • ioredis package

Step-by-Step Implementation

Install and connect

The following snippet shows how to install and connect. Copy this into your project and adjust the values for your environment.

npm install ioredis

Cache-aside pattern middleware

The following snippet shows how to cache-aside pattern middleware. Copy this into your project and adjust the values for your environment.

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function cacheMiddleware(keyFn, ttl = 300) {
  return async (req, res, next) => {
    const key = typeof keyFn === 'function' ? keyFn(req) : keyFn;
    const cached = await redis.get(key);

    if (cached) {
      return res.json(JSON.parse(cached));
    }

    // Override res.json to cache the response
    const originalJson = res.json.bind(res);
    res.json = (data) => {
      redis.setex(key, ttl, JSON.stringify(data));
      return originalJson(data);
    };
    next();
  };
}

// Usage
app.get('/api/products',
  cacheMiddleware(req => `products:${req.query.category || 'all'}`, 600),
  getProductsHandler
);

Invalidate cache on writes

The following snippet shows how to invalidate cache on writes. Copy this into your project and adjust the values for your environment.

app.post('/api/products', async (req, res) => {
  const product = await db.product.create({ data: req.body });

  // Invalidate related cache keys
  await redis.del('products:all');
  await redis.del(`products:${product.category}`);

  res.json(product);
});

⚠️ Don’t Do This

❌ Caching without TTL — stale data forever

// Data never expires — users see outdated information!
await redis.set('products:all', JSON.stringify(products));

✅ Always set a TTL and invalidate on writes

// Expires in 10 minutes AND invalidated on product changes
await redis.setex('products:all', 600, JSON.stringify(products));

Testing

Verify your implementation with these tests:

// __tests__/redis-caching-for-api-responses.test.ts
import { describe, it, expect } from 'vitest';

describe('Redis Caching for API Responses', () => {
  it('should initialize without errors', () => {
    // Test that the setup completes successfully
    expect(() => setup()).not.toThrow();
  });

  it('should handle the primary use case', async () => {
    const result = await execute();
    expect(result).toBeDefined();
    expect(result.success).toBe(true);
  });

  it('should handle edge cases', async () => {
    // Test with empty/null input
    const result = await execute(null);
    expect(result.error).toBeDefined();
  });
});

Verification

# Hit the endpoint twice and check response time:
curl -w '\
Time: %{time_total}s\
' http://localhost:3000/api/products
# First request: ~200ms (database)
# Second request: ~5ms (cache hit!)

# Verify in Redis:
redis-cli GET 'products:all'

Related Specs

Beginner

SQLite for Desktop/Local Apps

better-sqlite3 setup, WAL mode, concurrent access, and performance tuning

Database & ORM
Beginner

MongoDB CRUD with Mongoose

Schema validation, virtuals, populate, indexes, and aggregation

Database & ORM
Advanced

Connection Pooling Done Right

PgBouncer, Prisma pool, Supabase pooler, and pool sizing formula

Database & ORM