On most architecture diagrams a cache is one box between a service and its database. That box hides the questions that decide whether the cache helps or hurts: who puts data into it, who writes to the database, and what a reader sees in between.
Four strategies answer those questions differently: cache-aside, read-through, write-through and write-back. This guide walks through each one with the same example, compares them, covers the failures that show up in production, and ends with a diagram you can reuse.
Two questions every cache answers
Every caching strategy is a pair of answers:
- Who loads data into the cache on a read? Either your application code or a cache layer that knows how to reach the database.
- Who writes to the database, and when? Your application, the cache layer in the same request, or the cache layer later.
| Strategy | Read: who fills the cache | Write: who writes the database |
|---|---|---|
| Cache-aside | The application, after a miss | The application, then it invalidates the key |
| Read-through | The cache layer, after a miss | Not defined by the strategy; usually paired with one of the next two |
| Write-through | Usually combined with read-through or cache-aside | The cache layer, in the same request |
| Write-back (write-behind) | Usually combined with read-through | The cache layer, later and often in batches |
The running example is a product API with a Postgres products table and a Redis cache. A product page reads product:42, and an admin tool updates prices.
Cache-aside (lazy loading)
The application talks to both stores. It asks the cache first; on a miss it reads the database and puts the result into the cache with a time to live (TTL). AWS's ElastiCache documentation describes the same pattern as lazy loading.
def get_product(product_id):
key = f"product:{product_id}"
cached = redis.get(key)
if cached is not None:
return json.loads(cached) # hit
row = db.fetch_one("SELECT * FROM products WHERE id = %s", product_id)
if row is not None:
redis.set(key, json.dumps(row), ex=300) # fill, with a 300 s TTL
return row
def update_price(product_id, price):
db.execute("UPDATE products SET price = %s WHERE id = %s", price, product_id)
redis.delete(f"product:{product_id}") # invalidate, don't overwrite
On writes, delete the key instead of writing the new value into the cache. The next read loads the current row. Microsoft's cache-aside pattern describes the same write rule.
What you get: only data someone asked for is cached, and if the cache is down, reads still work by going to the database, just slower. What you pay: every miss costs three round trips (cache, database, cache), and there is a race. A reader that missed can read the old row, the writer updates the row and deletes the key, and then the reader writes the old value back. It stays stale until the TTL ends, which is one reason cache-aside always needs a TTL.
Read-through
The application only calls the cache. On a miss, the cache layer loads the value from the database through a loader you configure, stores it and returns it. The read result is the same as cache-aside, but the loading code lives in one place instead of in every caller.
A plain Redis server does not read from your database on its own. Read-through comes from a layer that can: a caching library with a loader function, a proxy, or a managed cache built for one database. Amazon DynamoDB Accelerator (DAX) is an example: on a cache miss it passes the read to DynamoDB, returns the result and caches it.
The trade-off: the cache layer is now on the read path. If it fails, reads fail unless the client has a fallback to the database.
Write-through
The application writes to the cache layer, and the layer writes the database in the same operation. The application gets a success only when both writes succeed. DAX documents exactly this: data is written to the DynamoDB table first, then to the cache, and the call succeeds only if both writes do.
What you get: data written through the cache is current in the cache, so a read right after a write sees it. What you pay: every write waits for two stores, and the cache fills with data that is written but never read. Add a TTL so that data expires. A new, empty cache node also has nothing until data is written again, which is why write-through is usually combined with read-through or lazy loading.
Write-back (write-behind)
The application writes to the cache and gets a success right away. The cache layer writes to the database later, often in batches, and repeated updates to the same key collapse into one database write.
What you get: the lowest write latency and far fewer database writes during bursts. What you pay: anything not yet flushed is lost if the cache node fails, unless the buffer is replicated or backed by a durable log. The database is also behind the cache, so reports, other services and backups see older data. Write-back fits view counters and similar data where losing a few seconds is acceptable.
Side by side
| Cache-aside | Read-through | Write-through | Write-back | |
|---|---|---|---|---|
| App code knows the database | Yes | No | No | No |
| Write latency | Database write plus a cache delete | Depends on the write strategy | Cache and database | Cache only |
| Stale reads | Until invalidation or TTL | Until invalidation or TTL | Rare for writes that go through the cache | The database lags the cache |
| Cache node lost | Slower reads | Reads fail without a fallback | Missing data until rewritten | Unflushed writes are lost |
| Typical fit | The default with Redis | A library or managed cache | Read-after-write freshness | Write-heavy counters and bursts |
TTLs, eviction and three classic failures
A TTL bounds how stale a value can get. Pick it per kind of data from the staleness the product accepts: a product description can live for minutes, a stock level maybe seconds. When an expired key is read, it behaves as if it were not there.
Eviction is a separate mechanism: what the cache removes when it runs out of memory. In Redis, maxmemory sets the limit and maxmemory-policy picks what to remove. The default in redis.conf is noeviction, which returns errors on commands that add data once memory is full. For a pure cache, the Redis documentation suggests allkeys-lru as a good default. Managed services set their own defaults, so check yours. Measure the hit ratio from INFO stats as keyspace_hits / (keyspace_hits + keyspace_misses).
Three failures show up in almost every cache:
- Stampede. A hot key expires and hundreds of requests miss at the same moment, all querying the database. Let one request rebuild the value and make the others wait briefly or serve the old value. A Redis lock does this:
SET lock:product:42 <token> NX PX 5000succeeds for one caller only. Facebook's memcache paper solves the same problem with leases and reports that, for keys prone to it, the peak database query rate fell from 17K/s to 1.3K/s. - Penetration. Requests for IDs that do not exist always miss and always hit the database. Cache the "not found" result with a short TTL, or check a Bloom filter of valid IDs first.
- Avalanche. Many keys expire together, for example after a bulk load or a cache restart, and the database takes the full load at once. Add random jitter to TTLs, such as 300 seconds plus up to 60, and warm the cache gradually.
The diagram
Four rows with the same three participants, so the difference is in the edges: who talks to the database, and when.
Diagram contents
- Product API
- Redis
- Postgres
- Cache layer
- 1 GET (miss)
- 2 SELECT
- 3 SET, TTL 300 s
- 1 read
- 2 on miss: SELECT
- 1 write
- 2 UPDATE, same request
- 1 write, OK at once
- 2 flush later, batched
How to read it:
- Row 1, cache-aside. The product API reads Redis (1), reads Postgres itself on a miss (2) and fills Redis with a TTL (3). The edge to Postgres bypasses the cache: the application owns both calls.
- Row 2, read-through. The API only talks to the cache layer, which loads from Postgres on a miss.
- Row 3, write-through. The write goes through the cache layer to Postgres in the same request.
- Row 4, write-back. The API gets its success at once. The dashed edge is the later, batched flush.
How to draw caches on architecture diagrams
- Name the strategy and the TTL. "Cache-aside, TTL 300 s" on the cache node or its edge answers the first question a reviewer asks.
- Draw who talks to the database. In cache-aside, the service has an edge to the database as well as to the cache. A cache drawn inline in front of the database suggests read-through, and readers will assume it.
- Draw the invalidation path. Show who deletes keys, including batch jobs and admin tools that write the database directly. A writer with no path to the cache is where stale data comes from.
- Write down the failure behavior. "Cache down: reads go to Postgres" tells the on-call engineer more than a second cache icon.
- Put the numbers on the canvas. Hit-ratio target, memory limit and eviction policy fit in a small note next to the cache.
- Dash anything asynchronous. A write-back flush or an invalidation event is not part of the request.
Choosing
Start with cache-aside and a TTL. It works with any cache and any database, and it degrades to slower reads when the cache fails. Move to read-through when a library or managed cache gives you a loader. Add write-through when reads right after writes must see the new value and writes can afford the extra latency. Use write-back only when you can lose or replay unflushed writes.
For the picture around the cache, see the system design diagram example, where Redis serves the read path of a URL shortener, and the microservices diagram rules for showing which service owns which data.
Build it in Archboard
- Sign in with Google or GitHub and create a diagram.
- For each strategy, add a Core Text title, then put three nodes in a row: a C4 Model Container for the product API, a Data pack Redis for the cache and a Data pack Postgres for the database. Label the cache "Cache layer" in rows 2 to 4.
- Connect each row with numbered edge labels: "1 GET (miss)", "2 SELECT" and "3 SET, TTL 300 s" for cache-aside, and the read or write steps for the other rows. Route the cache-aside edge from the API to Postgres over the cache.
- Set the write-back flush edge to dashed.
- Add a Core Text note under each row with the write rule and what happens when the cache fails.
The example uses 12 diagram nodes, within the Free plan. For the general method behind any architecture diagram, see how to draw a software architecture diagram.
FAQ
Is cache-aside the same as lazy loading? Yes. Both names describe the application loading data into the cache only after a miss. AWS documentation uses "lazy loading"; many other sources say "cache-aside".
Should I update or delete the cache key when data changes? Delete it in cache-aside. Two writers that each put their new value into the cache can finish in the wrong order and leave an old value behind. A delete followed by a fresh load avoids that, and the TTL limits the rarer race described above.
Can Redis do read-through or write-through by itself? No. A Redis server stores what clients send it. Read-through and write-through need a layer that knows your database: a caching library, a proxy, or a managed cache built for one database.
What TTL should I use? The longest staleness the product accepts for that kind of data. Then add jitter so that keys loaded together do not expire together.
Is write-back safe? Only if losing or replaying unflushed writes is acceptable, or if the write buffer is replicated or backed by a durable log. Avoid it for data such as payments or orders.