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.

Intermediate 30 minutes 5 Learning Objectives
  1. Decide whether a workload needs an edge cache, a database cache, or both
  2. Predict how long CloudFront keeps an object given the cache policy TTLs and the origin's Cache-Control headers
  3. Raise a CloudFront cache hit ratio by shrinking the cache key, and choose between invalidation and versioned file names
  4. Compare lazy loading, write-through, and TTL as ElastiCache population strategies and name the failure each one carries
  5. 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

CloudFrontElastiCache
Where it sitsAWS edge locations, near the viewerInside your VPC, near your application
What it storesWhole HTTP responses, keyed by a cache keyWhatever your code puts there, keyed by a string
Who writes to itCloudFront, automatically, on a cache missYour application code, explicitly
What it protectsThe origin and the network path to itThe database
A miss costsOne request to the originOne database query plus one cache write
You tune it withCache policies, TTLs, the cache keyStrategy, 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 policyMin TTLMax TTLDefault TTLCache key contents
CachingOptimized1 s31,536,000 s (365 days)86,400 s (24 h)Nothing but the normalized Accept-Encoding header
CachingDisabled0 s0 s0 sNothing

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=A and ?parameter1=a are two different keys, and so are ?parameter1=a&parameter2=b and ?parameter2=b&parameter1=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 .css file. 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, and CloudFront-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 responseCloudFront caches forWhy
Cache-Control: max-age=1060 sBelow the minimum, so it is raised to the Minimum TTL
Cache-Control: max-age=72007200 sInside the range, so it is honored exactly
Cache-Control: max-age=3153600086400 sAbove the maximum, so it is lowered to the Maximum TTL
No Cache-Control header3600 sNothing was proposed, so the Default TTL applies

Three details around the edges of that table:

  • s-maxage beats max-age at the edge. When the origin sends both, CloudFront clamps s-maxage and the browser uses max-age. That is how you cache an object for an hour at the edge and a minute in the browser.
  • Expires is the weaker option. If the origin sends both Cache-Control: max-age and Expires, CloudFront uses only max-age. AWS recommends max-age.
  • A viewer cannot force a refresh. CloudFront ignores Cache-Control and Pragma in 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=600 lets 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=86400 lets 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.jpg does 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 /images and /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.

MemcachedValkey / Redis OSS (cluster mode disabled)Valkey / Redis OSS (cluster mode enabled)
Data typesSimple strings and objectsComplex (lists, hashes, sets, sorted sets)Complex
Multi-threadedYesNoNo
Replication (read replicas)NoYesYes
Automatic failoverNoOptionalRequired
Data partitioning across shardsYes, client-sideNoYes
Online reshardingNoNoYes
Backup and restoreNode-based clusters: noYesYes

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.

MetricWhat it meansWhat it points you toward
CPUUtilizationHost-level CPU percentageOn small nodes, the workload ceiling
EngineCPUUtilizationUsage of the single engine coreThe real signal on nodes with 4 or more vCPUs
EvictionsKeys removed to make roomNot enough memory for the working set
SwapUsage and FreeableMemoryMemory pressureFreeableMemory under 100 MB, or SwapUsage above FreeableMemory, means the node is in trouble
ReplicationLagHow far a replica trails the primaryReplica reads are returning old data
TrafficManagementActiveValue of 1 means ElastiCache is throttling incoming commandsThe 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-age below Minimum TTL rounds up to the minimum, above Maximum TTL rounds down to the maximum, and Default TTL applies only when the origin sends no max-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 EngineCPUUtilization is 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.