Hero image for When PostgreSQL Caching Falls Short: A Practical Guide to Adding Redis

When PostgreSQL Caching Falls Short: A Practical Guide to Adding Redis


Your PostgreSQL database is handling everything beautifully until one day your cache table queries start timing out during peak traffic. You’ve tuned shared_buffers, added indexes, even moved your cache tables to UNLOGGED status. The queries still crawl. Meanwhile, your application servers are piling up connections waiting for cached product recommendations that used to return in 2ms but now take 200ms—when they return at all.

This is the PostgreSQL caching ceiling, and hitting it feels like betrayal. PostgreSQL is remarkably capable as a caching layer. Its shared_buffers keep hot data in memory. Materialized views precompute expensive aggregations. UNLOGGED tables eliminate write-ahead log overhead for data you can afford to lose. For many applications, these tools are enough. You avoid the operational complexity of another database, keep your stack simple, and sleep soundly.

But PostgreSQL has a fundamental constraint that no amount of tuning overcomes: it’s a single system serving both your cache reads and your transactional writes. When traffic spikes, those workloads compete for the same connections, the same CPU cycles, the same I/O bandwidth. Your cache—designed to reduce database load—becomes part of the problem it was supposed to solve.

Redis exists to break this coupling. It’s not about Redis being “faster” in some abstract benchmark sense. It’s about architectural separation: offloading read-heavy, ephemeral data to a system purpose-built for that access pattern, freeing PostgreSQL to focus on what it does best.

The question isn’t whether Redis is better than PostgreSQL for caching. The question is whether your specific workload has crossed the threshold where separation pays for its complexity.

The PostgreSQL Caching Ceiling: When Built-in Approaches Hit Their Limits

PostgreSQL ships with sophisticated caching mechanisms that handle most workloads admirably. The shared_buffers pool keeps frequently accessed data pages in memory, the OS page cache provides an additional layer, and prepared statements cache query plans. For many applications, this built-in infrastructure delivers excellent performance without external dependencies.

Visual: PostgreSQL caching mechanisms and their limitations

But every caching strategy has limits. Understanding where PostgreSQL’s native approaches break down helps you recognize when architectural changes become necessary rather than optional.

The shared_buffers Reality

PostgreSQL’s shared_buffers typically consumes 25% of available RAM—a recommendation that balances database caching against OS-level caching and application memory needs. This works well when your hot dataset fits within that allocation. Problems emerge when it doesn’t.

When cache hit ratios drop below 99%, query latencies become unpredictable. A query that normally returns in 2ms suddenly takes 200ms because the required pages were evicted. Under sustained load, this unpredictability cascades through your application, creating timeout storms and retry amplification.

Cache Tables and Write Amplification

A common pattern for application-level caching in PostgreSQL involves storing computed results in dedicated cache tables. Session data, user preferences, API response caches—all live alongside your transactional data.

This approach introduces write amplification that compounds under load. Every cache write generates WAL entries, triggers checkpoint activity, and competes for I/O bandwidth with your primary workload. At 10,000 cache writes per minute, the overhead remains negligible. At 100,000 writes per minute, you’re spending significant database resources maintaining ephemeral data.

The connection pool becomes another pressure point. Cache operations—typically short-lived but high-frequency—consume connections that your transactional queries need. Connection exhaustion during traffic spikes often traces back to cache table access patterns.

The UNLOGGED Compromise

UNLOGGED tables offer a partial solution by eliminating WAL overhead for cache data. Writes complete faster, replication lag disappears for those tables, and checkpoint pressure decreases.

The trade-off is durability. UNLOGGED tables are truncated after a crash or unclean shutdown. For true caches—data that can be regenerated from authoritative sources—this is acceptable. For session stores or rate limiting counters, losing this data during a failover creates user-facing problems.

💡 Pro Tip: UNLOGGED tables don’t replicate to standby servers. If your application reads from replicas, cache misses spike after any network partition or replication lag event.

When Reads Compete with Writes

The clearest signal that PostgreSQL-only caching has reached its ceiling: cache read operations competing with transactional writes for the same resources. Lock contention on cache tables, buffer pool churn affecting query performance, autovacuum struggling to keep pace with cache table modifications.

At this inflection point, the question shifts from “how do we optimize our PostgreSQL caching” to “what does our cache performance actually look like today.” Measuring that baseline is the essential first step.

