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
SQLite for Desktop/Local Apps
better-sqlite3 setup, WAL mode, concurrent access, and performance tuning
Connection Pooling Done Right
PgBouncer, Prisma pool, Supabase pooler, and pool sizing formula