System Design (High-Level Design) Module 7 – Caching
Cache-aside, read-through, write-through, write-back
How cache-aside, read-through, write-through and write-back move data between a cache and its database, compared side by side, with cache-aside in code.
What you will learn
- Implement cache-aside with a TTL and a delete after every write
- Compare read-through, write-through, write-back and refresh-ahead by who fills the cache and what is at risk
- Work out the writes a write-back cache can lose and the hit ratio a database needs
- Choose a strategy from a workload's freshness and durability needs
Before you start
What you’ll learn · Before you start
What you will learn
- Implement cache-aside with a TTL and a delete after every write
- Compare read-through, write-through, write-back and refresh-ahead by who fills the cache and what is at risk
- Work out the writes a write-back cache can lose and the hit ratio a database needs
- Choose a strategy from a workload's freshness and durability needs
Before you start
On this page
The four names say who copies data between a cache and its database, and when. With cache-aside, the application reads the cache, and on a miss it reads the database and stores the result in the cache; after a write it updates the database and deletes the cached copy. With read-through, the cache loads a missing value from the database by itself. With write-through, a write goes to the cache and the database before it is acknowledged. With write-back, also called write-behind, the cache acknowledges a write at once and copies it to the database later. Two of them shape the read path and two the write path, so real systems combine them. Choose with two questions: how fresh must a read be after a write, and how much data may you lose if the cache fails?
A strategy answers two questions: who fills a miss, and what a write does
A cache only helps while it is both useful and correct. It is useful when reads find what they need in it, which depends on who fills it after a miss and how soon. It is correct when the copy it holds still matches the data, which depends on what happens to that copy after every write. A strategy is an answer to both: a rule for the read path and a rule for the write path.
The answers have costs that show up elsewhere. A strategy that fills the cache on writes makes writes slower. One that delays writes to the database makes them fast and puts acknowledged data at risk. One that keeps the logic in the application gives the application a way around a failed cache; one that hides the database behind the cache does not. Each panel below is one strategy; follow its arrows to see who reads and writes the database:
The four strategies: who talks to the database
Text description of the diagram
Four panels stand in two rows of two. Each has the same three boxes from top to bottom: the app, the cache and the database.
- Cache-aside: the app talks to both. It gets from the cache, reads the database on a miss and then sets the value in the cache. Every write goes from the app to the database, and the app then deletes the key from the cache.
- Read-through: the app sends every read to the cache, and the cache itself loads a missing value from the database.
- Write-through: the app sends every write to the cache, and the cache writes it to the database before it acknowledges the write.
- Write-back: the app sends every write to the cache, which acknowledges it at once and writes it to the database later, in batches.
Checkpoint
Check: Two questions every strategy answers
The four strategies differ in who loads a miss and when a write returns
Cache-aside keeps all the logic in the application, which talks to the cache and the database separately. It is the usual default because a failed cache only makes the application slower: it can still read the database. The Azure cache-aside pattern also notes that it does not guarantee consistency, because anything that changes the database without deleting the cached copy leaves that copy stale.
Read-through moves the loading into the cache layer, often a library or a caching product, so every caller gets the same behaviour without writing it. A read-through cache needs its own access to the database, and when the cache is down the callers have no other path to the data.
Write-through sends each write through the cache, which stores it in the database before the write returns. Reads right after a write find the new value, and a write-through write takes as long as both writes together: the Oracle Coherence documentation says plainly that it does not improve write performance at all.
Write-back acknowledges a write as soon as the cache has it and copies it to the database after a delay, combining several changes to one key into one database write. Writes are fast, and the cache becomes the only holder of recent writes until they are copied, so a cache failure can lose them.
| Criterion | Cache-aside | Read-through |
|---|---|---|
| Who loads a miss | The application | The cache |
| Where the logic lives | In every caller | In one cache layer |
| Writes | Database, then delete the copy | Usually the same |
| Cache down | Reads go to the database | Reads fail |
| When to choose | Read-heavy data, seconds of staleness allowed | Many services that share one loading rule |
| Criterion | Write-through | Write-back |
|---|---|---|
| Write returns | After the database has it | When the cache has it |
| Reads just after a write | The new value | The new value, from the cache |
| Lost if the cache dies | Nothing | Writes not yet copied |
| Cache down | Writes fail | Reads and writes fail |
| When to choose | Reads that must see the last write; moderate write rates | Write rates the database cannot take; small losses allowed |
A fifth name, refresh-ahead, describes a cache that reloads popular entries shortly before they expire, so readers rarely wait for a miss. It costs database reads for entries nobody reads again.
Checkpoint
Check: The four strategies compared
Cache-aside in code: write the database, then delete the copy
The two rules of cache-aside fit in a few lines. This program reads prices through a cache with a 60-second TTL (cache entry): Time to live: how long a cache may serve an entry after storing it. When it runs out the next read is a miss, so even a copy that a missed delete left behind disappears., changes one price, and uses a fake clock so that every run prints the same trace:
Python 3.14 · main.py
TTL_MS = 60_000
database = {"p1": 499} # product id -> price
cache: dict[str, tuple[int, int]] = {} # product id -> (price, expires at)
def get_price(product: str, now: int) -> tuple[str, str]:
entry = cache.get(product)
if entry is not None and entry[1] > now:
return str(entry[0]), "hit"
price = database.get(product)
if price is None:
return "none", "miss: no such product, nothing cached"
cache[product] = (price, now + TTL_MS)
if entry is not None:
return str(price), "miss: expired, read again and cached"
return str(price), "miss: read the database, cached for 60 s"
def set_price(product: str, price: int) -> tuple[str, str]:
database[product] = price # 1. the database first: the source of truth
cache.pop(product, None) # 2. then delete the copy: the next read reloads
return "", "database updated, then the copy deleted"
steps = [
(0, "p1", None),
(10, "p1", None),
(20, "p9", None),
(30, "p1", 449),
(40, "p1", None),
(50, "p1", None),
(60_100, "p1", None),
]
for ms, product, new_price in steps:
if new_price is None:
label, (value, how) = f"get {product}", get_price(product, ms)
else:
label, (value, how) = (
f"set {product} to {new_price}",
set_price(product, new_price),
)
print(f"{ms:>6} ms {label:<14} {value:>4} {how}") In Python: cache.pop(product, None) deletes the cached copy and does nothing when there is none, so a write never fails on a key that was never cached.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const TTL_MS = 60_000;
const database = new Map([["p1", 499]]); // product id -> price
const cache = new Map(); // product id -> { price, expiresAt }
function getPrice(product, now) {
const entry = cache.get(product);
if (entry !== undefined && entry.expiresAt > now) return [String(entry.price), "hit"];
const price = database.get(product);
if (price === undefined) return ["none", "miss: no such product, nothing cached"];
cache.set(product, { price, expiresAt: now + TTL_MS });
if (entry !== undefined) {
return [String(price), "miss: expired, read again and cached"];
}
return [String(price), "miss: read the database, cached for 60 s"];
}
function setPrice(product, price) {
database.set(product, price); // 1. the database first: the source of truth
cache.delete(product); // 2. then delete the copy: the next read reloads
return ["", "database updated, then the copy deleted"];
}
const steps = [
[0, "p1"],
[10, "p1"],
[20, "p9"],
[30, "p1", 449],
[40, "p1"],
[50, "p1"],
[60_100, "p1"],
];
for (const [ms, product, newPrice] of steps) {
const write = newPrice !== undefined;
const label = write ? `set ${product} to ${newPrice}` : `get ${product}`;
const [value, how] = write ? setPrice(product, newPrice) : getPrice(product, ms);
console.log(`${String(ms).padStart(6)} ms ${label.padEnd(14)} ${value.padStart(4)} ${how}`);
} In JavaScript: A Map's get returns undefined for a missing key, which is how both the cache and the stand-in database say that they hold nothing.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class Main {
static final int TTL_MS = 60_000;
static final Map<String, Integer> database = new HashMap<>(Map.of("p1", 499)); // id -> price
static final Map<String, Entry> cache = new HashMap<>(); // product id -> entry
record Entry(int price, int expiresAt) {}
record Step(int ms, String product, Integer newPrice) {}
record Outcome(String value, String how) {}
static Outcome getPrice(String product, int now) {
Entry entry = cache.get(product);
if (entry != null && entry.expiresAt() > now) {
return new Outcome(String.valueOf(entry.price()), "hit");
}
Integer price = database.get(product);
if (price == null) {
return new Outcome("none", "miss: no such product, nothing cached");
}
cache.put(product, new Entry(price, now + TTL_MS));
String how =
entry != null
? "miss: expired, read again and cached"
: "miss: read the database, cached for 60 s";
return new Outcome(String.valueOf(price), how);
}
static Outcome setPrice(String product, int price) {
database.put(product, price); // 1. the database first: the source of truth
cache.remove(product); // 2. then delete the copy: the next read reloads
return new Outcome("", "database updated, then the copy deleted");
}
public static void main(String[] args) {
Locale.setDefault(Locale.ROOT);
List<Step> steps =
List.of(
new Step(0, "p1", null),
new Step(10, "p1", null),
new Step(20, "p9", null),
new Step(30, "p1", 449),
new Step(40, "p1", null),
new Step(50, "p1", null),
new Step(60_100, "p1", null));
for (Step step : steps) {
String product = step.product();
Integer price = step.newPrice();
String label = price != null ? "set " + product + " to " + price : "get " + product;
Outcome out = price != null ? setPrice(product, price) : getPrice(product, step.ms());
System.out.printf("%6d ms %-14s %4s %s%n", step.ms(), label, out.value(), out.how());
}
}
} In Java: Map.get returns null for a missing product, so the price is an Integer rather than an int; a step's newPrice is an Integer too, and null means a read.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <format>
#include <optional>
#include <print>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
constexpr int ttl_ms = 60'000;
struct Entry {
int price;
int expires_at;
};
// product id -> price, and product id -> entry
std::unordered_map<std::string, int> database{{"p1", 499}};
std::unordered_map<std::string, Entry> cache;
std::pair<std::string, std::string> get_price(const std::string &product,
int now) {
const auto cached = cache.find(product);
if (cached != cache.end() && cached->second.expires_at > now) {
return {std::to_string(cached->second.price), "hit"};
}
const bool expired = cached != cache.end();
const auto row = database.find(product);
if (row == database.end()) {
return {"none", "miss: no such product, nothing cached"};
}
const int price = row->second;
cache[product] = {price, now + ttl_ms};
return {std::to_string(price),
expired ? "miss: expired, read again and cached"
: "miss: read the database, cached for 60 s"};
}
std::pair<std::string, std::string> set_price(const std::string &product,
int price) {
database[product] = price; // 1. the database first: the source of truth
cache.erase(product); // 2. then delete the copy: the next read reloads
return {"", "database updated, then the copy deleted"};
}
struct Step {
int ms;
std::string product;
std::optional<int> new_price;
};
int main() {
const std::vector<Step> steps = {
{0, "p1", {}}, {10, "p1", {}}, {20, "p9", {}}, {30, "p1", 449},
{40, "p1", {}}, {50, "p1", {}}, {60'100, "p1", {}},
};
for (const auto &[ms, product, new_price] : steps) {
const std::string label =
new_price ? std::format("set {} to {}", product, *new_price)
: "get " + product;
const auto [value, how] =
new_price ? set_price(product, *new_price) : get_price(product, ms);
std::println("{:>6} ms {:<14} {:>4} {}", ms, label, value, how);
}
} In C++: The miss is noted as expired before cache[product] inserts the new entry, because an insertion may rehash the map and leave the earlier iterator invalid.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
const int TtlMs = 60_000;
var database = new Dictionary<string, int> { ["p1"] = 499 }; // product id -> price
var cache = new Dictionary<string, (int Price, int ExpiresAt)>(); // product id -> entry
(int Ms, string Product, int? NewPrice)[] steps =
[
(0, "p1", null),
(10, "p1", null),
(20, "p9", null),
(30, "p1", 449),
(40, "p1", null),
(50, "p1", null),
(60_100, "p1", null),
];
foreach (var (ms, product, newPrice) in steps)
{
var (label, (value, how)) = newPrice is int price
? ($"set {product} to {price}", SetPrice(product, price))
: ($"get {product}", GetPrice(product, ms));
Console.WriteLine($"{ms,6} ms {label,-14} {value,4} {how}");
}
(string, string) GetPrice(string product, int now)
{
bool cached = cache.TryGetValue(product, out var entry);
if (cached && entry.ExpiresAt > now)
{
return ($"{entry.Price}", "hit");
}
if (!database.TryGetValue(product, out int price))
{
return ("none", "miss: no such product, nothing cached");
}
cache[product] = (price, now + TtlMs);
return cached
? ($"{price}", "miss: expired, read again and cached")
: ($"{price}", "miss: read the database, cached for 60 s");
}
(string, string) SetPrice(string product, int price)
{
database[product] = price; // 1. the database first: the source of truth
cache.Remove(product); // 2. then delete the copy: the next read reloads
return ("", "database updated, then the copy deleted");
} In C#: TryGetValue reads a key and says whether it was there in one call, and newPrice is int price tells a write from a read and takes its value.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
#include <string.h>
enum { TTL_MS = 60000 };
typedef struct {
const char *product; // NULL when empty
int price, expires_at;
} Row;
static Row database = {"p1", 499, 0}; // one product id -> price
static Row cache; // one slot: product id -> price and expiry
static Row *holding(Row *row, const char *product) {
return row->product != NULL && strcmp(row->product, product) == 0 ? row
: NULL;
}
static const char *get_price(const char *product, int now, char *value) {
const Row *entry = holding(&cache, product);
if (entry != NULL && entry->expires_at > now) {
snprintf(value, 16, "%d", entry->price);
return "hit";
}
const Row *row = holding(&database, product);
if (row == NULL) {
snprintf(value, 16, "none");
return "miss: no such product, nothing cached";
}
cache = (Row){row->product, row->price, now + TTL_MS};
snprintf(value, 16, "%d", row->price);
return entry != NULL ? "miss: expired, read again and cached"
: "miss: read the database, cached for 60 s";
}
static const char *set_price(const char *product, int price) {
Row *row = holding(&database, product);
if (row != NULL) {
row->price = price; // 1. the database first: the source of truth
}
if (holding(&cache, product) != NULL) {
cache.product = NULL; // 2. then delete the copy: the next read reloads
}
return "database updated, then the copy deleted";
}
int main(void) {
// A new price of 0 means a read.
static const struct {
int ms;
const char *product;
int new_price;
} steps[] = {{0, "p1", 0}, {10, "p1", 0}, {20, "p9", 0}, {30, "p1", 449},
{40, "p1", 0}, {50, "p1", 0}, {60100, "p1", 0}};
for (size_t i = 0; i < sizeof steps / sizeof steps[0]; i++) {
char label[32], value[16] = "";
const char *how;
if (steps[i].new_price > 0) {
snprintf(label, sizeof label, "set %s to %d", steps[i].product,
steps[i].new_price);
how = set_price(steps[i].product, steps[i].new_price);
} else {
snprintf(label, sizeof label, "get %s", steps[i].product);
how = get_price(steps[i].product, steps[i].ms, value);
}
printf("%6d ms %-14s %4s %s\n", steps[i].ms, label, value, how);
}
return 0;
} In C: C's standard library has no hash map, so the database is one row and the cache one slot, enough for the one product this program caches; a delete empties the slot.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
const TTL_MS = 60_000;
$database = ['p1' => 499]; // product id -> price
$cache = []; // product id -> [price, expires at]
/**
* @param array<string, int> $database
* @param array<string, array{int, int}> $cache
* @return array{string, string}
*/
function getPrice(array $database, array &$cache, string $product, int $now): array
{
$entry = $cache[$product] ?? null;
if ($entry !== null && $entry[1] > $now) {
return [(string) $entry[0], 'hit'];
}
$price = $database[$product] ?? null;
if ($price === null) {
return ['none', 'miss: no such product, nothing cached'];
}
$cache[$product] = [$price, $now + TTL_MS];
return [(string) $price, $entry !== null
? 'miss: expired, read again and cached'
: 'miss: read the database, cached for 60 s'];
}
/**
* @param array<string, int> $database
* @param array<string, array{int, int}> $cache
* @return array{string, string}
*/
function setPrice(array &$database, array &$cache, string $product, int $price): array
{
$database[$product] = $price; // 1. the database first: the source of truth
unset($cache[$product]); // 2. then delete the copy: the next read reloads
return ['', 'database updated, then the copy deleted'];
}
$steps = [[0, 'p1', null], [10, 'p1', null], [20, 'p9', null], [30, 'p1', 449],
[40, 'p1', null], [50, 'p1', null], [60_100, 'p1', null]];
foreach ($steps as [$ms, $product, $newPrice]) {
[$label, [$value, $how]] = $newPrice === null
? ["get {$product}", getPrice($database, $cache, $product, $ms)]
: ["set {$product} to {$newPrice}", setPrice($database, $cache, $product, $newPrice)];
printf("%6d ms %-14s %4s %s\n", $ms, $label, $value, $how);
} In PHP: PHP copies an array passed by value, so the functions take the cache as &$cache to change the caller's; $cache[$product] ?? null reads a missing key without a warning.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::collections::HashMap;
const TTL_MS: u32 = 60_000;
/// Prices read through a cache-aside cache, in front of a stand-in database.
struct Store {
database: HashMap<&'static str, u32>, // product id -> price
cache: HashMap<&'static str, (u32, u32)>, // product id -> (price, expires at)
}
impl Store {
fn get_price(&mut self, product: &'static str, now: u32) -> (String, &'static str) {
let cached = self.cache.get(product).copied();
if let Some((price, _)) = cached.filter(|&(_, expires_at)| expires_at > now) {
return (price.to_string(), "hit");
}
let Some(&price) = self.database.get(product) else {
return ("none".into(), "miss: no such product, nothing cached");
};
self.cache.insert(product, (price, now + TTL_MS));
let how = match cached {
Some(_) => "miss: expired, read again and cached",
None => "miss: read the database, cached for 60 s",
};
(price.to_string(), how)
}
fn set_price(&mut self, product: &'static str, price: u32) -> (String, &'static str) {
self.database.insert(product, price); // 1. the database first: the source of truth
self.cache.remove(product); // 2. then delete the copy: the next read reloads
(String::new(), "database updated, then the copy deleted")
}
}
fn main() {
let database = HashMap::from([("p1", 499)]);
let mut store = Store {
database,
cache: HashMap::new(),
};
let steps = [
(0, "p1", None),
(10, "p1", None),
(20, "p9", None),
(30, "p1", Some(449)),
(40, "p1", None),
(50, "p1", None),
(60_100, "p1", None),
];
for (ms, product, new_price) in steps {
let (label, (value, how)) = match new_price {
Some(price) => (
format!("set {product} to {price}"),
store.set_price(product, price),
),
None => (format!("get {product}"), store.get_price(product, ms)),
};
println!("{ms:>6} ms {label:<14} {value:>4} {how}");
}
} In Rust: copied() takes the cached pair out of the map, so the method can insert a new entry while it still knows whether the old one had expired; let-else returns early for a missing product.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
const TTL_MS = 60_000;
const database = new Map<string, number>([["p1", 499]]); // product id -> price
const cache = new Map<string, { price: number; expiresAt: number }>(); // product id -> entry
type Outcome = [value: string, how: string];
function getPrice(product: string, now: number): Outcome {
const entry = cache.get(product);
if (entry !== undefined && entry.expiresAt > now) return [String(entry.price), "hit"];
const price = database.get(product);
if (price === undefined) return ["none", "miss: no such product, nothing cached"];
cache.set(product, { price, expiresAt: now + TTL_MS });
if (entry !== undefined) {
return [String(price), "miss: expired, read again and cached"];
}
return [String(price), "miss: read the database, cached for 60 s"];
}
function setPrice(product: string, price: number): Outcome {
database.set(product, price); // 1. the database first: the source of truth
cache.delete(product); // 2. then delete the copy: the next read reloads
return ["", "database updated, then the copy deleted"];
}
const steps: [ms: number, product: string, newPrice?: number][] = [
[0, "p1"],
[10, "p1"],
[20, "p9"],
[30, "p1", 449],
[40, "p1"],
[50, "p1"],
[60_100, "p1"],
];
for (const [ms, product, newPrice] of steps) {
const label = newPrice === undefined ? `get ${product}` : `set ${product} to ${newPrice}`;
const [value, how] = newPrice === undefined ? getPrice(product, ms) : setPrice(product, newPrice);
console.log(`${String(ms).padStart(6)} ms ${label.padEnd(14)} ${value.padStart(4)} ${how}`);
} In TypeScript: Each step is a tuple whose third element is optional, newPrice?: number, so a read simply leaves it out and the compiler checks every use.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import "fmt"
const ttlMs = 60_000
type entry struct{ price, expiresAt int }
var (
database = map[string]int{"p1": 499} // product id -> price
cache = map[string]entry{} // product id -> entry
)
func getPrice(product string, now int) (string, string) {
e, cached := cache[product]
if cached && e.expiresAt > now {
return fmt.Sprint(e.price), "hit"
}
price, found := database[product]
if !found {
return "none", "miss: no such product, nothing cached"
}
cache[product] = entry{price, now + ttlMs}
if cached {
return fmt.Sprint(price), "miss: expired, read again and cached"
}
return fmt.Sprint(price), "miss: read the database, cached for 60 s"
}
func setPrice(product string, price int) (string, string) {
database[product] = price // 1. the database first: the source of truth
delete(cache, product) // 2. then delete the copy: the next read reloads
return "", "database updated, then the copy deleted"
}
func main() {
// A new price of 0 means a read.
steps := []struct {
ms int
product string
newPrice int
}{
{0, "p1", 0}, {10, "p1", 0}, {20, "p9", 0}, {30, "p1", 449},
{40, "p1", 0}, {50, "p1", 0}, {60_100, "p1", 0},
}
for _, s := range steps {
label, value, how := "get "+s.product, "", ""
if s.newPrice > 0 {
label = fmt.Sprintf("set %s to %d", s.product, s.newPrice)
value, how = setPrice(s.product, s.newPrice)
} else {
value, how = getPrice(s.product, s.ms)
}
fmt.Printf("%6d ms %-14s %4s %s\n", s.ms, label, value, how)
}
} In Go: Go has no optional values, so a new price of 0, the zero value, marks a read; a map lookup's second result says whether the key was there.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
let ttlMs = 60_000
var database = ["p1": 499] // product id -> price
var cache: [String: (price: Int, expiresAt: Int)] = [:] // product id -> entry
func getPrice(_ product: String, now: Int) -> (value: String, how: String) {
let entry = cache[product]
if let entry, entry.expiresAt > now {
return (String(entry.price), "hit")
}
guard let price = database[product] else {
return ("none", "miss: no such product, nothing cached")
}
cache[product] = (price, now + ttlMs)
if entry != nil {
return (String(price), "miss: expired, read again and cached")
}
return (String(price), "miss: read the database, cached for 60 s")
}
func setPrice(_ product: String, to price: Int) -> (value: String, how: String) {
database[product] = price // 1. the database first: the source of truth
cache[product] = nil // 2. then delete the copy: the next read reloads
return ("", "database updated, then the copy deleted")
}
func padded(_ text: String, _ width: Int, left: Bool) -> String {
let fill = String(repeating: " ", count: max(0, width - text.count))
return left ? fill + text : text + fill
}
let steps: [(ms: Int, product: String, newPrice: Int?)] = [
(0, "p1", nil), (10, "p1", nil), (20, "p9", nil), (30, "p1", 449),
(40, "p1", nil), (50, "p1", nil), (60_100, "p1", nil),
]
for (ms, product, newPrice) in steps {
let label = newPrice.map { "set \(product) to \($0)" } ?? "get \(product)"
let out = newPrice.map { setPrice(product, to: $0) } ?? getPrice(product, now: ms)
let time = padded(String(ms), 6, left: true)
print(
"\(time) ms \(padded(label, 14, left: false)) \(padded(out.value, 4, left: true)) \(out.how)")
} In Swift: newPrice.map { … } ?? … chooses the write or the read in one expression, and setting a dictionary key to nil deletes the cached copy.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# Prices read through a cache-aside cache, in front of a stand-in database.
class PriceStore
TTL_MS = 60_000
EXPIRED = 'miss: expired, read again and cached'
LOADED = 'miss: read the database, cached for 60 s'
def initialize
@database = { 'p1' => 499 } # product id -> price
@cache = {} # product id -> [price, expires at]
end
def get_price(product, now)
price, expires_at = @cache[product]
return [price.to_s, 'hit'] if expires_at && expires_at > now
fresh = @database[product]
return ['none', 'miss: no such product, nothing cached'] if fresh.nil?
@cache[product] = [fresh, now + TTL_MS]
[fresh.to_s, price ? EXPIRED : LOADED]
end
def set_price(product, price)
@database[product] = price # 1. the database first: the source of truth
@cache.delete(product) # 2. then delete the copy: the next read reloads
['', 'database updated, then the copy deleted']
end
end
store = PriceStore.new
steps = [[0, 'p1'], [10, 'p1'], [20, 'p9'], [30, 'p1', 449], [40, 'p1'], [50, 'p1'], [60_100, 'p1']]
steps.each do |ms, product, new_price|
label = new_price ? "set #{product} to #{new_price}" : "get #{product}"
value, how = new_price ? store.set_price(product, new_price) : store.get_price(product, ms)
puts "#{ms.to_s.rjust(6)} ms #{label.ljust(14)} #{value.rjust(4)} #{how}"
end In Ruby: Hash#delete removes the cached copy and returns nil when there is none, and price, expires_at = @cache[product] unpacks a missing entry into two nils.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
const val TTL_MS = 60_000
val database = mutableMapOf("p1" to 499) // product id -> price
val cache = mutableMapOf<String, Pair<Int, Int>>() // product id -> (price, expires at)
data class Step(
val ms: Int,
val product: String,
val newPrice: Int? = null,
)
fun getPrice(
product: String,
now: Int,
): Pair<String, String> {
val entry = cache[product]
if (entry != null && entry.second > now) return entry.first.toString() to "hit"
val price = database[product] ?: return "none" to "miss: no such product, nothing cached"
cache[product] = price to now + TTL_MS
if (entry != null) return price.toString() to "miss: expired, read again and cached"
return price.toString() to "miss: read the database, cached for 60 s"
}
fun setPrice(
product: String,
price: Int,
): Pair<String, String> {
database[product] = price // 1. the database first: the source of truth
cache.remove(product) // 2. then delete the copy: the next read reloads
return "" to "database updated, then the copy deleted"
}
fun main() {
val steps =
listOf(
Step(0, "p1"),
Step(10, "p1"),
Step(20, "p9"),
Step(30, "p1", 449),
Step(40, "p1"),
Step(50, "p1"),
Step(60_100, "p1"),
)
for ((ms, product, newPrice) in steps) {
val label = if (newPrice == null) "get $product" else "set $product to $newPrice"
val (value, how) =
if (newPrice == null) getPrice(product, ms) else setPrice(product, newPrice)
println("${ms.toString().padStart(6)} ms ${label.padEnd(14)} ${value.padStart(4)} $how")
}
} In Kotlin: The elvis operator, ?: return, leaves getPrice early when the database has no such product, and Step is a data class whose newPrice defaults to null, a read.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
const ttlMs = 60000;
final database = {'p1': 499}; // product id -> price
final cache = <String, ({int price, int expiresAt})>{}; // product id -> entry
(String, String) getPrice(String product, int now) {
final entry = cache[product];
if (entry != null && entry.expiresAt > now) return ('${entry.price}', 'hit');
final price = database[product];
if (price == null) return ('none', 'miss: no such product, nothing cached');
cache[product] = (price: price, expiresAt: now + ttlMs);
return entry != null
? ('$price', 'miss: expired, read again and cached')
: ('$price', 'miss: read the database, cached for 60 s');
}
(String, String) setPrice(String product, int price) {
database[product] = price; // 1. the database first: the source of truth
cache.remove(product); // 2. then delete the copy: the next read reloads
return ('', 'database updated, then the copy deleted');
}
void main() {
const steps = <(int, String, int?)>[
(0, 'p1', null),
(10, 'p1', null),
(20, 'p9', null),
(30, 'p1', 449),
(40, 'p1', null),
(50, 'p1', null),
(60100, 'p1', null),
];
for (final (ms, product, newPrice) in steps) {
final label = newPrice == null
? 'get $product'
: 'set $product to $newPrice';
final (value, how) = newPrice == null
? getPrice(product, ms)
: setPrice(product, newPrice);
print(
'${'$ms'.padLeft(6)} ms ${label.padRight(14)} ${value.padLeft(4)} $how',
);
}
} In Dart: Each operation returns a record of two strings, and final (value, how) = … takes the record apart in one declaration.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
0 ms get p1 499 miss: read the database, cached for 60 s
10 ms get p1 499 hit
20 ms get p9 none miss: no such product, nothing cached
30 ms set p1 to 449 database updated, then the copy deleted
40 ms get p1 449 miss: read the database, cached for 60 s
50 ms get p1 449 hit
60100 ms get p1 449 miss: expired, read again and cached
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 67 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 154 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 30 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 719 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 191 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 17 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Step through the same seven steps as messages between the app, the cache and the database:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- 0 ms: get p1. The app asks the cache for p1's price first. The cache is empty, so this read is a miss.
- Read the database. On a miss the app reads the database itself. The database returns 499.
- Store it with a TTL. The app stores 499 in the cache with a TTL of 60 seconds, so the copy expires at 60,000 ms, and returns the price.
- 10 ms: a hit. The next read finds p1 in the cache, a hit. The database does no work for it.
- 20 ms: p9 does not exist. p9 misses the cache, and the database has no such product, so the app caches nothing. Caching an absence would be a separate decision with its own lifetime.
- 30 ms: write the database. The write of a new price, 449, goes to the database first, because the database is the source of truth.
- Then delete the copy. Then the app deletes the cached copy instead of writing the new price into it. The cache holds nothing for p1 now.
- 40 ms: the next read misses. The read after the write misses, because the copy is gone, and the app reads 449 from the database.
- Store the new price. It stores 449 with a fresh 60-second TTL, until 60,040 ms. The read at 50 ms is a hit on this copy.
- 60,100 ms: the TTL has run out. Sixty seconds after the copy was stored it has expired. Without any write, the read misses, reloads 449 from the database and caches it again.
Two details are easy to get wrong. The write changes the database first and deletes the cached copy second: the database is the source of truth: The one store whose copy of a fact is authoritative. Every other store that holds the fact copies it from there and can be rebuilt from it. More in “Choosing a data model: relational to vector”, and the Azure pattern warns that the opposite order leaves a window in which a reader can put the old value back. And the read of p9, which does not exist, caches nothing; caching an absence is a separate decision with its own lifetime. The TTL is the safety net: even a copy that a missed delete leaves behind disappears within a minute.
Checkpoint
Check: Cache-aside's two rules in code
Practise: write get and put for a cache-aside store
Write the two rules yourself. The sample tests check a hit, the TTL, the delete after a write and a product the database does not have:
Exercise · Easy · JavaScript
Implement get and put for a cache-aside store
Write the two methods of createStore(db, ttlMs, clock) in store.mjs, a cache-aside store in front of a database.
dbhasget(key), which returns the stored value orundefinedwhen there is none, andput(key, value).ttlMsis how long an entry may be served from the cache, in milliseconds.clock()returns the current time in milliseconds. Use it for every time you need, so the tests can move time on.cacheis the store'sMapof entries,key → { value, expiresAt }. The tests look inside it.
get(key) returns the value. If the cache holds an entry for key and clock() is still before its expiresAt, it answers from the cache without touching the database. Otherwise it reads the database once, keeps the value with expiresAt set to clock() + ttlMs, and returns it. A key the database does not have returns undefined and is not cached.
put(key, value) writes the database first and then deletes the cached entry, so the next get loads the new value. It never writes the value into the cache itself.
Starter code · store.mjs
/** A cache-aside store in front of `db`: entries are served from the cache for `ttlMs` milliseconds of `clock()`. */
export function createStore(db, ttlMs, clock) {
const cache = new Map(); // key -> { value, expiresAt }
return {
cache,
get(key) {
// Replace this line with your code.
return undefined;
},
put(key, value) {
// Replace this line with your code.
},
};
} The sample tests · store.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { createStore } from './store.mjs';
/** A stand-in database that counts its reads and writes. */
function fakeDb(rows) {
const data = new Map(Object.entries(rows));
const db = {
reads: 0,
writes: 0,
get(key) {
db.reads++;
return data.get(key);
},
put(key, value) {
db.writes++;
data.set(key, value);
},
};
return db;
}
test('a miss reads the database once, then the cache answers', () => {
const db = fakeDb({ p1: 499 });
const store = createStore(db, 1000, () => 0);
assert.equal(store.get('p1'), 499);
assert.equal(store.get('p1'), 499);
assert.equal(db.reads, 1);
});
test('an entry is served until its TTL ends, then read again', () => {
let now = 0;
const db = fakeDb({ p1: 499 });
const store = createStore(db, 1000, () => now);
store.get('p1');
now = 999;
store.get('p1');
assert.equal(db.reads, 1);
now = 1000;
assert.equal(store.get('p1'), 499);
assert.equal(db.reads, 2);
assert.equal(store.cache.get('p1').expiresAt, 2000);
});
test('put writes the database, then deletes the cached copy', () => {
const db = fakeDb({ p1: 499 });
const store = createStore(db, 1000, () => 0);
store.get('p1');
store.put('p1', 449);
assert.equal(db.writes, 1);
assert.equal(store.cache.has('p1'), false);
assert.equal(store.get('p1'), 449);
assert.equal(db.reads, 2);
});
test('a key the database does not have is not cached', () => {
const db = fakeDb({});
const store = createStore(db, 1000, () => 0);
assert.equal(store.get('p9'), undefined);
assert.equal(store.get('p9'), undefined);
assert.equal(db.reads, 2);
assert.equal(store.cache.has('p9'), false);
}); A hint
Start with get. An entry may answer only while clock() is still before its expiresAt; anything else is a miss. On a miss, read the database once and decide what to do with undefined before you store anything. For put, the order of the two calls matters: the database is the source of truth, so it changes first.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then answer the questions below on the two paths, the four strategies and cache-aside. A question you miss is asked again before the session ends.
Practice that adapts to you
Practise this lesson
11 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Each strategy in depth, with its failure cases Pro: opens with a Pro, Premium or Ultimate pass
Every strategy fails in its own way, and most failures only show when two things happen at once or when the cache disappears. This part reproduces those failures in code that runs and counts what each one costs.
In this part:
- Read and write paths of each strategy, step by step
- Delete or update after a write: the race, reproduced
- What a partition and a crash cost each strategy, measured
- How long write-back leaves writes at risk
- Cold starts and refresh-ahead under each strategy
2 diagrams · 2 runnable examples (Python) · about 17 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Choosing a strategy by workload, with numbers Pro: opens with a Pro, Premium or Ultimate pass
The right strategy follows from a few numbers you can find before writing any code: how many reads the database can take, how many writes arrive, and how stale or how lost a value may be. This part turns those numbers into a choice, with two worked scenarios and five more to decide.
In this part:
- From the database’s limit to the hit ratio you need
- Write-back’s flush interval against the writes at risk, worked
- Matching each requirement to a strategy
- Worked scenario: a product catalogue
- Worked scenario: a view counter
- Five scenarios to decide
1 diagram · about 14 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
Cache-aside or read-through: who loads data into the cache on a miss?
In cache-aside the application does: it reads the cache, and on a miss it reads the database itself and writes the value into the cache. In read-through the cache does: the application asks only the cache, and the cache calls a loader that reads the database. The cached data can be exactly the same; what differs is where the code lives and what happens when the cache fails. A cache-aside application can fall back to the database when the cache is down, while a read-through caller has no other path to the data. Read-through keeps one loading rule for every caller; cache-aside lets each caller decide what to cache and for how long.
-
Mid to senior
Product pages are read 50,000 times a second and the database serves at most 8,000 reads a second. What do you cache, and how do you keep it correct?
-
Mid to senior
A video view counter uses write-back: increments go to the cache and are flushed to the database every 10 seconds. How many views can you lose, and when is that acceptable?
-
Senior to staff
During a sale, stock counts are read 30,000 times a second and change 2,000 times a second. Would you use cache-aside, write-through or write-back for them, and what goes wrong with each?
Key takeaways
- A strategy is a rule for the read path and a rule for the write path: who fills a miss, and what a write does to the cached copy.
- Cache-aside: the application reads the cache, loads misses from the database, writes the database and then deletes the cached copy.
- Read-through and write-through put the cache in the data path: consistent behaviour, and no way around a failed cache.
- Write-back acknowledges writes before the database has them: fast, and a cache failure can lose them.
- Write the database first, then delete; give every entry a TTL as a safety net.
Terms in this lesson
- cache-aside
- A caching strategy in which the application does the work: it reads the cache, loads a miss from the database and stores it, and after a write it updates the database and deletes the cached copy.
- read-through
- A caching strategy in which the cache loads a missing value from the database by itself, so every caller shares one loading rule and has no other path to the data while the cache is down.
- refresh-ahead
- A cache that reloads popular entries shortly before they expire, so readers rarely wait for a miss, at the cost of database reads for entries nobody reads again.
- TTL (cache entry)
- Time to live: how long a cache may serve an entry after storing it. When it runs out the next read is a miss, so even a copy that a missed delete left behind disappears.
- write-back (write-behind)
- A caching strategy in which the cache acknowledges a write at once and copies it to the database later, so a cache failure can lose writes that were already acknowledged.
- write-through
- A caching strategy in which a write goes to the cache and the database before it is acknowledged, so a read right after it finds the new value, and each write takes as long as both writes together.
References
- Cache-Aside pattern (Azure Architecture Center) (Microsoft)
- Caching challenges and strategies (Amazon Builders' Library) (Amazon Web Services)
- Caching data sources (Oracle Coherence 14.1.1 documentation) (Oracle)
- Scaling Memcache at Facebook (USENIX NSDI (Nishtala and others))
- Redis persistence (Redis documentation) (Redis)
- Client-side caching reference (Redis documentation) (Redis)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress