Hybrid Circuit Breaker - In-Memory with Redis Sync in NestJS
We’ve built an in-memory circuit breaker, then added Redis. But here’s the thing: every circuit check hits Redis. That’s a network call. What if we could use in-memory for speed and Redis for sync?
Let’s build a hybrid.
The problem with pure Redis
Every time you check a circuit state, you’re hitting Redis:
const state = await this.redis.get(`circuit:${key}:state`)
That’s a network call. If you’re checking circuits on every request, that adds up. Plus, if Redis has a hiccup, your requests slow down.
The hybrid approach
Use in-memory for reads, Redis for writes. Sync state across instances in the background.
Here’s the idea:
- Keep circuit state in memory (fast reads)
- Write state changes to Redis (sync across instances)
- Subscribe to Redis pub/sub (get updates from other instances)
- Periodically sync from Redis (catch up if we missed something)
Building it
Start with the hybrid service:
import { Injectable, Inject, OnModuleInit } from '@nestjs/common'
import Redis from 'ioredis'
import { REDIS_CLIENT } from './redis.module'
enum CircuitState {
CLOSED = 'CLOSED',
OPEN = 'OPEN',
HALF_OPEN = 'HALF_OPEN'
}
interface CircuitConfig {
failureThreshold: number
successThreshold: number
timeout: number
}
interface CircuitStats {
state: CircuitState
failures: number
successes: number
nextAttempt: number
}
@Injectable()
export class HybridCircuitBreakerService implements OnModuleInit {
private circuits = new Map<string, CircuitStats>()
private subscriber: Redis
private redisAvailable = false
private readonly defaultConfig: CircuitConfig = {
failureThreshold: 5,
successThreshold: 2,
timeout: 60000
}
constructor(
@Inject(REDIS_CLIENT) private readonly redis: Redis
) {
this.subscriber = this.redis.duplicate()
}
async onModuleInit() {
try {
await this.subscriber.subscribe('circuit:updates')
this.redisAvailable = true
this.subscriber.on('message', (channel, message) => {
if (channel === 'circuit:updates') {
const update = JSON.parse(message)
this.circuits.set(update.key, update.stats)
}
})
this.redis.on('error', () => {
this.redisAvailable = false
})
this.redis.on('connect', () => {
this.redisAvailable = true
})
} catch (error) {
this.redisAvailable = false
}
}
async execute<T>(
key: string,
fn: () => Promise<T>,
config?: Partial<CircuitConfig>
): Promise<T> {
const cfg = { ...this.defaultConfig, ...config }
const circuit = this.getCircuit(key)
if (circuit.state === CircuitState.OPEN) {
if (Date.now() < circuit.nextAttempt) {
throw new Error(`Circuit breaker is OPEN for ${key}`)
}
circuit.state = CircuitState.HALF_OPEN
circuit.successes = 0
this.publishUpdate(key, circuit)
}
try {
const result = await fn()
this.onSuccess(key, cfg)
return result
} catch (error) {
this.onFailure(key, cfg)
throw error
}
}
private getCircuit(key: string): CircuitStats {
if (!this.circuits.has(key)) {
this.circuits.set(key, {
state: CircuitState.CLOSED,
failures: 0,
successes: 0,
nextAttempt: 0
})
}
return this.circuits.get(key)!
}
private onSuccess(key: string, config: CircuitConfig) {
const circuit = this.getCircuit(key)
if (circuit.state === CircuitState.HALF_OPEN) {
circuit.successes++
if (circuit.successes >= config.successThreshold) {
circuit.state = CircuitState.CLOSED
circuit.failures = 0
circuit.successes = 0
}
} else {
circuit.failures = 0
}
this.writeToRedis(key, circuit)
this.publishUpdate(key, circuit)
}
private onFailure(key: string, config: CircuitConfig) {
const circuit = this.getCircuit(key)
circuit.failures++
if (circuit.failures >= config.failureThreshold) {
circuit.state = CircuitState.OPEN
circuit.nextAttempt = Date.now() + config.timeout
circuit.successes = 0
}
this.writeToRedis(key, circuit)
this.publishUpdate(key, circuit)
}
private async writeToRedis(key: string, stats: CircuitStats) {
if (!this.redisAvailable) return
try {
await this.redis.hset(`circuit:${key}`, {
state: stats.state,
failures: stats.failures,
successes: stats.successes,
nextAttempt: stats.nextAttempt
})
await this.redis.expire(`circuit:${key}`, 3600)
} catch (error) {
// Redis write failed, continue with in-memory
}
}
private async publishUpdate(key: string, stats: CircuitStats) {
if (!this.redisAvailable) return
try {
await this.redis.publish(
'circuit:updates',
JSON.stringify({ key, stats })
)
} catch (error) {
// Publish failed, other instances won't get update immediately
}
}
getState(key: string): CircuitState {
return this.getCircuit(key).state
}
async reset(key: string) {
this.circuits.delete(key)
if (this.redisAvailable) {
try {
await this.redis.del(`circuit:${key}`)
} catch (error) {
// Redis delete failed
}
}
}
getStats(key: string) {
return this.getCircuit(key)
}
}
Parallel operations
The key here is that Redis operations don’t block:
private async onSuccess(key: string, config: CircuitConfig) {
const circuit = this.getCircuit(key)
// Update in-memory state immediately
if (circuit.state === CircuitState.HALF_OPEN) {
circuit.successes++
if (circuit.successes >= config.successThreshold) {
circuit.state = CircuitState.CLOSED
circuit.failures = 0
circuit.successes = 0
}
} else {
circuit.failures = 0
}
// Fire and forget - don't await
// These run in parallel and don't block the response
this.writeToRedis(key, circuit)
this.publishUpdate(key, circuit)
}
No await means the function returns immediately. Redis writes happen in the background.
Performance comparison
The difference is noticeable:
// Pure Redis approach
async executeWithRedis() {
const start = Date.now()
const state = await this.redis.get('circuit:test:state') // Network call
const failures = await this.redis.get('circuit:test:failures') // Network call
console.log(`Redis check took: ${Date.now() - start}ms`)
}
// Hybrid approach
async executeWithHybrid() {
const start = Date.now()
const circuit = this.circuits.get('test') // In-memory, instant
console.log(`Hybrid check took: ${Date.now() - start}ms`)
}
In-memory lookups are basically instant. Redis calls need a network round trip. The exact difference depends on your network latency, but in-memory is always faster for reads.
Testing the hybrid approach
Test that state syncs across instances:
describe('HybridCircuitBreakerService', () => {
let service1: HybridCircuitBreakerService
let service2: HybridCircuitBreakerService
let redis: Redis
beforeEach(async () => {
redis = new Redis()
service1 = new HybridCircuitBreakerService(redis)
service2 = new HybridCircuitBreakerService(redis)
await service1.onModuleInit()
await service2.onModuleInit()
})
it('should sync state across instances', async () => {
const failingFn = async () => {
throw new Error('API down')
}
for (let i = 0; i < 5; i++) {
try {
await service1.execute('test', failingFn)
} catch (e) {}
}
// Wait for pub/sub
await new Promise(resolve => setTimeout(resolve, 100))
expect(service2.getState('test')).toBe(CircuitState.OPEN)
})
it('should work without Redis', async () => {
await redis.disconnect()
const result = await service1.execute('test', async () => 'success')
expect(result).toBe('success')
})
})
What I learned
In-memory is fast: Reads happen instantly without network calls.
Fire and forget: Don’t await Redis writes. Let them happen in the background.
Pub/sub for sync: Other instances get updates through Redis pub/sub.
Graceful degradation: Works without Redis, just loses cross-instance sync.
Keep it simple: Removed periodic sync and complex reconnection logic. Pub/sub handles most cases.
Use Redis hashes: Atomic updates for all circuit fields at once.
Set TTLs: Old circuits expire automatically after 1 hour.
Wrapping up
The hybrid approach gives you the best of both worlds - in-memory speed with Redis sync. Fast reads, eventual consistency across instances, and graceful degradation when Redis is down.
This is what I use in production. It’s fast, reliable, and scales.
Keep pushing forward and savor every step of your coding journey.
