There is a moment every engineering team hits at some point. The application is growing. Traffic is up. Users are happy. Then the database starts groaning under the load and suddenly every request is slow, timeouts are creeping in, and your RDS bill is doubling every month.
The instinct is to throw more compute at the problem. Bigger instances, more read replicas. That buys time but it does not fix the root issue, which is that your application is asking the database for the same data over and over again, thousands of times a minute, for data that almost never changes between requests.
Caching is the answer. Not caching as an afterthought, but caching as a deliberate architectural decision with clear patterns, proper invalidation strategies, and observability built in from the start.
Amazon ElastiCache for Redis is what I reach for on AWS. It is fully managed, supports clustering for horizontal scale, has built-in replication, and gives you the full Redis data structure ecosystem. In this article I will walk you through the caching patterns that hold up in real production systems, with working code and the configuration decisions that actually matter.
Setting Up ElastiCache with Terraform
Everything in production belongs in code. Here is a proper ElastiCache setup with cluster mode enabled for horizontal scaling:
resource "aws_elasticache_replication_group" "main" { replication_group_id = "app-cache" description = "Application Redis cache" node_type = "cache.r7g.large" port = 6379 parameter_group_name = aws_elasticache_parameter_group.redis7.name subnet_group_name = aws_elasticache_subnet_group.main.name security_group_ids = [aws_security_group.elasticache.id] automatic_failover_enabled = true multi_az_enabled = true num_cache_clusters = 2 at_rest_encryption_enabled = true transit_encryption_enabled = true auth_token = var.redis_auth_token engine_version = "7.1" maintenance_window = "sun:05:00-sun:06:00" snapshot_retention_limit = 5 snapshot_window = "03:00-04:00" log_delivery_configuration { destination = aws_cloudwatch_log_group.redis_slow_log.name destination_type = "cloudwatch-logs" log_format = "json" log_type = "slow-log" } log_delivery_configuration { destination = aws_cloudwatch_log_group.redis_engine_log.name destination_type = "cloudwatch-logs" log_format = "json" log_type = "engine-log" }}resource "aws_elasticache_parameter_group" "redis7" { family = "redis7" name = "app-redis7-params" parameter { name = "maxmemory-policy" value = "allkeys-lru" } parameter { name = "slowlog-log-slower-than" value = "10000" } parameter { name = "timeout" value = "300" }}resource "aws_elasticache_subnet_group" "main" { name = "app-cache-subnet-group" subnet_ids = var.private_subnet_ids}resource "aws_security_group" "elasticache" { name = "elasticache-sg" vpc_id = var.vpc_id ingress { from_port = 6379 to_port = 6379 protocol = "tcp" security_groups = [var.application_sg_id] }}
A few configuration decisions worth calling out here.
The maxmemory-policy is set to allkeys-lru. This means when memory fills up, Redis will evict the least recently used keys across the entire keyspace. For a general application cache this is almost always the right setting. If you switch it to noeviction, Redis will start returning errors when memory is full, which is almost never what you want in a cache.
Transit encryption and auth token are both enabled. ElastiCache lives inside your VPC, but defense in depth matters. Enabling both adds negligible latency and protects you from misconfigurations that expose the cluster unexpectedly.
Slow log delivery to CloudWatch is enabled. Any command taking longer than 10 milliseconds gets logged. This is how you catch runaway KEYS commands, missing indexes on Redis search operations, and large value retrievals before they become incidents.
Connecting from Your Application
A proper Redis client setup in Python with connection pooling:
import redisimport boto3import jsonimport hashlibfrom typing import Any, Optionalfrom functools import wrapsclass RedisClient: _instance = None def __init__(self): ssm = boto3.client("ssm", region_name="us-east-1") endpoint = ssm.get_parameter( Name="/app/redis/endpoint", WithDecryption=False )["Parameter"]["Value"] auth_token = ssm.get_parameter( Name="/app/redis/auth_token", WithDecryption=True )["Parameter"]["Value"] self.client = redis.Redis( host=endpoint, port=6379, password=auth_token, ssl=True, ssl_cert_reqs=None, decode_responses=True, socket_connect_timeout=2, socket_timeout=2, retry_on_timeout=True, health_check_interval=30, connection_pool=redis.ConnectionPool( host=endpoint, port=6379, password=auth_token, ssl=True, decode_responses=True, max_connections=20 ) ) @classmethod def get_instance(cls): if cls._instance is None: cls._instance = cls() return cls._instance def get(self, key: str) -> Optional[str]: try: return self.client.get(key) except redis.RedisError as e: print(f"Redis GET error for key {key}: {e}") return None def set(self, key: str, value: str, ttl: int = 300) -> bool: try: return self.client.setex(key, ttl, value) except redis.RedisError as e: print(f"Redis SET error for key {key}: {e}") return False def delete(self, key: str) -> bool: try: self.client.delete(key) return True except redis.RedisError as e: print(f"Redis DELETE error for key {key}: {e}") return Falseredis_client = RedisClient.get_instance()
The socket_connect_timeout and socket_timeout values of 2 seconds are intentional. If your cache is unavailable, you want to fail fast and fall through to the database rather than hanging. The application should degrade gracefully when the cache is down, not grind to a halt.
Pulling credentials from SSM Parameter Store rather than environment variables keeps secrets out of your container definitions and gives you rotation capability without redeployment.
Cache-Aside: The Pattern You Will Use Most
Cache-aside, sometimes called lazy loading, is the most common pattern and the right default for most database query caching.
The application checks the cache first. If the data is there, it returns it. If not, it queries the database, stores the result in cache, and returns it. The cache only holds data that has actually been requested.
def get_product(product_id: str) -> Optional[dict]: cache_key = f"product:{product_id}" cached = redis_client.get(cache_key) if cached: return json.loads(cached) product = db.query( "SELECT * FROM products WHERE id = %s", (product_id,) ) if product: redis_client.set(cache_key, json.dumps(product), ttl=600) return productdef update_product(product_id: str, updates: dict) -> dict: updated = db.execute( "UPDATE products SET name=%s, price=%s WHERE id=%s RETURNING *", (updates["name"], updates["price"], product_id) ) redis_client.delete(f"product:{product_id}") return updated
The TTL of 600 seconds means even if you miss an invalidation, the cache corrects itself within 10 minutes. Always set a TTL. A cache without TTLs is a slow memory leak waiting to cause an incident.
Delete on update rather than updating the cache value. Writing a new value into cache on update creates a race condition: two requests can update the database simultaneously, write different values to cache, and leave the cache in an incorrect state. Delete forces the next request to do a fresh read.
Write-Through: Keeping Cache and Database in Sync
Write-through caching updates the cache at the same time as the database. It is the right choice when cache misses are expensive, when you need strong consistency, or when your data is written frequently and read immediately after writes.
def save_user_session(user_id: str, session_data: dict) -> dict: session_data["updated_at"] = datetime.utcnow().isoformat() db.execute( """ INSERT INTO user_sessions (user_id, data, updated_at) VALUES (%s, %s, %s) ON CONFLICT (user_id) DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at """, (user_id, json.dumps(session_data), session_data["updated_at"]) ) redis_client.set( f"session:{user_id}", json.dumps(session_data), ttl=1800 ) return session_datadef get_user_session(user_id: str) -> Optional[dict]: cached = redis_client.get(f"session:{user_id}") if cached: return json.loads(cached) session = db.query( "SELECT data FROM user_sessions WHERE user_id = %s", (user_id,) ) if session: redis_client.set( f"session:{user_id}", json.dumps(session["data"]), ttl=1800 ) return session["data"] if session else None
The tradeoff with write-through is that you write to cache on every update, even for data that may never be read. For write-heavy, read-light access patterns, this wastes memory. Use it selectively.
Rate Limiting with Redis
This is one of those use cases where Redis shines in ways a database never could. Building a rate limiter in PostgreSQL means locking rows and doing updates under contention. In Redis, the INCR command is atomic and takes microseconds.
def check_rate_limit( user_id: str, action: str, limit: int, window_seconds: int) -> tuple[bool, int]: window_start = int(time.time() // window_seconds) * window_seconds cache_key = f"ratelimit:{action}:{user_id}:{window_start}" pipe = redis_client.client.pipeline(transaction=True) pipe.incr(cache_key) pipe.expire(cache_key, window_seconds) results = pipe.execute() current_count = results[0] allowed = current_count <= limit remaining = max(0, limit - current_count) return allowed, remainingdef rate_limited_api_handler(user_id: str, request_data: dict): allowed, remaining = check_rate_limit( user_id=user_id, action="api_call", limit=100, window_seconds=60 ) if not allowed: raise RateLimitExceededError( f"Rate limit exceeded. Limit resets in {60} seconds." ) return process_request(request_data)
Using a pipeline here is important. The INCR and EXPIRE need to happen as close together as possible. If the process crashes between them, the key has no TTL and lives forever. Wrapping them in a pipeline sends both commands in a single round trip and executes them back to back on the server.
The window key includes the window start time, not the current time. This creates fixed windows rather than sliding windows, which means a user could make 100 requests at the end of one window and 100 requests at the start of the next, effectively doubling the rate for a brief period. For most use cases this is acceptable. If you need true sliding windows, implement it with a sorted set using timestamps as scores.
Distributed Locking
When multiple instances of your application need to perform an operation that should only run once at a time, a distributed lock is the answer. Processing a payment, sending a notification, running a scheduled job.
import uuiddef acquire_lock( lock_name: str, timeout_seconds: int = 30) -> Optional[str]: lock_key = f"lock:{lock_name}" lock_token = str(uuid.uuid4()) acquired = redis_client.client.set( lock_key, lock_token, nx=True, ex=timeout_seconds ) if acquired: return lock_token return Nonedef release_lock(lock_name: str, lock_token: str) -> bool: lock_key = f"lock:{lock_name}" lua_script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ result = redis_client.client.eval(lua_script, 1, lock_key, lock_token) return result == 1def process_payment_once(payment_id: str, payment_data: dict): lock_name = f"payment:{payment_id}" lock_token = acquire_lock(lock_name, timeout_seconds=60) if not lock_token: raise PaymentAlreadyProcessingError( f"Payment {payment_id} is already being processed" ) try: result = charge_customer(payment_data) update_order_status(payment_id, "completed") return result finally: release_lock(lock_name, lock_token)
he Lua script for releasing the lock is not optional. Without it you have a race condition: thread A checks that the lock value matches its token, then thread B’s lock expires and thread C acquires a new lock, then thread A deletes the key that now belongs to thread C. The Lua script executes atomically on the Redis server, making the check and delete a single indivisible operation.
The lock timeout must be longer than the maximum time your operation can take. If your payment processing takes 45 seconds in the worst case, a 30-second timeout will cause the lock to expire while the operation is still running, allowing another instance to start processing the same payment.
Observability: What to Watch in Production
CloudWatch gives you the core ElastiCache metrics automatically. The ones that actually matter are these.
CacheHitRate is the single most important metric. It tells you what percentage of requests are being served from cache. Below 80 percent means your cache is not helping much. Below 60 percent means something is wrong, either your TTLs are too short, your key design is inconsistent, or your traffic patterns changed.
EngineCPUUtilization above 80 percent means you have hot keys or expensive commands running. The slow log will tell you which commands are causing the problem.
DatabaseMemoryUsagePercentage above 80 percent means you are approaching eviction territory. Either increase your node size or audit which data you are caching.
CurrConnections helps you detect connection leaks. If this climbs steadily without a corresponding traffic increase, you have application code that is opening connections and not closing them.
A simple CloudWatch alarm for cache hit rate:
resource "aws_cloudwatch_metric_alarm" "cache_hit_rate_low" { alarm_name = "elasticache-low-hit-rate" comparison_operator = "LessThanThreshold" evaluation_periods = 3 metric_name = "CacheHitRate" namespace = "AWS/ElastiCache" period = 300 statistic = "Average" threshold = 80 alarm_description = "ElastiCache hit rate has dropped below 80%" alarm_actions = [aws_sns_topic.alerts.arn] dimensions = { ReplicationGroupId = aws_elasticache_replication_group.main.id }}
Key Design: The Part That Scales
The way you name your cache keys determines how well your cache holds up as your application grows. A few rules that will save you problems later.
Use structured namespaces with colons as separators. product:12345, user:session:67890, ratelimit:api:user:11111. This makes it easy to understand what each key holds and makes bulk deletions by pattern possible.
Never use the KEYS command in production. It blocks the entire Redis server while it scans the keyspace. If you need to find keys by pattern, use SCAN with a cursor. If you need to delete all keys matching a pattern, run the SCAN in a loop and delete in batches.
Include a version prefix when you make breaking changes to your cache schema. v2:product:12345 versus v1:product:12345. This lets you roll out schema changes without a cache flush that hammers your database.
Leave a comment