Benchmarking Your Current Setup: Measuring Cache Performance

Before adding Redis to your stack, you need hard evidence that PostgreSQL caching is your actual bottleneck. Too many teams add infrastructure complexity based on intuition rather than data. This section provides the diagnostic queries that separate genuine cache performance issues from other problems masquerading as cache failures.

Start with pg_stat_statements, the single most valuable extension for query performance analysis. If you haven’t enabled it yet, add shared_preload_libraries = 'pg_stat_statements' to your postgresql.conf and restart. This query surfaces your most expensive cached-data queries:

cache_bottleneck_analysis.sql
SELECT
substring(query, 1, 80) AS query_preview,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS avg_ms,
round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct_total,
rows
FROM pg_stat_statements
WHERE query ILIKE '%cache%'
OR query ILIKE '%session%'
OR query ILIKE '%config%'
ORDER BY total_exec_time DESC
LIMIT 20;

Look for queries with high calls counts combined with consistent avg_ms values above 5ms. These represent cache-like access patterns that PostgreSQL handles repeatedly without benefiting from query plan caching. Pay particular attention to queries returning single rows with simple WHERE clauses—these are prime candidates for Redis migration since they follow classic key-value access patterns that Redis handles with sub-millisecond latency.

Measuring Buffer Cache Effectiveness

PostgreSQL’s shared buffer cache works well until it doesn’t. The buffer cache stores frequently accessed disk pages in memory, but it operates at the block level rather than the query result level. This query reveals whether your frequently-accessed tables actually reside in memory:

buffer_cache_hit_ratio.sql
SELECT
schemaname,
relname AS table_name,
heap_blks_read AS disk_reads,
heap_blks_hit AS cache_hits,
round(
heap_blks_hit::numeric / NULLIF(heap_blks_hit + heap_blks_read, 0) * 100,
2
) AS hit_ratio_pct
FROM pg_stat_user_tables
WHERE heap_blks_hit + heap_blks_read > 1000
ORDER BY heap_blks_read DESC
LIMIT 15;

A healthy PostgreSQL installation maintains hit ratios above 99% for frequently-accessed tables. Ratios dropping below 95% for your “cache” tables indicate memory pressure that Redis handles more gracefully through explicit eviction policies. When interpreting these results, remember that even 99% hit ratios still mean PostgreSQL must parse, plan, and execute each query—overhead that Redis eliminates entirely for simple lookups.

Detecting Connection Pool Exhaustion

Cache stampedes—when many requests simultaneously miss the cache and query the database—manifest as connection pool exhaustion. This phenomenon occurs when cached data expires and dozens or hundreds of concurrent requests all attempt to regenerate the same cached value simultaneously. Query your connection metrics during peak load:

connection_pressure.sql
SELECT
datname,
numbackends AS active_connections,
xact_commit + xact_rollback AS total_transactions,
blks_hit,
blks_read,
tup_fetched,
round(blks_hit::numeric / NULLIF(blks_hit + blks_read, 0) * 100, 2) AS cache_ratio
FROM pg_stat_database
WHERE datname = 'myapp_production';

Compare active_connections against your max_connections setting during traffic spikes. Consistent utilization above 70% combined with cache-heavy workloads signals that connection pooling alone won’t solve your scaling constraints. Redis mitigates stampedes through atomic operations like SETNX, allowing you to implement cache locks that ensure only one request regenerates expensive cached data while others wait.

Pro Tip: Run these queries during your peak traffic windows, not during quiet periods. Cache problems often hide until load exposes them. Consider scheduling automated snapshots of these metrics every five minutes using pg_cron or an external monitoring tool.

Performance Thresholds That Trigger the Redis Conversation

Based on production experience across multiple high-traffic applications, these thresholds indicate PostgreSQL caching has reached its practical limits:

  • Buffer cache hit ratio below 95% for lookup tables
  • Average query time above 10ms for key-value style lookups
  • Connection utilization above 60% during normal traffic
  • Cache table query volume exceeding 10,000 calls/minute per table

Meeting two or more of these thresholds simultaneously justifies the operational overhead of Redis. Meeting all four makes Redis nearly mandatory. However, context matters—a batch processing system with predictable load patterns has different tolerance levels than a user-facing API serving real-time requests.

