redis latency profile
Redis is exceptionally fast in production — sub-millisecond at p50. But under memory pressure, key eviction, or network jitter, the p99 can spike to 20ms+. Without slowdep, your local Redis returns in <0.1ms and you never notice code that assumes cache is "free".
Code that calls Redis in a loop (fetching 100 keys one at a time) runs fine locally where each call takes 0.1ms, but becomes a 100ms bottleneck in production. Realistic latency exposes these patterns in tests before they reach users.
Wrap the ioredis client
The fastest path: use withLatencyAll on your ioredis instance. Every command — get, set, del, expire, and pipeline — will experience realistic latency.
import Redis from 'ioredis'; import { withLatencyAll } from 'slowdep'; const redis = new Redis(process.env.REDIS_URL); // Wrap every Redis command with realistic latency const slowRedis = withLatencyAll(redis, 'redis'); // Cache-aside pattern — now with realistic latency async function getCachedUser(userId: string) { const cached = await slowRedis.get(`user:${userId}`); if (cached) return JSON.parse(cached); const user = await fetchUserFromDB(userId); await slowRedis.set(`user:${userId}`, JSON.stringify(user), 'EX', 3600); return user; }
Wrap get and set independently
If you want different latency profiles for reads vs writes, or you only want to slow specific commands, wrap them individually with withLatency.
import Redis from 'ioredis'; import { withLatency } from 'slowdep'; const redis = new Redis(process.env.REDIS_URL); // Standard redis preset for reads const slowGet = withLatency(redis.get.bind(redis), 'redis'); // Writes are slightly slower due to persistence overhead const slowSet = withLatency(redis.set.bind(redis), { p50: 2, p99: 30 }); // DEL is fast, same as GET const slowDel = withLatency(redis.del.bind(redis), 'redis'); // Use them as drop-in replacements const value = await slowGet('session:abc123'); await slowSet('session:abc123', 'data', 'EX', 86400); await slowDel('session:abc123');
Test cache-miss vs cache-hit latency
The most important Redis test: verify your service correctly distinguishes the fast cache-hit path from the slower cache-miss path that falls through to the database.
import { withLatency } from 'slowdep'; test('cache miss: falls through to DB and caches result', async () => { // Miss: GET returns null, then SET stores the DB result const mockGet = withLatency( jest.fn().mockResolvedValue(null), // cache miss 'redis' ); const mockSet = withLatency(jest.fn().mockResolvedValue('OK'), 'redis'); const mockDbFetch = jest.fn().mockResolvedValue({ id: 'u1', name: 'Alice' }); const user = await getCachedUser('u1', { get: mockGet, set: mockSet, db: mockDbFetch }); expect(mockDbFetch).toHaveBeenCalledOnce(); // DB was hit expect(mockSet).toHaveBeenCalledOnce(); // result was cached expect(user.name).toBe('Alice'); });
import { withLatency } from 'slowdep'; test('cache hit: returns cached value without hitting DB', async () => { const cachedUser = JSON.stringify({ id: 'u1', name: 'Alice' }); // Hit: GET returns the cached JSON string const mockGet = withLatency( jest.fn().mockResolvedValue(cachedUser), 'redis' ); const mockDbFetch = jest.fn(); const user = await getCachedUser('u1', { get: mockGet, db: mockDbFetch }); expect(mockDbFetch).not.toHaveBeenCalled(); // DB was NOT hit expect(user.name).toBe('Alice'); });
import { withLatency } from 'slowdep'; test('slow Redis falls back to DB gracefully', async () => { // Simulate Redis under pressure — p99 of 500ms const slowGet = withLatency( jest.fn().mockResolvedValue(null), { p50: 100, p99: 500 } ); const mockDbFetch = jest.fn().mockResolvedValue({ id: 'u1', name: 'Alice' }); // Your service should fall back to DB if Redis takes > 50ms const user = await getCachedUserWithFallback('u1', { get: slowGet, db: mockDbFetch, redisTimeoutMs: 50 }); // DB fallback happened because Redis was too slow expect(mockDbFetch).toHaveBeenCalled(); expect(user.name).toBe('Alice'); });
Gate on NODE_ENV or SLOWDEP_ENABLED
import Redis from 'ioredis'; import { withLatencyAll } from 'slowdep'; const redis = new Redis(process.env.REDIS_URL); export const cache = process.env.SLOWDEP_ENABLED === 'true' ? withLatencyAll(redis, 'redis') : redis;
If you use ioredis pipelines (redis.pipeline().get(...).set(...).exec()), withLatencyAll wraps the pipeline factory method, not the individual pipeline commands. The latency applies once — at the pipeline execution — which accurately reflects the single round-trip cost.
Scenarios this integration helps you verify
- N+1 cache reads — realistic per-command latency makes sequential cache reads visible; push toward
mgetwith multiple keys - Cache stampede handling — when multiple requests miss the cache simultaneously, does your code protect against thundering herd?
- Redis fallback to DB — if Redis is slow (p99 tail), does your service degrade gracefully to the database?
- Session timeout handling — does your auth middleware handle a slow
get(sessionKey)within the request deadline? - Rate limiter accuracy — sliding window rate limiters depend on Redis ZADD/ZREMRANGEBYSCORE; verify they work with network latency
- Pub/sub delivery timing — event-driven features relying on Redis pub/sub need to account for delivery latency in their correctness guarantees
Typed Redis wrapper
import Redis from 'ioredis'; import { withLatency, withLatencyAll } from 'slowdep'; const redis = new Redis(process.env.REDIS_URL); // withLatencyAll preserves the Redis type — slowCache is still typed as Redis const slowCache: Redis = withLatencyAll(redis, 'redis'); // Type-safe cache helper async function getJson<T>(key: string): Promise<T | null> { const raw = await slowCache.get(key); // string | null return raw ? JSON.parse(raw) as T : null; } async function setJson<T>(key: string, value: T, ttlSeconds: number): Promise<void> { await slowCache.set(key, JSON.stringify(value), 'EX', ttlSeconds); }