AWS Certified CloudOps Engineer - Associate
Caching with CloudFront and ElastiCache
Where to put a cache so it removes real load: the CloudFront cache key and TTL rules that decide what the edge keeps, and the lazy loading, write-through, and TTL strategies that decide what ElastiCache holds in front of your database.
- Decide whether a workload needs an edge cache, a database cache, or both
- Predict how long CloudFront keeps an object given the cache policy TTLs and the origin's Cache-Control headers
- Raise a CloudFront cache hit ratio by shrinking the cache key, and choose between invalidation and versioned file names
- Compare lazy loading, write-through, and TTL as ElastiCache population strategies and name the failure each one carries
- Read ElastiCache CloudWatch metrics to choose between scaling up, adding replicas, and adding shards
Your catalog page runs one query that takes 300 ms and returns the same 200 rows for every visitor. At 400 requests per second, the database runs that query 400 times a second and burns its CPU producing an answer it already produced. Nothing is wrong with the query. The problem is that you are recomputing a constant, and the fix is to keep the answer somewhere closer than the thing that computed it.
AWS gives you two very different places to keep it, and SOA-C03 Skill 2.1.2 names both: Amazon CloudFront at the edge and Amazon ElastiCache next to your application. They are not interchangeable, and picking the wrong one means adding a service that removes no load.
Two caches, two distances
| CloudFront | ElastiCache | |
|---|---|---|
| Where it sits | AWS edge locations, near the viewer | Inside your VPC, near your application |
| What it stores | Whole HTTP responses, keyed by a cache key | Whatever your code puts there, keyed by a string |
| Who writes to it | CloudFront, automatically, on a cache miss | Your application code, explicitly |
| What it protects | The origin and the network path to it | The database |
| A miss costs | One request to the origin | One database query plus one cache write |
| You tune it with | Cache policies, TTLs, the cache key | Strategy, TTL, node type, shard count |
The line that decides which one you reach for: CloudFront is a cache you configure, ElastiCache is a cache you program. If the same bytes go out to many viewers over HTTP, CloudFront can absorb that traffic without a code change. If the expensive thing is a query result, a session object, or a computed leaderboard that only your application understands, no edge cache can help, because CloudFront cannot see inside the response to know it is reusable.
Large systems run both. CloudFront takes the repeated public responses, ElastiCache takes the repeated internal reads, and the database only sees what genuinely varies.
The CloudFront cache key decides what a "hit" even means
CloudFront stores each object under a cache key. A viewer request is a hit only if it produces the same cache key as an earlier request and that object is still valid at the edge location. Everything about tuning CloudFront caching comes back to one rule: fewer values in the cache key means more hits.
You control the key with a cache policy attached to a cache behavior. The policy says which headers, cookies, and query strings become part of the key, and it carries the TTL settings.
The managed policies are worth memorizing because they mark the two ends of the range:
| Managed cache policy | Min TTL | Max TTL | Default TTL | Cache key contents |
|---|---|---|---|---|
CachingOptimized | 1 s | 31,536,000 s (365 days) | 86,400 s (24 h) | Nothing but the normalized Accept-Encoding header |
CachingDisabled | 0 s | 0 s | 0 s | Nothing |
CachingOptimized is the default answer for static assets. CachingDisabled is the correct answer for anything genuinely per-user, and it works because all three TTLs are 0, not because it forwards nothing.
Three cache-key mistakes cost real hit ratio, and each has a fix:
- Case and order in query strings.
?parameter1=Aand?parameter1=aare two different keys, and so are?parameter1=a¶meter2=band?parameter2=b¶meter1=a. Same object, up to four cache entries. Standardize on one case and one order in the application that builds the URLs. - Forwarding all cookies. For every cookie you forward, CloudFront caches a separate copy per name and value combination. Two cookies with three possible values each is up to 9 copies of the same
.cssfile. Split static and dynamic content into separate cache behaviors and forward cookies only on the dynamic one. - Caching on
User-Agent. It has an enormous number of distinct values, so it effectively disables caching while looking like a working configuration. If you need device-aware responses, cache on the CloudFront device headers instead:CloudFront-Is-Desktop-Viewer,CloudFront-Is-Mobile-Viewer,CloudFront-Is-SmartTV-Viewer, andCloudFront-Is-Tablet-Viewer. Four values, not thousands, and you can forward only the ones that actually change the response.
One more lever sits outside the key. If compression is not in play, attach a custom origin header named Accept-Encoding with a blank value. CloudFront then drops that header from the cache key entirely instead of splitting every object into Gzip, Brotli, and uncompressed variants.
How long an object stays: the origin proposes, the policy decides
This is the part operators get wrong, and it is worth walking slowly.
Three settings live in the cache policy: Minimum TTL, Maximum TTL, and Default TTL. The origin can also send Cache-Control: max-age, Cache-Control: s-maxage, or an Expires header. Neither side wins outright. The origin proposes a duration and the policy clamps it into range.
Work through it with a policy of Minimum TTL 60, Maximum TTL 86400, Default TTL 3600:
| Origin response | CloudFront caches for | Why |
|---|---|---|
Cache-Control: max-age=10 | 60 s | Below the minimum, so it is raised to the Minimum TTL |
Cache-Control: max-age=7200 | 7200 s | Inside the range, so it is honored exactly |
Cache-Control: max-age=31536000 | 86400 s | Above the maximum, so it is lowered to the Maximum TTL |
No Cache-Control header | 3600 s | Nothing was proposed, so the Default TTL applies |
Three details around the edges of that table:
s-maxagebeatsmax-ageat the edge. When the origin sends both, CloudFront clampss-maxageand the browser usesmax-age. That is how you cache an object for an hour at the edge and a minute in the browser.Expiresis the weaker option. If the origin sends bothCache-Control: max-ageandExpires, CloudFront uses onlymax-age. AWS recommendsmax-age.- A viewer cannot force a refresh. CloudFront ignores
Cache-ControlandPragmain viewer requests, so a hard reload in the browser does not clear the edge.
Now the misconception that ships bugs. It is tempting to assume that Cache-Control: no-store from the origin always stops CloudFront from caching. It does not when the cache policy's Minimum TTL is greater than 0. In that case CloudFront caches the object for the minimum TTL even though the origin said not to, which is how a personalized account page gets served to the next viewer that hits the same edge location. Both CachingOptimized (Minimum TTL 1 second) and the Amplify policy (Minimum TTL 2 seconds) carry this warning in the AWS docs. If content must never be cached, the answer is a policy whose minimum TTL is 0.
Serving stale on purpose
Two directives let you trade a little freshness for latency and for surviving an origin outage:
Cache-Control: max-age=3600, stale-while-revalidate=600, stale-if-error=86400
- For the first hour, CloudFront serves from cache normally.
- After that,
stale-while-revalidate=600lets CloudFront hand the viewer the stale copy immediately while it fetches a fresh one in the background, for up to 10 minutes. stale-if-error=86400lets CloudFront keep serving the stale copy for up to 24 hours if the origin is unreachable or returns a 5xx.
Both are capped by the cache policy's Maximum TTL, whichever is less. Past the maximum TTL the object is gone from the edge regardless of what the directives asked for.
Invalidation versus versioned file names
When you need content out of the cache before it expires, you have two options, and AWS recommends the one people reach for second.
Invalidation removes files from edge caches now. The first 1,000 invalidation paths per month are free per AWS account across all distributions, and you pay per path after that. A path with a * wildcard counts as one path no matter how many files it removes, so /* is a single billable unit. The wildcard must be the last character; an asterisk anywhere else is matched literally.
Two invalidation traps that waste a deployment:
- Query strings are part of the path. If the cache key includes query strings,
/images/logo.jpgdoes not invalidate/images/logo.jpg?v=2. Use/images/logo.jpg*. - Directory paths need both forms. If your URLs are not consistent about trailing slashes, invalidate
/imagesand/images/.
Versioned file names mean shipping app.a3f91c.js instead of overwriting app.js. AWS recommends this as the primary approach for content that changes often, and the reason is not just cost. An invalidation clears CloudFront but does nothing about the copy sitting in the viewer's browser or a corporate proxy; a new file name changes the URL, so every cache in the chain misses. It also makes rollback trivial and makes access logs readable, because the log line names the version that was served.
Origin Shield is the other structural lever. It puts one more caching layer in front of the origin so that all CloudFront layers, edge locations and regional edge caches, funnel through a single location. The origin can then serve one request per object instead of one per regional cache.
ElastiCache: the cache you have to program
CloudFront populates itself. ElastiCache does not: your code decides what goes in, when, and for how long. AWS documents three strategies, and the exam tests the failure mode of each rather than the definition.
Lazy loading writes to the cache only after a miss.
get_customer(customer_id)
customer_record = cache.get(customer_id)
if (customer_record == null)
customer_record = db.query("SELECT * FROM Customers WHERE id = {0}", customer_id)
cache.set(customer_id, customer_record)
return customer_record
Only requested data ever enters the cache, and a node failure is survivable: a fresh empty node still returns correct answers, just slower, while misses refill it. The costs are a three-trip miss penalty (read the cache, query the database, write the cache) and stale data, because nothing updates the cache when the database changes underneath it.
Write-through writes to the cache on every database write.
save_customer(customer_id, values)
customer_record = db.query("UPDATE Customers WHERE id = {0}", customer_id, values)
cache.set(customer_id, customer_record)
return success
Cached data is never stale, and the extra latency lands on writes, where users tolerate it better. The costs are the mirror image of lazy loading: missing data on any new node, because a fresh node holds nothing until the corresponding rows are written again, and cache churn, because you are caching writes that may never be read.
Adding a TTL to every write is what makes the pair work together. A TTL caps how stale a lazily loaded entry can get and evicts write-through entries that nobody reads. The production pattern is all three: write-through for correctness, lazy loading for resilience, TTL to bound both.
cache.set(customer_id, customer_record, 300) # expires in 5 minutes
TTL never guarantees freshness. It guarantees a ceiling on staleness, which is a different and much more achievable promise. Say so in the design review rather than letting "we cache for 5 minutes" be heard as "the cache is correct".
Choosing the engine, and why it changes your scaling options
ElastiCache supports Memcached, Valkey, and Redis OSS. The engine you pick determines which scaling actions even exist, so this is not a preference question.
| Memcached | Valkey / Redis OSS (cluster mode disabled) | Valkey / Redis OSS (cluster mode enabled) | |
|---|---|---|---|
| Data types | Simple strings and objects | Complex (lists, hashes, sets, sorted sets) | Complex |
| Multi-threaded | Yes | No | No |
| Replication (read replicas) | No | Yes | Yes |
| Automatic failover | No | Optional | Required |
| Data partitioning across shards | Yes, client-side | No | Yes |
| Online resharding | No | No | Yes |
| Backup and restore | Node-based clusters: no | Yes | Yes |
Choose Memcached when you want the simplest possible model, large multi-core nodes, and plain object caching. Choose Valkey or Redis OSS when you need replication, failover, persistence, sorted sets, or pub/sub. The practical consequence: a Memcached cluster cannot be fixed by adding replicas, because it has none. Its only levers are a larger node type or more nodes.
Reading the metrics, then choosing the scaling action
ElastiCache scaling questions are usually diagnostic. A metric tells you which resource is short, and the resource plus the engine tells you the action.
| Metric | What it means | What it points you toward |
|---|---|---|
CPUUtilization | Host-level CPU percentage | On small nodes, the workload ceiling |
EngineCPUUtilization | Usage of the single engine core | The real signal on nodes with 4 or more vCPUs |
Evictions | Keys removed to make room | Not enough memory for the working set |
SwapUsage and FreeableMemory | Memory pressure | FreeableMemory under 100 MB, or SwapUsage above FreeableMemory, means the node is in trouble |
ReplicationLag | How far a replica trails the primary | Replica reads are returning old data |
TrafficManagementActive | Value of 1 means ElastiCache is throttling incoming commands | The node is underscaled for the workload |
The CPU threshold catches people out, so work it. Valkey and Redis OSS run the engine on one thread, so a node can be completely saturated while host CPU reads far below 100 percent. AWS gives the arithmetic: set the threshold at 90 divided by the number of cores. On a 2-core node that is 45 percent. A cluster sitting at 46 percent CPU with climbing latency is not healthy and idle, it is pinned. On node types with 4 or more vCPUs, use EngineCPUUtilization instead and the division disappears. Memcached is multi-threaded, so its threshold really is around 90 percent.
Once you know the resource, the action follows the engine and the workload:
- Read-heavy and over threshold, Valkey or Redis OSS: add read replicas.
- Write-heavy, cluster mode disabled: scale up to a larger node type. There is only one primary, so more writes need a bigger primary.
- Write-heavy, cluster mode enabled: add shards, which spreads writes across more primary nodes.
- Any pressure, Memcached: larger node type, or more nodes.
How the scaling actually happens
For cluster mode enabled, online resharding changes the shard count while the cluster keeps serving requests. Adding shards raises read and write capacity, removing them lowers cost, and rebalancing evens out the keyspace across existing shards. Three limits to know: new shards get the same node count as the smallest existing shard, you cannot set per-shard keyspaces online (that requires the offline backup-and-restore path), and keys holding items larger than 256 MB after serialization are not migrated, which can leave shards unbalanced. Before removing shards, ElastiCache checks that the remaining shards can hold the data and cancels the operation rather than losing keys.
Vertical scaling by node type is also an online operation for Valkey and Redis OSS. Offline resharding, the backup-and-restore path, is the one that goes dark, and you accept that downtime only when you need the things it uniquely allows: changing node type, engine version, per-shard replica counts, and keyspaces in a single move.
ElastiCache Serverless removes the shard decision. It tracks CPU, memory, and network continuously and adds shards on its own, and you watch two metrics instead of a node list: BytesUsedForCache for storage and ElastiCacheProcessingUnits (ECPUs) for compute. You can cap both to bound cost, but understand what a cap does at the edge: hitting the storage maximum makes ElastiCache evict TTL-bearing keys by LRU and then return out-of-memory errors, and hitting the ECPU maximum makes it throttle requests. AWS recommends a CloudWatch alarm at 75 percent of whatever maximum you set, so you find out before your users do.
Exam tips
- "Same response to many viewers over HTTP" is CloudFront. "Expensive query result reused by the application" is ElastiCache. A stem that describes a database under read pressure is not asking about the edge.
- Given TTL numbers, do the clamp:
max-agebelow Minimum TTL rounds up to the minimum, above Maximum TTL rounds down to the maximum, and Default TTL applies only when the origin sends nomax-age. Wrong answers pick whichever number was mentioned last. - "The origin sends no-store but stale content is still served" points at a cache policy with a Minimum TTL greater than 0, not at a CloudFront defect.
- 1,000 free invalidation paths per month per account, and a wildcard path counts as one. If the stem stresses frequent updates, the intended answer is usually versioned file names, not more invalidations.
- Any option that raises the cache hit ratio by adding headers, cookies, or query strings to the cache key is wrong. Hits come from a smaller key.
- Lazy loading allows stale data and survives empty nodes. Write-through is always fresh and fails on empty nodes. TTL is what makes running both viable. Match the symptom to the strategy, not the definition.
- Valkey and Redis OSS are single-threaded: the CPU alarm threshold is 90 divided by the core count, and
EngineCPUUtilizationis the cleaner signal on larger nodes. - Memcached has no replicas, no failover, and no cluster mode. Any answer offering those for a Memcached cluster is wrong on the engine boundary alone.
- Read-heavy means replicas, write-heavy on cluster mode disabled means a bigger node, write-heavy on cluster mode enabled means more shards.
The rule to carry out of this lesson: name the thing that is repeating before you name the service. Repeated HTTP responses belong at the edge, repeated query results belong in memory next to the application, and anything that genuinely varies per request belongs nowhere but the database. That last category is the one caching cannot rescue, and it is where the next lesson starts, on scaling the relational database itself.