Document your baseline metrics now, before making architectural changes. Export the results of each diagnostic query to a timestamped file or dashboard. These numbers become your success criteria for validating that Redis actually improved your situation rather than simply adding complexity. Without this baseline, you cannot objectively measure whether Redis delivered the performance gains that justified its operational cost.

With concrete performance data in hand, you’re ready to evaluate which Redis architecture patterns best complement your existing PostgreSQL deployment.

Redis Architecture Patterns That Complement PostgreSQL

Adding Redis to a PostgreSQL architecture demands careful pattern selection. The wrong pattern creates complexity without solving your actual problem. The right pattern offloads specific workloads from PostgreSQL while maintaining the data consistency guarantees your application requires.

Visual: Redis architecture patterns for PostgreSQL integration

Cache-Aside vs Write-Through: Matching Patterns to Consistency Needs

Cache-aside places cache management responsibility in your application code. When reading data, the application checks Redis first, falls back to PostgreSQL on a miss, and populates the cache before returning. Writes go directly to PostgreSQL, and the application either invalidates or updates the cache entry.

This pattern works well when you can tolerate brief inconsistency windows. Read-heavy workloads with infrequent updates—product catalogs, user profiles, configuration data—fit naturally here. The application controls exactly what gets cached and for how long.

Write-through maintains stronger consistency by updating both Redis and PostgreSQL on every write. The cache always reflects the current database state, eliminating stale reads. The trade-off is write latency: every update now involves two systems.

Choose write-through when your reads significantly outnumber writes and cache staleness causes business problems. Financial dashboards showing account balances, inventory counts for e-commerce checkouts, and permission systems all benefit from guaranteed cache freshness.

💡 Pro Tip: Cache-aside handles cache failures gracefully—requests fall through to PostgreSQL. Write-through requires careful failure handling since a Redis failure can block writes entirely. Design your write-through implementation with circuit breakers from day one.

Session Storage: Reducing PostgreSQL Connection Pressure

PostgreSQL connection limits create a hard ceiling on concurrent users when storing sessions in your database. Each session check consumes a connection, and connection pool exhaustion triggers request queuing or failures.

Moving sessions to Redis eliminates this pressure entirely. Session reads and writes bypass PostgreSQL, freeing connections for actual data operations. Redis handles the high-frequency, low-latency access pattern of session lookups far more efficiently than a relational database.

The migration path is straightforward: most web frameworks support Redis session backends with minimal configuration changes. Session data rarely needs ACID guarantees—if a user needs to re-authenticate after a Redis restart, the inconvenience is minor compared to database connection exhaustion.

Real-Time Counters and Leaderboards: Purpose-Built Data Structures

Incrementing a counter in PostgreSQL requires a row lock. Under high concurrency, these locks serialize operations and create bottlenecks. Redis atomic increment operations—INCR, INCRBY, HINCRBY—scale horizontally without locking overhead.

Sorted sets provide native leaderboard functionality that PostgreSQL replicates only through expensive queries. Adding a score, retrieving top N entries, and finding a specific item’s rank all execute in logarithmic time. Building real-time gaming leaderboards, trending content rankings, or rate limiting counters on PostgreSQL requires constant query optimization. Redis handles these use cases with single commands.

Pub/Sub for Distributed Cache Invalidation

Running multiple application instances creates a cache coherency challenge. When one instance updates data, other instances hold stale cache entries until TTL expiration.

Redis pub/sub provides the invalidation broadcast channel. The instance performing the write publishes an invalidation message. All subscribed instances receive the notification and clear their local cache entries immediately. This pattern keeps cache TTLs short while maintaining consistency across your application fleet.

The pub/sub channel carries lightweight invalidation signals, not the actual data. Instances fetch fresh data from PostgreSQL on their next read, ensuring they always work with authoritative values.

Understanding these patterns is essential, but implementation details determine success or failure. The next section walks through cache-aside implementation with proper invalidation handling, showing exactly how to wire these concepts into production code.

Implementing Cache-Aside with Proper Invalidation

Cache-aside (also called lazy-loading) remains the most practical pattern for adding Redis to an existing PostgreSQL application. The strategy is straightforward: check Redis first, fall back to PostgreSQL on a miss, and populate the cache for subsequent requests. The complexity lies in keeping the cache consistent with your source of truth.

Building the Cache Layer

