A FinOps review on a mid-sized SaaS bill shows a cache.r6g.4xlarge running with BytesUsedForCache averaging 18 GB on a node with 105 GB usable. The team picked that size two years ago “to leave headroom” and never revisited. The same review shows a second cluster on cache.m6g.xlarge (13 GB) that is constantly at 11 GB and evicting under memory pressure — the wrong family entirely. After both clusters are right-sized and a one-year reserved instance is applied to the surviving nodes, ElastiCache spend drops by roughly half. Nothing changed about the application. The right-sizing was sitting in the metrics the whole time.
What’s happening
ElastiCache pricing is per node-hour, by instance type. Three node families dominate practical use, and they target different workloads.
cache.t4g.* is the burstable family. Small base capacity, CPU credits that accumulate during low load and burn during bursts. Right for dev, staging, and very-low-traffic production workloads (a small SaaS that does maybe 100 cache ops/sec). Wrong for any cluster where the working set actually pushes the node hard — a t4g under sustained load runs out of CPU credits and is silently throttled, which surfaces as latency spikes nobody can explain.
cache.m6g.* is the general-purpose family. Balanced CPU and memory. Right for clusters where workload is mostly read-heavy or moderately memory-bound and CPU and memory pressure track together. The m6g.large (6.38 GB) through m6g.16xlarge (217.55 GB) range covers most general workloads.
cache.r6g.* is the memory-optimized family. Same CPU as m6g for an equivalent size, but ~2x the memory. Right for clusters that are memory-bound — the working set is large, the access pattern is mostly hash lookups by ID, and you do not need to push commands at the CPU’s limit. r6g.large has 13.07 GB; r6g.xlarge has 26.32 GB; r6g.4xlarge has 105.81 GB.
For nearly every memory-heavy production Redis workload, r6g is right. The mistake is using m6g because “general-purpose” sounds like a safe default, and finding that the cluster is constantly bumping the memory ceiling while CPU sits idle. Picking r6g over m6g of equivalent dollar size buys you 2x the memory for the same CPU budget.
The other lever is reserved instances. On-demand ElastiCache pricing is the default. Reserved instances commit to a node type for 1 or 3 years and discount the rate roughly 30–40% (1-year, no upfront) up to 60% (3-year, all upfront). The economics:
- No commitment → on-demand. Right for clusters that may go away or change type within months.
- 1-year, no upfront → ~30% discount. Right for steady-state production clusters where you are confident in the node type and size but not certain about 3 years from now. Break-even versus on-demand is roughly 8 months.
- 1-year, partial upfront → slightly better discount. Same use case as no-upfront, slightly more capital cost.
- 3-year, all upfront → ~60% discount. Right only for very steady, long-lived clusters where you are absolutely confident the node type will not change. The all-upfront capital is locked for 3 years.
For a typical SaaS, the right pattern is 1-year, no upfront on the steady-state nodes. Three-year reservations are usually only worth it for a core production cluster that you will absolutely still need at that scale in 36 months.
The pattern called “the second-most-expensive line is an oversized Redis”: after EC2, the next-largest line on many SaaS AWS bills is ElastiCache. And nine times out of ten when we audit it, the cluster has been sized for a working set 3–5x larger than it actually holds. The right-size is a single API call. The savings are immediate and recurring.
How to verify
Three measurements drive the cost decisions.
Current memory usage versus node capacity. For each cluster, pull the maximum BytesUsedForCache over the last 30 days and compare to the node’s maxmemory.
aws cloudwatch get-metric-statistics \
--namespace AWS/ElastiCache \
--metric-name BytesUsedForCache \
--dimensions Name=CacheClusterId,Value=saas-prod-001 \
--start-time $(date -u -v-30d +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics Maximum \
--query 'Datapoints[].Maximum' \
--output text | sort -n | tail -1
A cache.r6g.4xlarge has 105.81 GB usable. If the 30-day max is 18 GB, that node is sized for ~17% of capacity. The right size is somewhere between r6g.large (13 GB — slightly tight) and r6g.xlarge (26 GB — comfortable). Resize one step down and re-measure.
The same measurement also surfaces the opposite problem. If the 30-day max is consistently at 90%+ of maxmemory, the cluster is evicting. Check the eviction metric to confirm:
aws cloudwatch get-metric-statistics \
--namespace AWS/ElastiCache \
--metric-name Evictions \
--dimensions Name=CacheClusterId,Value=saas-prod-001 \
--start-time $(date -u -v-7d +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics Sum \
--query 'Datapoints[?Sum > `0`]' \
--output text
Sustained evictions point to undersize. The cure is one size up, in the same family.
CPU utilization on each node. ElastiCache exposes CPUUtilization and EngineCPUUtilization. The first is the EC2-level CPU; the second is the single-threaded Redis main process. Redis is largely single-threaded for command execution, so EngineCPUUtilization is the more meaningful metric.
aws cloudwatch get-metric-statistics \
--namespace AWS/ElastiCache \
--metric-name EngineCPUUtilization \
--dimensions Name=CacheClusterId,Value=saas-prod-001 \
--start-time $(date -u -v-14d +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 60 \
--extended-statistics p95 \
--query 'Datapoints[].ExtendedStatistics.p95' \
--output text | sort -n | tail -1
If EngineCPUUtilization p95 is below 30% and the cluster is in the m6g family, you can switch to r6g of the same size class and gain memory headroom for the same dollar. If p95 is above 70%, you are CPU-bound and need to scale CPU — usually by going to a larger size, or in extreme cases by sharding via cluster mode.
Current versus committed pricing. AWS Cost Explorer’s right-sizing recommendations include ElastiCache reserved-instance suggestions. Filter for ElastiCache and look at the recommended commitment.
aws ce get-reservation-purchase-recommendation \
--service "AmazonElastiCache" \
--term-in-years ONE_YEAR \
--payment-option NO_UPFRONT \
--query 'Recommendations[].RecommendationDetails[].[InstanceDetails.NodeType,EstimatedMonthlySavingsPercentage]' \
--output table
The recommendations are based on actual usage over the past 14–30 days. They are conservative — AWS only recommends a reservation when usage is high and consistent enough to clearly break even. Even so, treat them as starting points: confirm the recommended node type matches what you actually want long-term before committing.
The fix
The cost-tuning sequence that works.
Step 1: family correction. For each cluster, decide if it is in the right family. The rule: EngineCPUUtilization p95 below 50% and memory pressure present → r6g. Both CPU and memory headroom present → consider m6g to save on memory you do not need. Dev/staging/very-low-traffic → t4g if and only if you have measured that bursting is not sustained.
Moving family requires a cluster recreation (no in-place family change). The pattern is:
aws elasticache create-replication-group \
--replication-group-id saas-prod-v2 \
--replication-group-description "Family migration" \
--cache-node-type cache.r6g.xlarge \
--num-cache-clusters 2 \
--automatic-failover-enabled \
--engine redis \
--engine-version 7.1 \
--cache-subnet-group-name saas-prod \
--security-group-ids sg-0123456789abcdef0
Cut over the application with a brief warm-up window, verify, delete the old cluster.
Step 2: size correction. Within the right family, pick the size where the 30-day memory max lands at 50–70% of usable. This leaves headroom for growth and burst, without paying for half-empty nodes.
Size changes within a family can be done in place with modify-replication-group:
aws elasticache modify-replication-group \
--replication-group-id saas-prod \
--cache-node-type cache.r6g.xlarge \
--apply-immediately
The modify triggers a rolling node replacement. Each node is replaced one at a time, with a brief failover when the primary is replaced. The whole sequence on a 2-node cluster is 20–40 minutes.
Step 3: reservations on the steady-state. Once the cluster has been right-sized and is stable for a month, apply a 1-year no-upfront reservation. The Cost Explorer recommendation API surfaces the candidates. Buy the reservation matched to the actual node type and quantity:
aws elasticache purchase-reserved-cache-nodes-offering \
--reserved-cache-nodes-offering-id <offering-id> \
--reserved-cache-node-id saas-prod-r6g-xlarge-1yr \
--cache-node-count 2
The offering ID maps to a specific node type and term. Pull the matching offering with describe-reserved-cache-nodes-offerings filtered by node type, term, and offering type.
A subtle point: reservations are by node type and region, not by cluster. If you have two clusters both running cache.r6g.xlarge in the same region, four reservations of that node type cover both clusters’ nodes — the reservation is at the account level. This means you can reorganize clusters under existing reservations as long as the node-type total matches.
Step 4: working-set audit. The cheapest node is the one you do not run. Some clusters are sized for caches that are mostly stale data — keys set with no TTL, ancient session entries from inactive users, computed responses that nobody queries anymore. Setting realistic TTLs and enabling an eviction policy shrinks the working set.
Check the eviction policy in the parameter group:
aws elasticache describe-cache-parameter-groups \
--query 'CacheParameterGroups[].[CacheParameterGroupName]' \
--output text
aws elasticache describe-cache-parameters \
--cache-parameter-group-name saas-prod \
--query 'Parameters[?ParameterName==`maxmemory-policy`]' \
--output table
For a cache (not a session store), allkeys-lru is usually right — when memory fills, evict the least-recently-used keys. For a session store, volatile-lru is right — only evict keys that have TTLs, so persistent state is not lost. The wrong default (noeviction) makes the cluster refuse new writes when memory fills — which surfaces as application errors, not as a quiet cache miss.
The three levers — family, size, and reservation — compound. A cluster moved from m6g.4xlarge on-demand to r6g.xlarge reserved 1-year can drop its line-item cost by 60–70% while increasing usable memory. The work fits in an afternoon per cluster and is permanent.
Stack Harbor designs Redis topologies as part of cluster-tier work in Managed AWS and the HA Clusters operating layer. If ElastiCache is your second-largest AWS line and nobody can remember why it was sized that way, start with an infrastructure consult.