A production cache layer needs more than basic get/set operations. It requires graceful degradation when Redis is unavailable, consistent serialization, and clear TTL policies. The design philosophy here prioritizes resilience: your application should never fail because the cache is down.

cache_layer.py
import json
import redis
import psycopg2
from functools import wraps
from typing import Optional, Callable
import logging
logger = logging.getLogger(__name__)
class CacheLayer:
def __init__(self, redis_client: redis.Redis, pg_conn, default_ttl: int = 300):
self.redis = redis_client
self.pg = pg_conn
self.default_ttl = default_ttl
def get_user(self, user_id: int) -> Optional[dict]:
cache_key = f"user:{user_id}"
# Try Redis first
try:
cached = self.redis.get(cache_key)
if cached:
return json.loads(cached)
except redis.RedisError as e:
logger.warning(f"Redis unavailable, falling back to PostgreSQL: {e}")
# Fall back to PostgreSQL
with self.pg.cursor() as cur:
cur.execute(
"SELECT id, email, name, created_at FROM users WHERE id = %s",
(user_id,)
)
row = cur.fetchone()
if not row:
return None
user = {
"id": row[0],
"email": row[1],
"name": row[2],
"created_at": row[3].isoformat()
}
# Populate cache for next request
try:
self.redis.setex(cache_key, self.default_ttl, json.dumps(user))
except redis.RedisError:
pass # Cache population is best-effort
return user

The critical detail here: Redis failures never break your application. PostgreSQL remains the authoritative data source, and cache operations are always best-effort. Notice how both the cache read and write are wrapped in try-except blocks—this defensive approach ensures that a Redis connection timeout or memory exhaustion event degrades gracefully rather than cascading into application failures.

Consider your TTL strategy carefully. Short TTLs (30-60 seconds) work well for frequently changing data where eventual consistency is acceptable. Longer TTLs (5-15 minutes) suit reference data that changes infrequently. The default of 300 seconds (5 minutes) represents a reasonable middle ground for user profile data, balancing cache hit rates against staleness tolerance.

Preventing Cache Stampedes

When a popular cache key expires, hundreds of concurrent requests can simultaneously hit PostgreSQL. This “thundering herd” problem brings down databases during traffic spikes. The issue compounds during peak traffic—precisely when you need caching most, your database faces maximum load from cache misses.

Probabilistic early expiration solves this by having some requests refresh the cache before the actual TTL expires. The algorithm, sometimes called XFetch, introduces controlled randomness so that cache refreshes are spread across multiple requests rather than all happening at the moment of expiration.

stampede_prevention.py
import random
import time
def get_with_early_refresh(
redis_client: redis.Redis,
key: str,
fetch_func: Callable,
ttl: int = 300,
beta: float = 1.0
) -> dict:
"""Fetch from cache with probabilistic early expiration."""
pipe = redis_client.pipeline()
pipe.get(key)
pipe.ttl(key)
cached, remaining_ttl = pipe.execute()
if cached and remaining_ttl > 0:
# XFetch algorithm: probabilistically refresh before expiration
delta = ttl - remaining_ttl
if delta > 0:
random_threshold = delta * beta * random.random()
if random_threshold < remaining_ttl:
return json.loads(cached)
else:
return json.loads(cached)
# Cache miss or early refresh triggered
fresh_data = fetch_func()
redis_client.setex(key, ttl, json.dumps(fresh_data))
return fresh_data

The beta parameter controls refresh aggressiveness. A value of 1.0 provides good protection for most workloads. Increase it for extremely hot keys that receive thousands of requests per second. For keys with expensive computation behind them—complex aggregations or joins across multiple tables—consider setting beta to 2.0 or higher to ensure refreshes happen well before expiration.

Event-Driven Invalidation with LISTEN/NOTIFY

Polling for changes wastes resources and introduces latency between writes and cache updates. PostgreSQL’s LISTEN/NOTIFY mechanism provides a more elegant solution: it pushes invalidation events to a background worker that clears the relevant Redis keys immediately after writes. This event-driven approach means your cache updates happen in near real-time without the overhead of continuous polling.

invalidation_listener.py
import select
import psycopg2.extensions
def run_invalidation_listener(pg_dsn: str, redis_client: redis.Redis):
conn = psycopg2.connect(pg_dsn)
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
with conn.cursor() as cur:
cur.execute("LISTEN cache_invalidation;")
logger.info("Listening for cache invalidation events")
while True:
if select.select([conn], [], [], 5) == ([], [], []):
continue
conn.poll()
while conn.notifies:
notify = conn.notifies.pop(0)
# Payload format: "table:primary_key"
cache_key = notify.payload
redis_client.delete(cache_key)
logger.debug(f"Invalidated cache key: {cache_key}")

The corresponding PostgreSQL trigger fires notifications on data changes. This trigger should be defined once and attached to any table whose data you cache:

invalidation_trigger.sql
CREATE OR REPLACE FUNCTION notify_cache_invalidation()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('cache_invalidation', TG_TABLE_NAME || ':' || NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_cache_invalidation
AFTER INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION notify_cache_invalidation();

💡 Pro Tip: Run multiple invalidation listener instances for high availability. NOTIFY delivers to all listeners, so you get automatic redundancy. If one listener process crashes, others continue processing invalidation events without interruption.

Atomic Updates to Prevent Stale Reads

The write path matters as much as the read path. A subtle but dangerous race condition emerges if you delete the cache key before committing the database transaction: another request can read from PostgreSQL, get the old value, and re-cache stale data before your commit completes. The window may be milliseconds, but under high concurrency, this race condition triggers frequently enough to cause real problems.

Always invalidate after the commit succeeds:

atomic_update.py
def update_user(cache_layer: CacheLayer, user_id: int, new_email: str):
cache_key = f"user:{user_id}"
with cache_layer.pg.cursor() as cur:
cur.execute(
"UPDATE users SET email = %s WHERE id = %s",
(new_email, user_id)
)
cache_layer.pg.commit()
# Invalidate only after successful commit
cache_layer.redis.delete(cache_key)

For systems requiring stronger consistency, use a short TTL on writes and let the LISTEN/NOTIFY mechanism handle invalidation. The dual approach—immediate deletion plus event-driven cleanup—handles edge cases where the application crashes between commit and delete. This belt-and-suspenders strategy ensures that even in failure scenarios, stale data has a bounded lifetime determined by your TTL.

These patterns assume Redis restarts are rare and brief. The next section examines what happens to your cache during Redis failures and restarts, and how to design for durability when you need it.

Data Durability Trade-offs: What Happens When Redis Restarts

Redis persistence is one of the most misunderstood aspects of cache architecture. Many teams either over-engineer durability for ephemeral data or under-engineer it for critical cached state. Understanding your actual requirements prevents both wasted resources and unexpected data loss.

RDB Snapshots vs AOF: Matching Persistence to Cache Type

Redis offers two persistence mechanisms with fundamentally different trade-offs:

RDB (Redis Database) creates point-in-time snapshots at configured intervals. You lose data between snapshots, but recovery is fast and the performance impact is minimal.

AOF (Append-Only File) logs every write operation. Data loss is limited to the fsync interval, but recovery takes longer and disk I/O increases.

redis.conf
## RDB: Snapshot every 300 seconds if at least 100 keys changed
save 300 100
save 60 10000
## AOF: Sync to disk every second (good balance)
appendonly yes
appendfsync everysec
## Hybrid: Use RDB for fast recovery, AOF for minimal data loss
aof-use-rdb-preamble yes

Calculating Your Acceptable Data Loss Window

Before configuring persistence, calculate the actual cost of cache loss for each data type:

Cache TypeRebuild CostAcceptable LossRecommended Persistence
Session tokensImmediate user impact< 1 secondAOF with everysec
API response cacheRe-fetch from PostgreSQLMinutesRDB every 5 minutes
Computed aggregationsExpensive recalculation< 1 minuteAOF or hybrid
Rate limiting countersSecurity implications< 1 secondAOF with always

For most caches derived from PostgreSQL, the rebuild cost is simply re-querying your database. If your PostgreSQL can handle the thundering herd of cache misses after a restart, ephemeral caching with no persistence is the right choice.

check-rebuild-capacity.sh
## Estimate cache rebuild load on PostgreSQL
redis-cli DBSIZE # Total keys to potentially rebuild
redis-cli INFO stats | grep instantaneous_ops_per_sec # Current request rate
## If DBSIZE * avg_query_time < acceptable_recovery_window, skip persistence

💡 Pro Tip: Run Redis without persistence for 30 days in staging while monitoring PostgreSQL load during restarts. Most teams discover their “critical” caches rebuild faster than expected.

When Ephemeral Is the Right Choice

Persistence adds operational complexity: larger disk requirements, slower restarts, and backup considerations. For pure cache workloads where PostgreSQL remains the source of truth, disable persistence entirely:

redis-cache-only.conf
save ""
appendonly no

This configuration gives you maximum performance and simplest operations. Your cache warms organically as requests flow through the system.

With persistence decisions made, the next challenge is understanding what’s happening inside your cache layer—which brings us to monitoring and failure modes.

Operational Reality: Monitoring and Failure Modes

Adding Redis to your stack means adding operational surface area. The difference between a Redis deployment that hums along quietly and one that pages you at 3 AM comes down to monitoring the right metrics and building resilience into your application layer. This section covers the observability foundation you need and the patterns that keep cache failures from becoming application outages.

Critical Metrics to Watch

Memory fragmentation ratio is your canary. When Redis reports a mem_fragmentation_ratio above 1.5, you’re wasting significant memory to fragmentation—often caused by frequent deletions of variable-sized keys. Below 1.0 means Redis is swapping to disk, and your latency is about to spike dramatically. In production, aim to keep this ratio between 1.0 and 1.4 for optimal performance.

Eviction rate tells you whether your cache is sized appropriately. A steady eviction rate under maxmemory-policy pressure means your working set exceeds available memory. Track evicted_keys over time—sudden spikes indicate traffic pattern changes that warrant investigation. Correlate eviction spikes with deployment events or traffic surges to understand whether you’re dealing with a capacity problem or an application behavior change.

Connection count creeps up silently until it doesn’t. Each connection consumes memory (roughly 10KB per client), and hitting maxclients means rejected connections and cascading failures. Monitor connected_clients against your limit and set alerts at 80% capacity. Connection leaks from application bugs or missing connection pool limits are a common culprit when this metric trends upward over time.

redis_health_check.py
import redis
from dataclasses import dataclass
@dataclass
class RedisHealthMetrics:
fragmentation_ratio: float
eviction_rate: float
connection_utilization: float
is_healthy: bool
def check_redis_health(client: redis.Redis, max_clients: int = 10000) -> RedisHealthMetrics:
info = client.info()
fragmentation = info.get("mem_fragmentation_ratio", 1.0)
evicted = info.get("evicted_keys", 0)
connections = info.get("connected_clients", 0)
is_healthy = (
0.8 < fragmentation < 1.5
and connections < max_clients * 0.8
)
return RedisHealthMetrics(
fragmentation_ratio=fragmentation,
eviction_rate=evicted,
connection_utilization=connections / max_clients,
is_healthy=is_healthy
)

Circuit Breakers: Graceful Degradation

When Redis becomes unavailable, your application needs a plan. The circuit breaker pattern prevents cascade failures by failing fast and falling back to PostgreSQL or serving stale data. Without this protection, a Redis outage causes request threads to block on connection timeouts, exhausting your application’s thread pool and taking down services that don’t even need the cache.

The pattern works in three states: closed (normal operation), open (failing fast without attempting Redis calls), and half-open (periodically testing if Redis has recovered). This approach bounds the blast radius of cache infrastructure failures.

cache_circuit_breaker.py
import time
from enum import Enum
from typing import Optional, Callable, TypeVar
T = TypeVar("T")
class CircuitState(Enum):
CLOSED = "closed" # Normal operation
OPEN = "open" # Failing fast
HALF_OPEN = "half_open" # Testing recovery
class CacheCircuitBreaker:
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 30):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failures = 0
self.state = CircuitState.CLOSED
self.last_failure_time: Optional[float] = None
def call(
self,
cache_fn: Callable[[], T],
fallback_fn: Callable[[], T]
) -> T:
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
return fallback_fn()
try:
result = cache_fn()
if self.state == CircuitState.HALF_OPEN:
self.state = CircuitState.CLOSED
self.failures = 0
return result
except Exception:
self.failures += 1
self.last_failure_time = time.time()
if self.failures >= self.failure_threshold:
self.state = CircuitState.OPEN
return fallback_fn()

💡 Pro Tip: Set your circuit breaker’s recovery timeout based on your Redis restart time plus buffer. Testing recovery too aggressively delays actual recovery by consuming resources during restart. For managed services, 30-60 seconds is typically sufficient; for self-managed Redis, measure your actual failover time.

Avoiding the Distributed Systems Trap

Resist the urge to over-architect. A single Redis instance with a replica handles remarkable throughput—easily 100,000+ operations per second for typical cache workloads. Redis Cluster introduces operational complexity: hash slots, resharding during scaling, cross-slot transaction limitations, and client library compatibility concerns. Each of these represents another potential 3 AM page.

Consider Redis Cluster only when you’ve exhausted vertical scaling (modern instances support 64GB+ RAM), your working set genuinely exceeds single-instance memory, or you need geographic distribution for latency reasons. Most applications never reach these thresholds. Before reaching for Cluster, profile your actual memory usage and operations per second—you may find significant headroom on a single instance.

For most teams, a primary with one replica behind a managed service (ElastiCache, Cloud Memorystore, or Aiven) provides sufficient availability without the cluster management overhead. Managed services handle failover, patching, and backups, letting you focus on application logic rather than infrastructure operations.

The monitoring foundation you build here feeds directly into the decision framework—knowing your actual metrics transforms the “should we add Redis” question from speculation into data-driven analysis.

The Decision Framework: Stay PostgreSQL-Only or Add Redis

After exploring caching patterns, invalidation strategies, and operational considerations, you’re ready to make an informed decision. This framework distills the key factors into actionable checklists.

Signs You Should Optimize PostgreSQL First

Before adding infrastructure complexity, verify you’ve exhausted PostgreSQL’s capabilities:

  • Shared buffers sized below 25% of available RAM. PostgreSQL’s built-in cache deserves proper resources before you look elsewhere.
  • Missing indexes on frequently filtered columns. Query analysis with EXPLAIN ANALYZE reveals sequential scans that indexing eliminates.
  • Connection pooling not implemented. PgBouncer or built-in connection pooling in your application framework often delivers dramatic improvements.
  • No materialized views for complex aggregations. PostgreSQL handles read-heavy dashboards and reports efficiently when you precompute results.
  • Query patterns that benefit from partitioning. Time-series data and large tables with clear partition keys see order-of-magnitude improvements.
  • Autovacuum tuning neglected. Default settings rarely match production workloads, and bloated tables destroy cache efficiency.

If three or more items on this list apply to your system, PostgreSQL optimization delivers better ROI than adding Redis.

Signs Redis Will Genuinely Solve Your Problem

Redis becomes the right choice when your requirements exceed what a relational database handles efficiently:

  • Sub-millisecond response times required. User-facing features like autocomplete, rate limiting, or real-time leaderboards demand Redis’s in-memory speed.
  • Session data accessed on every request. The overhead of PostgreSQL connections for ephemeral session lookups creates unnecessary load.
  • Cache invalidation patterns are simple and well-defined. TTL-based expiration or straightforward key-based invalidation works cleanly in Redis.
  • Your workload is read-heavy with predictable hot spots. A small set of frequently accessed data benefits from dedicated caching infrastructure.
  • Horizontal scaling is on the roadmap. Redis Cluster provides a clear path to distributed caching that PostgreSQL’s shared buffer model doesn’t match.

Total Cost of Ownership

Redis isn’t free caching—it’s a new system to operate. Factor in memory costs (data plus overhead typically doubles raw size), high availability requirements (Redis Sentinel or Cluster), monitoring infrastructure, and team knowledge. A conservative estimate: Redis adds 15-20% to your infrastructure operational burden.

Starting Small: The Session Storage Migration

Begin with session storage. It’s low-risk, immediately measurable, and builds operational experience. Sessions tolerate cache misses gracefully (users simply log in again), making this the ideal proving ground before migrating business-critical caches.

With this framework in hand, you’re equipped to make a decision that fits your specific constraints—not one based on industry hype or premature optimization.

Key Takeaways

  • Run pg_stat_statements analysis before adding Redis—your bottleneck might be query optimization, not caching infrastructure
  • Start with cache-aside pattern and PostgreSQL LISTEN/NOTIFY for invalidation to maintain a single source of truth
  • Configure Redis with no persistence for pure caches, but implement circuit breakers so your app degrades gracefully to PostgreSQL
  • Add Redis for session storage first as a low-risk way to validate the operational overhead before broader caching migration