NoSQL · 06

Caching Patterns

A cache is a faster, smaller, possibly stale copy of something expensive. This page is about everything that goes wrong after you add one: which pattern to pick, how to invalidate, the read-miss race, stampedes, missing keys, mass expiry, hot keys, HTTP and CDN caching, how to size and measure it, and how to survive it being down. It ends with a small plain-Java cache you can run to watch a stampede happen and be stopped.

13
Chapters
5
Patterns
9
Diagrams
1
Build-it demo
01
Chapter One - Why and What

What to Cache, and What Never To

A cache is a faster, smaller, possibly stale copy of data that is expensive to produce or fetch. It earns its place in two ways: it lowers latency (the answer is nearby) and it lowers load on the source of truth (the database sees only misses). It costs you three things: staleness, memory, and a new failure mode (the cache itself, plus the code that keeps it correct). Phil Karlton's joke about cache invalidation being one of the two hard problems in computer science is the most quoted line in the field because it is true.

Caches are layered: each layer answers before the next one is asked
Browser private, per user CDN / proxy shared, edge App local (L1) in-process, per instance Redis / Memcached (L2) shared, remote Database source of truth A request moves right until a layer has a fresh answer; layers to the right of a hit never see it. Closer to the user: faster, cheaper, but harder to invalidate (you cannot reach into a browser). Closer to the data: easier to invalidate and share, but each call still crosses a network. Every layer you add trades staleness for speed. Decide the acceptable staleness per data type first: product description: minutes to hours. stock count on a listing page: seconds. permissions after a role change: seconds, or event-driven invalidation. payment balance: do not cache. Relative cost only (illustrative, depends entirely on your system): in-process memory is orders of magnitude cheaper than a network call to Redis, which is in turn typically far cheaper than a database query.
Good candidatesWhyBad candidatesWhy
Read-heavy data that changes rarely: catalogues, configuration, reference dataHigh reuse, cheap to be a little staleData that must be exactly current: account balances, inventory used to accept an order, payment stateA stale read causes a wrong business decision. Read the source of truth
Expensive computations: aggregates, rendered fragments, ML scores, permission setsProducing it costs far more than storing itWrite-heavy data with little re-readingEvery write invalidates; you pay maintenance and get no hits
Responses from slow or rate-limited external APIsSaves latency, money and quotaPer-request unique results (search with free text, one-off reports)Hit ratio near zero; memory wasted
Hot keys with skewed access (few items get most reads)A small cache captures most trafficVery large objects that rarely repeatEvict useful small items, cost bandwidth
Idempotent, deterministic lookupsSame input, same outputAnything personalised where the key omits the user or tenantYou serve one user's data to another (a security incident, not a bug)
⚠ Leaving an input out of the cache key
  • The key must contain every input that changes the answer: tenant, user or role, locale, currency, API version, feature flags. A cached GET /dashboard keyed only by URL shows the first user's data to everyone. Symptom: "customer A saw customer B's name". Include the dimension in the key, or do not cache that response.
Before adding a cache, try these first
  • Fix the slow query (an index, a missing join condition, an N+1) and measure again. A cache over a slow query hides the problem and moves it to the first request after every expiry. See Spring Data JPA and the SQL pages for query tuning.
02
Chapter Two - The Patterns

Cache-Aside, Read-Through, Write-Through, Write-Behind, Refresh-Ahead

Five names cover almost every design. They differ in who loads on a miss and when writes reach the database. The Spring annotation side (@Cacheable, @CachePut, @CacheEvict) is in Spring: Caching and Resilience; this page is about the behaviour behind the annotations.

Cache-aside versus read-through: who talks to the database?
Cache-aside (the application orchestrates) Application Cache Database 1. get(key) miss 2. app reads the database itself, then 3. put(key, value) into the cache Read-through (the cache loads for you) Application Cache + loader Database get(key) on miss: load In both, a database write does not touch the cache by itself: invalidate or update it (chapters 3, 4). Cache-aside: if the cache is down the app still works (slower). Explicit and easy to reason about. Read-through: one API for callers; single-flight and negative caching live in one place. Caffeine LoadingCache and Spring @Cacheable behave as read-through to the caller; with Redis as the store they are cache-aside in practice (the application process runs the loader, not Redis).
PatternRead pathWrite pathStaleness windowMain risk
Cache-aside (lazy loading)App: get; on miss load from DB, put in cacheWrite DB, then delete (or update) the cache entryUntil the next write invalidates, or TTLRead/write races leave stale values (chapter 4); stampede on a cold hot key (chapter 5)
Read-throughCache loads from DB on a miss through a configured loaderSame as cache-aside unless combined with write-throughSameLoader failure mode is hidden inside the cache; one place to get it right
Write-throughReads hit a cache that is always currentWrite goes to the cache, which writes to the DB synchronously before acknowledgingNear zero for keys written through itSlower writes; cache holds data that may never be read; DB failure fails the write
Write-behind (write-back)Reads hit the cacheWrite goes to the cache and is acknowledged; the DB write happens later, batchedThe DB is stale until flushData loss if the cache node dies before flush; ordering and failure handling are your job
Refresh-aheadHot entries are reloaded in the background before they expiren/a (a read-side technique)Bounded by the refresh intervalWasted loads for keys nobody asks for; refresh storms
Failure analysis per pattern
PatternWhat breaksWhat you seeMitigation
Cache-asideCache downLatency up, database load up by 1/(1-hit ratio)Timeouts, circuit breaker, load shedding (chapter 10)
Cache-asideUpdate the DB but the cache delete failsStale value until TTLRetry the delete, TTL as a safety net, event-driven invalidation (chapter 3)
Read-throughLoader throws or hangsEvery caller on that key waits or failsDo not cache failures, timeout the loader, serve stale-on-error
Write-throughDB write fails after the cache updated (or the reverse)Cache and DB disagreeWrite the DB first, update the cache on success; or invalidate instead of update
Write-behindCache node crashes with unflushed writesAcknowledged writes are gonePersist the queue (a log or stream), replicate, limit batch size; use only for data you can afford to lose
Write-behindDB is down for a whileQueue grows without boundBounded queue, backpressure, alert on queue age
Refresh-aheadPredicts the wrong keysLoad on the DB for entries nobody readsRefresh only keys read recently (Caffeine refreshAfterWrite refreshes on access)
⚠ Write-behind for data that matters
  • It is attractive because writes are fast. It is dangerous because you have acknowledged a write that exists only in memory. If you need it for throughput (counters, metrics, view counts), say out loud what you accept to lose and put a durable log (a Kafka topic or a Redis stream with persistence, see Redis) between the cache and the database.
Key insight: Cache-aside is a notebook you consult first and update yourself. Read-through is a librarian who fetches from the stacks for you. Write-through is a librarian who files your new book in the stacks and the catalogue before saying thanks. Write-behind is a librarian who says thanks immediately and files it tonight - fast, until the librarian goes home sick.
03
Chapter Three - Invalidation

TTL, Explicit Deletes, Events and Versioned Keys

Invalidation is the act of making sure a cache stops serving a value that is no longer true. There are four tools. Real systems use a TTL as a safety net plus one precise method on top.

StrategyHowStaleness boundCost / catch
TTL onlySET key value EX 300At most the TTLSimplest. Chooses a staleness number for the whole data class. A short TTL means more misses; a long one means longer stale reads
Explicit delete on writeAfter the DB commit: DEL key (or cache.invalidate(key))Near zero, if the delete runs and is not raced (chapter 4)Every writer must know every cache key derived from its data. Derived and aggregate keys are easy to forget
Event-drivenThe write publishes a change event (outbox or CDC); consumers delete or refresh keysEvent latency plus processingDecouples writers from caches, scales to many caches and services. Needs at-least-once handling, idempotent invalidation, and ordering care. See Event-driven patterns
Versioned keysPut a version in the key: product:42:v17, or a namespace version shop:v3:...; bump the version instead of deletingImmediate for readers that read the new versionOld versions linger until TTL or eviction (memory). Needs a cheap place to read the current version

Event-driven invalidation is how you keep many caches (several services, local L1 caches on every instance) correct without the writer knowing about them. The reliable form writes the change and an outbox row in the same database transaction, a relay publishes the event, and each cache owner consumes it and invalidates. Because delivery is at least once, invalidating twice must be harmless (deleting a key twice is). See Messaging fundamentals for the delivery guarantees and Kafka Architecture for a log that can carry the events.

Text - the shape of event-driven invalidation
write path: BEGIN; UPDATE product ...; INSERT INTO outbox(type, key) VALUES ('product-changed', 42); COMMIT; relay: reads outbox, publishes {"type":"product-changed","id":42} to a topic cache consumer: on event -> DEL product:42 (L2), invalidate product:42 (local L1 on every instance) handler is idempotent: deleting twice is fine; lost events are bounded by the TTL

Versioned keys are the cheapest way to invalidate a whole class at once. If the value format changes in a deployment, or a bulk import rewrites a table, bump shop:v3 to shop:v4: every old key becomes unreachable instead of being found and deleted one by one, and the TTL or the eviction policy reclaims the memory. The same idea gives you safe rolling deployments: instances running the old and the new code use different prefixes and never read each other's incompatible values.

Update or delete on write?
  • Delete is safer: the next reader loads the truth. Update (write the new value into the cache) saves a miss but is vulnerable to two writers finishing in a different order than they started (chapter 4), and to writing a value that was valid only inside a transaction that later rolled back. Delete after commit by default.
04
Chapter Four - Consistency

Races Between Readers, Writers and the Cache

A cache and a database are two stores updated by separate calls, so there is no moment where both change atomically. The worst symptom is stale data that stays stale until the TTL. The diagram shows the most common sequence in cache-aside with delete-on-write.

The classic race: a stale value written back after the invalidation
Reader Writer Cache Database 1 get(k) -> miss 2 read DB -> v1 (old) v1 returned to reader 3 UPDATE DB: v2 DB now holds v2 4 DEL cache 5 SET cache = v1 (STALE) cache holds v1, DB has v2 The reader loaded before the write but stored after the delete, so the stale value lives until TTL. No lock was broken: it is only an interleaving of five independent steps.

Why it happens: step 2 reads the old value, the whole write (steps 3 and 4) completes, and only then does step 5 write the old value into the cache. It needs a slow reader and a fast writer, which is rare per request but certain at enough traffic. What you can do, in order of cost:

TechniqueHow it helpsLimits
Short TTL as a safety netBounds how long a stale value survivesThe inconsistency window is the TTL; some data cannot accept it
Delete after commit, not beforeDeleting before the DB commit lets a reader reload the old row and cache itDoes not fix the diagram above
Delayed double deleteDelete after the write, then delete again after a short delay longer than a typical read-and-store (hundreds of milliseconds)A heuristic: the delay is a guess, and the second delete needs a retry-safe scheduler
Versioned values with compare-and-setStore the row version with the value; the loader writes only if its version is newer than what is in the cache (a script in Redis, a conditional put locally)Needs a version column and an atomic conditional write in the cache
Event-driven invalidation from the database logA CDC stream emits changes in commit order; the consumer deletes the key after the change is durable, and retriesEventually consistent by design; more moving parts (chapter 3)
Short leases on loadingA reader takes a lease token on a miss; a delete invalidates the token so the late SET is rejected (the approach used in large Memcached deployments)Needs cache support for leases or a Lua script
Do not cache thisRead from the source of truthCosts the latency you wanted to save

A second race: two writers, update-on-write. Writer 1 sets the DB to 1, writer 2 sets it to 2, writer 2 writes the cache (2), then writer 1 writes the cache (1): cache says 1, DB says 2. This is why delete-on-write is the default advice. If you do update the cache, include a version and make the cache write conditional.

Lua - compare-and-set a cached value by version (a sketch of the "newer version wins" idea for Redis)
-- KEYS[1] = cache key, ARGV[1] = new version (number), ARGV[2] = serialized value, ARGV[3] = ttl seconds local cur = redis.call('HGET', KEYS[1], 'v') if cur == false or tonumber(cur) < tonumber(ARGV[1]) then redis.call('HSET', KEYS[1], 'v', ARGV[1], 'val', ARGV[2]) redis.call('EXPIRE', KEYS[1], ARGV[3]) return 1 end return 0
⚠ Believing a lock around cache and DB makes this go away
  • A lock held by the application process does not cover another instance, or a replica read that is lagging. If staleness matters that much, prefer a version check or the database as the only reader for that path.
05
Chapter Five - Stampede

Thundering Herds and How to Stop Them

A cache stampede (thundering herd, dog-piling) happens when a popular entry expires or is evicted and many requests miss at the same instant. Each one runs the expensive loader, so the database receives a burst of identical queries at exactly the moment the cache stopped protecting it. The burst makes the database slower, which makes the loaders slower, which keeps more requests in flight: a feedback loop that can take the database down.

Stampede: what happens when a hot key expires
Without protection: all concurrent misses call the loader r1 r2 r3 r4 r5 r6 Database: N identical queries e.g. 2,000 req/s x 0.2 s load = ~400 identical queries (example) With single-flight: one loader call, the others wait for its result r1 r2 r3 r4 r5 r6 in-flight map: key -> future Database Other defences (they combine): jittered TTLs so keys do not expire together; probabilistic early refresh so ONE request recomputes shortly before expiry; stale-while-revalidate (serve the old value while one refresh runs); a distributed lock or lease when the loaders run in many processes and one in-process map is not enough. Real run in chapter 11: 100 threads, cold key -> naive loader calls = 100, single-flight = 1.
DefenceIdeaCost / catch
Single-flight (request coalescing)Per key, the first miss becomes the loader; concurrent misses wait for its future. Caffeine's LoadingCache.get does this per key inside one JVM. Real result in chapter 11: 100 callers, 1 loadPer process only. With 50 app instances you still get 50 loads, which is usually fine
Distributed lock / leaseCross-process single-flight: on a miss, SET lock:key token NX PX 5000; the winner loads, the losers briefly retry or serve stale. See Redis, distributed lockExtra round trips, a TTL to tune, and a lock-service failure mode. An efficiency lock is fine here
Jittered TTLAdd a random amount to each TTL (3600 + random(0..360)) so keys loaded together do not expire togetherSpreads expiries; does not help one hot key
Probabilistic early expirationEach reader may decide to refresh a little before expiry, with a probability that rises as expiry nears. The XFetch rule from the VLDB 2015 paper "Optimal Probabilistic Cache Stampede Prevention": recompute when now - delta * beta * ln(rand()) >= expiry, where delta is how long the last recomputation took and beta (default 1) tunes eagernessYou must store delta and the expiry with the value. Wastes a few recomputations to avoid a herd
Stale-while-revalidateKeep serving the expired value while one request refreshes it in the background. Caffeine: refreshAfterWrite. HTTP: stale-while-revalidateReaders see slightly old data for one refresh time
Background refresh / pre-warmingA scheduled job reloads known-hot keys before they expireOnly works for a known hot set
Protect the sourceRate-limit or bulkhead the loader so the database never sees an unbounded burstCallers beyond the limit get errors or stale data
Java - Caffeine: bounded, expiring, refreshing and single-flight in a few lines (compiled and run, see chapter 11 for output)
import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.LoadingCache; import com.github.benmanes.caffeine.cache.stats.CacheStats; import java.time.Duration; import java.util.Optional; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class CaffeineDemo { static final AtomicInteger dbCalls = new AtomicInteger(); // Pretend database: id 1..100 exist, everything else does not static Optional<String> loadFromDb(Long id) { dbCalls.incrementAndGet(); try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return id >= 1 && id <= 100 ? Optional.of("product-" + id) : Optional.empty(); // Optional.empty() = negative entry } public static void main(String[] args) throws Exception { LoadingCache<Long, Optional<String>> cache = Caffeine.newBuilder() .maximumSize(10_000) // bounded: evicts by a size-aware policy (W-TinyLFU) .expireAfterWrite(Duration.ofMinutes(10)) // hard TTL .refreshAfterWrite(Duration.ofMinutes(5)) // after 5 min the next read returns the old value and reloads in the background .recordStats() .build(CaffeineDemo::loadFromDb); // get(key) computes at most once per key at a time ExecutorService pool = Executors.newFixedThreadPool(50); CountDownLatch gate = new CountDownLatch(1); for (int i = 0; i < 50; i++) pool.submit(() -> { gate.await(); return cache.get(42L); }); gate.countDown(); pool.shutdown(); pool.awaitTermination(5, TimeUnit.SECONDS); System.out.println("50 threads, same cold key -> database calls = " + dbCalls.get()); for (int i = 0; i < 3; i++) cache.get(-7L); // a missing id: loaded once, then the empty answer is cached System.out.println("3 lookups of a missing id -> database calls = " + dbCalls.get() + " (negative entry cached)"); CacheStats s = cache.stats(); System.out.println("hitCount=" + s.hitCount() + " missCount=" + s.missCount() + " loadCount=" + s.loadCount() + " hitRate=" + String.format("%.2f", s.hitRate())); } }
Shell - real output of that program
50 threads, same cold key -> database calls = 1 3 lookups of a missing id -> database calls = 2 (negative entry cached) hitCount=51 missCount=2 loadCount=2 hitRate=0.96

Fifty threads asked for the same cold key and the database was called once. The second line shows negative caching in action (chapter 6): the missing id was loaded once and the empty answer was cached for the other two lookups. The statistics line is Caffeine's own recordStats() data.

Jitter arithmetic (example)
  • Suppose a warm-up job loads 1,000,000 keys in about 5 minutes, each with a 1-hour TTL. Without jitter, they expire over 5 minutes in the same order: a wave of reloads the same size as the warm-up. With 0-10 % jitter (0-6 minutes) the wave is spread over about 11 minutes, roughly halving the peak rate. The numbers are an illustration of the spread, not a measurement.
06
Chapter Six - Penetration and Avalanche

Missing Keys, Mass Expiry and Cache Collapse

Penetration: queries for data that does not exist

A cache stores what exists. A request for an id that is not in the database gets a miss every single time, so it always reaches the database. A scanner, a bug or an attacker iterating random ids can therefore bypass the cache completely. The defences: negative caching and a Bloom filter, plus ordinary input validation and rate limiting.

Penetration: requests for keys that do not exist bypass the cache every time
Request id = -1 / random Bloom filter "maybe exists?" Cache Database maybe miss "definitely not" -> answer 404 immediately, never reach the cache Negative caching: cache the "not found" answer for a short TTL (30-60 s is typical), so repeating the same missing key costs one database query, not one per request. Bloom filter: a compact bit array answering "definitely not present" or "possibly present". It has no false negatives while you only add; false positives are tunable. Example: 1,000,000 ids at 1 % false positives need about 9.6 bits per id = about 1.2 MB and 7 hash functions (standard formula, computed, not measured). Real run on redis:8: BF.RESERVE seen 0.01 1000 -> OK, BF.ADD seen user:1 -> 1, BF.EXISTS seen user:1 -> 1, BF.EXISTS seen user:999 -> 0 (the Bloom commands ship in core Redis 8; on 7.x they need a module).
TechniqueHowCatch
Negative cachingStore a marker for "not found" (Optional.empty(), a null sentinel) with a short TTLA row created right after the miss is invisible until the negative entry expires or is deleted: delete the key on create
Bloom filter in frontKeep the set of valid ids in a Bloom filter; if it says "definitely not", reject without touching the cache or databaseStandard Bloom filters cannot delete; rebuild periodically or use a counting/cuckoo variant. Must be updated when new rows are created
Validate the key spaceIds have a known format and range; reject others at the edgeDoes not stop valid-looking nonexistent ids
Rate limit by clientLimits what one caller can scanDistributed attackers
Avalanche: many keys expire (or the cache dies) together

An avalanche is the same problem at scale: a large share of the cache disappears at once and the database gets the full load. Three common causes: (1) lots of keys written together with the same TTL, (2) a cache restart or failover with no persistence, (3) a deployment that changes the key prefix for everyone. All three are about the cold cache, and the database must be able to survive it, or the traffic must be limited until the cache is warm again.

CausePreventionDuring the event
Same TTL on a batch of keysJitter the TTL (chapter 5)Single-flight and background refresh
Cache node restartPersistence (RDB) so it restarts warm, replicas, several nodes so one failure loses a fractionLoad shedding, stale-if-error, warm hot keys first
Key prefix / version bumpBump gradually, or warm the new version before switching readersSame
Cache eviction under memory pressureSize for the working set (chapter 9) and watch evicted_keysAdd memory or shrink values
07
Chapter Seven - Hot Keys and Two Levels

L1 in the Process, L2 Shared

A hot key is one that receives a disproportionate share of traffic: the home page, a trending product, a feature flag read on every request. In a sharded cache all requests for one key land on the node that owns it, so adding nodes does not help. In Redis Cluster, for example, a key lives in exactly one slot (see Redis, chapter 5).

Two-level cache: L1 in each instance, L2 shared, one invalidation path
App instance 1 L1 Caffeine small, ~seconds-minutes App instance 2 L1 Caffeine small, ~seconds-minutes App instance 3 L1 Caffeine small, ~seconds-minutes L2 Redis (shared) on miss Database Invalidation bus (pub/sub, topic) to every L1 writer publishes Read: L1 -> L2 -> database, filling each level on the way back. Write: change DB, delete L2, publish an event so every instance drops its L1 copy. If an event is lost, a short L1 TTL (seconds) bounds the staleness. L1 absorbs hot-key traffic that would otherwise hammer one Redis shard; it also survives a Redis outage for the keys it holds. The cost: N copies of the same data, each possibly stale for a moment. Keep L1 small and its TTL short.
TechniqueHowTrade-off
Local L1 in front of L2A small in-process cache (Caffeine) per instance with a short TTL, then Redis, then the databaseHot reads never leave the process. Staleness up to the L1 TTL unless invalidated by event
Key replication / suffixingStore the hot value under key#0 ... key#N-1; each reader picks one at random, so the load spreads over several shardsWrites must update or delete all copies. Use only for a known handful of keys
Client-side caching with server-assisted invalidationRedis can track which keys a client has cached (CLIENT TRACKING ON) and push an invalidation message when one changes; needs a client that implements it (RESP3 or a second connection)Memory on the server for tracking; support depends on client library. Described on the Redis client-side caching docs page
Pub/Sub invalidationWriters publish the key name; every instance drops its L1 copyPub/Sub is at-most-once, so keep a short L1 TTL as the backstop; the Redis docs call broadcast invalidation by Pub/Sub tricky and bandwidth-costly at scale
Request coalescing at L1Single-flight in the L1 loaderAlready covered in chapter 5
Keep L1 honest
  • Give L1 a hard size bound and a TTL measured in seconds or a few minutes. Instrument L1 and L2 separately: an L1 hit ratio of 60 % with an L2 hit ratio of 95 % tells you a different story than one blended number. The Spring side of configuring two cache layers is covered in Spring: Caching and Resilience.
⚠ Per-instance caches and rolling deployments
  • Every new instance starts with an empty L1, so the first requests after a deployment all go to L2 and the database at once. Roll slowly, warm L1 from L2 for the hot keys, or let L2 absorb the first wave (it is the reason L2 exists).
08
Chapter Eight - HTTP and CDN Caching

Cache-Control, ETag and the Edge

HTTP has caching built in, and it is the cheapest cache you can use because the request never reaches your servers. Two mechanisms work together: freshness (the response says how long it may be reused without asking) and validation (once stale, the cache asks the origin "has it changed?" with a conditional request). The directives below were checked against MDN's Cache-Control reference (2026-10-31).

HTTP caching: freshness first, then conditional revalidation
Browser / CDN cache Origin server 1. GET /app.css 200 + ETag: "v7" + Cache-Control: max-age=60 ... 60 seconds pass: the stored copy is now stale ... 2. GET /app.css If-None-Match: "v7" 304 Not Modified (no body) The cache keeps using its stored copy and resets its freshness. Cost: one round trip, no body bytes. If the content had changed, the origin would answer 200 with the new body and a new ETag. Fresh (within max-age): no request at all. Stale: revalidate with If-None-Match / If-Modified-Since. Fingerprinted assets (app.3f9c1a.js) never change under that name: cache them for a year with immutable.
Directive (response)MeaningTypical use
max-age=NFresh for N seconds after it was generated, in any cacheStatic content, rarely changing API data
s-maxage=NLike max-age but for shared caches (CDN, proxy); overrides max-age thereLet the CDN keep it longer than the browser
no-cacheThe response may be stored, but must be revalidated with the origin before each reuse. Despite the name it does not mean "do not store"Pages that must always be fresh but can use a 304
no-storeDo not store the response in any cacheSensitive data: account pages, tokens
privateOnly the user's own (browser) cache may store it, not shared cachesPersonalised responses
publicShared caches may store it even if it would normally not be stored (for example an authorised request)Explicitly cacheable shared content
must-revalidateOnce stale, do not serve it without successful revalidation (no stale on error)When serving stale is unacceptable
immutableThe response will not change during its freshness lifetime, so do not revalidate on reloadFingerprinted assets: public, max-age=31536000, immutable
stale-while-revalidate=NServe a stale response for up to N seconds while revalidating in the backgroundLatency-friendly freshness
stale-if-error=NServe a stale response for up to N seconds if the origin errorsResilience: the origin can be down briefly
HeaderRole
ETag: "v7" and request If-None-Match: "v7"Validator for the content. If it still matches the origin answers 304 Not Modified with no body. A weak ETag starts with W/ and means "semantically equivalent"
Last-Modified and If-Modified-SinceA time-based validator; coarser than an ETag (one-second resolution)
Vary: Accept-Encoding, Accept-LanguageExtends the cache key: the cache keeps a separate copy per value of the named request headers. Vary: * effectively disables sharing. A missing Vary serves the wrong language or encoding to somebody
AgeSeconds the response has been in shared caches, set by the cache
Text - example response headers (illustrative)
HTTP/1.1 200 OK Content-Type: text/css Cache-Control: public, max-age=31536000, immutable ETag: "3f9c1a" HTTP/1.1 200 OK Content-Type: application/json Cache-Control: private, max-age=0, must-revalidate ETag: W/"user-42-v9" Vary: Authorization
⚠ HTTP caching mistakes
  • No Cache-Control at all: a cache is allowed to store the response and choose a freshness time heuristically, so always state your intent.
  • Putting personalised data behind a CDN without private/no-store or a Vary on what identifies the user: users see each other's pages.
  • Long max-age on a URL whose content changes in place. Version the URL (fingerprint) instead of shortening the TTL.
  • Confusing no-cache with no-store.
Where this meets the application cache
  • Use HTTP caching for what a client may reuse, and the server-side patterns above for what your servers may reuse. A common stack: CDN with s-maxage plus stale-while-revalidate in front, Caffeine plus Redis behind the app, and event-driven invalidation (and a CDN purge for the few paths that need it) when content changes.
09
Chapter Nine - Sizing and Metrics

Hit Ratio Math, Working Sets and What to Watch

Hit ratio and what it buys

The hit ratio is hits / (hits + misses). Two formulas make it concrete. Database load is requests x (1 - hit ratio). Average latency is h x t_hit + (1 - h) x (t_hit + t_load), because a miss still pays the cache lookup. The example below uses made-up round numbers: a 1 ms cache lookup and a 20 ms database load.

Database requests per second at 10,000 requests/s, by hit ratio (arithmetic, not a measurement)
hit ratio 80 % 2,000 req/s reach the database hit ratio 90 % 1,000 req/s reach the database hit ratio 95 % 500 req/s reach the database hit ratio 99 % 100 req/s reach the database hit ratio 99.9 % 10 req/s reach the database Going from 95 % to 99 % cuts database load fivefold; the last few percent are worth the most.
Hit ratio (h)Database load at 10,000 req/sAverage latency (1 ms lookup, 20 ms load)Reading
0.901,000 req/s0.90 x 1 + 0.10 x 21 = 3.0 msA good start
0.95500 req/s0.95 x 1 + 0.05 x 21 = 2.0 msHalf the database load of 0.90
0.99100 req/s0.99 x 1 + 0.01 x 21 = 1.2 msTail latency is now dominated by the misses

Two consequences. First, the database must survive the miss traffic, and a cold cache means h near 0: at 10,000 req/s you need either capacity for 10,000 req/s of loads or a plan to shed load (chapter 10). Second, average latency hides the tail: with h = 0.99 the 99th percentile request is a miss, so p99 is the database latency, not the cache latency. Watch percentiles, not the mean.

The working set

Caches work because access is skewed: a small part of the data takes most of the traffic. The cache needs to hold that working set, not the whole dataset. Measure it: log key accesses for a day, sort by frequency, and plot the cumulative share of requests against the number of keys. If, say, 5 % of keys served 80 % of requests (a made-up skew for illustration), a cache holding about 5 % of the data reaches roughly an 80 % hit ratio, and each extra percent of capacity buys less.

Sizing stepExample (labelled example numbers)Notes
Entries to hold5,000,000 entriesFrom the working-set measurement, not the table row count
Bytes per entry1 KB value + about 100 B key and bookkeeping overhead (assumption; measure with MEMORY USAGE in Redis)Overhead is per entry, so many small entries cost more than their payload
Total5,000,000 x 1,100 B = about 5.5 GBAdd headroom for fragmentation, replication buffers and fork copy-on-write
Set limitsmaxmemory below the container limit; Caffeine maximumSize or maximumWeightAn unbounded cache is a memory leak with a nice name
Compress or shrinkCache the DTO the page needs, not the entity graphSmaller values mean more entries per GB
Metrics to put on a dashboard
MetricWhere to read itWhat it tells you
Hit ratio (per cache and per endpoint)Redis INFO stats: keyspace_hits, keyspace_misses; Caffeine stats().hitRate()Is the cache earning its keep? Alert on a sudden drop
EvictionsRedis evicted_keys; Caffeine evictionCount()Rising evictions with a falling hit ratio means the cache is too small or keys are too big
Expired keysRedis expired_keysNormal churn; spikes suggest synchronised TTLs
Load time and failuresCaffeine averageLoadPenalty(), loadFailureCount(); your own timersThe cost of a miss; the first sign a source is degrading
MemoryRedis used_memory against maxmemory; JVM heap for L1Headroom before eviction or OOM
Latency percentiles of cache callsClient metrics (Micrometer)A slow cache is worse than no cache
In-flight loads / stampede indicatorLoader concurrency per keyConfirms single-flight works

The output of INFO stats on a lightly used test server looked like keyspace_hits:34, keyspace_misses:7, evicted_keys:0, expired_keys:5 (real values from the session used for the Redis page). The ratio of hits to misses, not the raw counts, is what you alert on.

10
Chapter Ten - Failure Modes

Cache Down, Cold Start and Degradation

A cache is an optimisation, so the system must still work, within limits, without it. Most outages involving caches share one pattern: the cache stops absorbing 90-99 % of reads, the database receives all of them, and the database falls over.

When the cache is down: fail open, but protect the database
Request Circuit breaker around the cache Cache Load shedder / bulkhead Database skip Cache failure = a latency and load problem for the database, not just a missing feature. Fail open: serve from the database (or stale data) instead of erroring, within what it can take. Contain it at the cache call: a Redis timeout must not become a user-visible 500. Order: short timeout, circuit breaker, load shedding / bulkhead, stale-if-error, degrade. After recovery do not release all traffic at once: warm hot keys first, then ramp up. Pick one pre-agreed answer per feature: serve stale, serve a default, or return a clear "temporarily unavailable".
FailureWhat you seeResponse
Cache unreachable or slowTimeouts, thread pools filling with waiting requests, latency spike everywhereShort client timeouts (tens of milliseconds, not seconds), circuit breaker that stops calling the cache while it is unhealthy, then fail open to the database under a concurrency limit. Breaker details in Spring: Caching and Resilience
Database overloaded after cache lossDatabase CPU and connections saturate, errors cascadeLoad shedding: cap concurrent loads, return stale or a degraded response for the excess. Rate-limit expensive endpoints
Cold start after restart or failoverHit ratio near 0 for minutesPersist the cache to disk and restart warm (Redis RDB), pre-warm hot keys from a list or from L2, ramp traffic
Cache returns wrong or stale dataUsers report old data, or another user's dataCheck key construction first (missing tenant/user), then invalidation, then races; add versioned keys to flush a class quickly
Serialization incompatibility after deployDeserialization errors on old entriesVersion the key prefix; treat a deserialization error as a miss, not a failure
Memory exhaustionEvictions climbing, hit ratio falling, or OOM errorsEviction policy, bounded sizes, find big keys (see Redis)
⚠ A cache read that can fail the request
  • If cache.get throws and the exception is not caught, a cache outage becomes a full outage. Wrap cache reads and writes: on error, log, count a metric, treat as a miss. Make writes to the cache best effort as well: failing to populate a cache should not roll back the business operation.
A capacity question to ask in design review
  • "If the cache were empty right now, would the database survive the traffic?" If not, you do not have a cache, you have a dependency, and the protection (shedding, breakers, stale serving) is part of the design, not an extra.
11
Chapter Eleven - Build It

A TTL + LRU Cache with Single-Flight Loading, in Plain Java

Libraries hide the mechanics, so here is the whole thing in about a hundred lines of plain Java 17+: a bounded (LRU) cache, expiring entries (TTL with optional jitter), and single-flight loading so that concurrent misses share one loader call. It is for understanding; in production use Caffeine, which adds much better eviction (W-TinyLFU), asynchronous refresh and statistics.

Java - MiniCache.java (compiled with JDK 21)
import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; import java.util.concurrent.locks.ReentrantLock; import java.util.function.*; /** A small in-process cache: bounded (LRU), expiring (TTL + jitter) and single-flight on misses. */ public class MiniCache<K, V> { private record Entry<V>(V value, long expiresAtNanos) {} private final int capacity; private final long ttlNanos; private final long jitterNanos; private final LongSupplier clock; // injectable so tests do not sleep private final ReentrantLock lock = new ReentrantLock(); private final LinkedHashMap<K, Entry<V>> map; // access-order = LRU private final ConcurrentHashMap<K, CompletableFuture<V>> inFlight = new ConcurrentHashMap<>(); final AtomicInteger hits = new AtomicInteger(), misses = new AtomicInteger(), evictions = new AtomicInteger(); public MiniCache(int capacity, long ttlMillis, double jitterFraction, LongSupplier nanoClock) { this.capacity = capacity; this.ttlNanos = TimeUnit.MILLISECONDS.toNanos(ttlMillis); this.jitterNanos = (long) (ttlNanos * jitterFraction); this.clock = nanoClock; this.map = new LinkedHashMap<>(16, 0.75f, true); } /** Fresh value or null. A hit also marks the key as most recently used. */ private V lookup(K key) { lock.lock(); try { Entry<V> e = map.get(key); if (e == null) return null; if (clock.getAsLong() - e.expiresAtNanos >= 0) { map.remove(key); return null; } // lazy expiry return e.value; } finally { lock.unlock(); } } private void store(K key, V value) { long jitter = jitterNanos == 0 ? 0 : ThreadLocalRandom.current().nextLong(jitterNanos + 1); lock.lock(); try { map.put(key, new Entry<>(value, clock.getAsLong() + ttlNanos + jitter)); while (map.size() > capacity) { // evict eldest = LRU Iterator<K> it = map.keySet().iterator(); it.next(); it.remove(); evictions.incrementAndGet(); } } finally { lock.unlock(); } } /** Naive cache-aside: every concurrent miss calls the loader. This is the stampede. */ public V getNaive(K key, Function<K, V> loader) { V v = lookup(key); if (v != null) { hits.incrementAndGet(); return v; } misses.incrementAndGet(); v = loader.apply(key); store(key, v); return v; } /** Single-flight: concurrent misses for one key share ONE loader call. */ public V get(K key, Function<K, V> loader) { V v = lookup(key); if (v != null) { hits.incrementAndGet(); return v; } misses.incrementAndGet(); CompletableFuture<V> mine = new CompletableFuture<>(); CompletableFuture<V> existing = inFlight.putIfAbsent(key, mine); if (existing != null) { // someone else is loading: wait for their result try { return existing.get(); } catch (ExecutionException e) { throw new RuntimeException(e.getCause()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } } try { // we are the leader V again = lookup(key); // re-check: a previous leader may have finished just before us if (again != null) { mine.complete(again); return again; } V loaded = loader.apply(key); store(key, loaded); mine.complete(loaded); return loaded; } catch (RuntimeException ex) { mine.completeExceptionally(ex); // nothing is cached, so the next call retries throw ex; } finally { inFlight.remove(key); } } public Long expiresInMillis(K key) { lock.lock(); try { Entry<V> e = map.get(key); return e == null ? null : TimeUnit.NANOSECONDS.toMillis(e.expiresAtNanos - clock.getAsLong()); } finally { lock.unlock(); } } public Set<K> keys() { lock.lock(); try { return new LinkedHashSet<>(map.keySet()); } finally { lock.unlock(); } } }

How it works. lookup takes the lock, returns a fresh entry or removes an expired one (lazy expiry), and a LinkedHashMap in access order makes the eldest entry the least recently used one (LRU: store evicts it when the size exceeds the capacity). getNaive is the classic cache-aside code, so concurrent misses all call the loader. get is single-flight: the first miss puts a CompletableFuture into the inFlight map and becomes the leader; later misses for the same key find the future and wait on it. The leader re-checks the cache once it has won, because a previous leader may have just finished (without that re-check a thread can slip in between a leader's store and its inFlight.remove and load again). A loader failure completes the future exceptionally, is not cached, and the next call retries.

Java - CacheDemo.java: the stampede test and the other checks (a manual clock replaces sleeping)
import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; import java.util.function.*; public class CacheDemo { static final AtomicLong fakeNanos = new AtomicLong(); // manual clock for expiry tests static void advance(long millis) { fakeNanos.addAndGet(TimeUnit.MILLISECONDS.toNanos(millis)); } /** Runs `threads` concurrent callers against one key and returns how many times the loader ran. */ static int stampede(BiFunction<String, Function<String, String>, String> access, int threads, long loaderMillis) throws Exception { AtomicInteger loaderCalls = new AtomicInteger(); Function<String, String> loader = k -> { loaderCalls.incrementAndGet(); try { Thread.sleep(loaderMillis); } catch (InterruptedException e) { throw new RuntimeException(e); } return "value-of-" + k; }; ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch gate = new CountDownLatch(1); List<Future<String>> results = new ArrayList<>(); for (int i = 0; i < threads; i++) { results.add(pool.submit(() -> { gate.await(); return access.apply("product:42", loader); })); } gate.countDown(); // release all threads at once for (Future<String> f : results) f.get(); pool.shutdown(); return loaderCalls.get(); } public static void main(String[] args) throws Exception { System.out.println("== 1. Stampede: 100 threads, cold key, loader takes 100 ms"); MiniCache<String, String> naive = new MiniCache<>(100, 60_000, 0, System::nanoTime); int n = stampede(naive::getNaive, 100, 100); System.out.println("naive cache-aside : loader calls = " + n); MiniCache<String, String> sf = new MiniCache<>(100, 60_000, 0, System::nanoTime); n = stampede(sf::get, 100, 100); System.out.println("single-flight : loader calls = " + n + " (hits=" + sf.hits + ", misses=" + sf.misses + ")"); System.out.println("\n== 2. TTL: entry expires, the next herd triggers exactly one reload (manual clock)"); MiniCache<String, String> ttl = new MiniCache<>(100, 1_000, 0, fakeNanos::get); System.out.println("t=0 s herd of 50 -> loader calls = " + stampede(ttl::get, 50, 20)); advance(900); System.out.println("t=0.9 s herd of 50 -> loader calls = " + stampede(ttl::get, 50, 20) + " (still fresh)"); advance(200); System.out.println("t=1.1 s herd of 50 -> loader calls = " + stampede(ttl::get, 50, 20) + " (expired, one reload)"); System.out.println("\n== 3. LRU: capacity 3"); MiniCache<String, String> lru = new MiniCache<>(3, 60_000, 0, System::nanoTime); for (String k : List.of("a", "b", "c")) lru.get(k, key -> key.toUpperCase()); lru.get("a", key -> key.toUpperCase()); // touch a: b is now least recently used lru.get("d", key -> key.toUpperCase()); // inserts d, evicts b System.out.println("keys after touching a and adding d: " + lru.keys() + " evictions=" + lru.evictions); System.out.println("\n== 4. Jitter: 8 keys loaded at the same instant, ttl 60 s +0..20 %"); MiniCache<String, String> jit = new MiniCache<>(100, 60_000, 0.20, fakeNanos::get); StringBuilder sb = new StringBuilder(); for (int i = 0; i < 8; i++) { jit.get("k" + i, k -> "v"); sb.append(jit.expiresInMillis("k" + i)).append(" ms "); } System.out.println("expires in: " + sb); System.out.println("\n== 5. Loader failure is not cached"); MiniCache<String, String> fail = new MiniCache<>(10, 60_000, 0, System::nanoTime); AtomicInteger calls = new AtomicInteger(); Function<String, String> flaky = k -> { if (calls.incrementAndGet() == 1) throw new IllegalStateException("db down"); return "ok"; }; try { fail.get("x", flaky); } catch (RuntimeException e) { System.out.println("first call : " + e.getMessage()); } System.out.println("second call: " + fail.get("x", flaky) + " (loader calls = " + calls + ")"); } }
Shell - real output (javac MiniCache.java CacheDemo.java && java CacheDemo). The jitter numbers are random and differ on every run
== 1. Stampede: 100 threads, cold key, loader takes 100 ms naive cache-aside : loader calls = 100 single-flight : loader calls = 1 (hits=0, misses=100) == 2. TTL: entry expires, the next herd triggers exactly one reload (manual clock) t=0 s herd of 50 -> loader calls = 1 t=0.9 s herd of 50 -> loader calls = 0 (still fresh) t=1.1 s herd of 50 -> loader calls = 1 (expired, one reload) == 3. LRU: capacity 3 keys after touching a and adding d: [c, a, d] evictions=1 == 4. Jitter: 8 keys loaded at the same instant, ttl 60 s +0..20 % expires in: 64686 ms 67275 ms 71672 ms 70008 ms 71998 ms 71232 ms 70902 ms 64059 ms == 5. Loader failure is not cached first call : db down second call: ok (loader calls = 2)
TestWhat it shows
1. 100 threads, cold key, loader 100 msNaive cache-aside called the loader 100 times; single-flight called it once. All 100 callers still got the value
2. Manual clockWithin the TTL a herd of 50 triggers 0 loads; one millisecond past expiry it triggers exactly 1
3. LRU, capacity 3After touching a and inserting d, b (least recently used) was evicted: keys are [c, a, d]
4. Jitter 0-20 %Eight keys loaded at the same instant expire at different times between 60 s and 72 s
5. Loader failureThe failure was not cached; the second call loaded again and succeeded
⚠ What this toy leaves out (and a library does not)
  • It holds one global lock for lookups, has no background refresh, no size-by-weight, no statistics beyond three counters, no negative-entry TTL, and no protection against a loader that hangs (a hung leader blocks its followers forever; give the loader a timeout). It is a teaching model, not a library.
12
Chapter Twelve - Interview

Caching Questions with Crisp Answers

  • Q: Name the main caching patterns.
    Cache-aside (app loads and fills), read-through (cache loads), write-through (synchronous write to cache and DB), write-behind (asynchronous DB write), refresh-ahead (reload before expiry). They differ in who loads on a miss and when the database is written.
  • Q: Why delete the cache entry on write instead of updating it?
    Delete lets the next reader load the truth and avoids two writers leaving the cache in a different order than the database. Update saves a miss but needs versioning or a conditional write.
  • Q: Describe the read/write race in cache-aside.
    A reader misses and reads the old row, a writer updates the DB and deletes the cache entry, then the reader writes its old value into the cache. It stays stale until TTL. Mitigate with TTL, versioned values with conditional set, delayed double delete, or event-driven invalidation from the database log.
  • Q: What is a cache stampede and how do you prevent it?
    Many concurrent misses on an expired hot key all hit the database. Use single-flight, a distributed lock or lease, jittered TTLs, probabilistic early refresh (XFetch), stale-while-revalidate, and protect the source with limits.
  • Q: How does probabilistic early expiration work?
    Readers recompute a bit before expiry with rising probability: recompute if now - delta * beta * ln(rand()) >= expiry, where delta is the last recompute time. One reader usually refreshes early, so there is no herd at the deadline.
  • Q: Cache penetration versus avalanche versus stampede?
    Penetration: requests for nonexistent keys always miss (negative caching, Bloom filter). Avalanche: many keys expire or the cache dies together (jitter, persistence, replicas, shedding). Stampede: concurrent misses on one hot key (single-flight).
  • Q: How does a Bloom filter help and what are its limits?
    It answers "definitely not present" or "possibly present" in small memory, so nonexistent keys are rejected before the cache or database. False positives are tunable (about 9.6 bits per element for 1 %); standard filters cannot delete and must be updated when data is added.
  • Q: What is a hot key and how do you handle it?
    One key getting disproportionate traffic, which overloads the shard that owns it. Add a local L1 cache, replicate the value under suffixed keys, or use client-side caching with server-assisted invalidation.
  • Q: Design a two-level cache. What about invalidation?
    L1 in-process (small, seconds-minutes TTL), L2 shared (Redis), database behind. On write: update DB, delete L2, publish an event so every instance drops its L1 entry. A short L1 TTL bounds staleness if an event is lost.
  • Q: Explain no-cache versus no-store.
    no-cache allows storing but requires revalidation before each reuse; no-store forbids storing. Neither means "not cached" by itself in the colloquial sense.
  • Q: What is an ETag?
    A validator for a response version. The client sends If-None-Match; if it matches, the server returns 304 Not Modified with no body.
  • Q: How do you cache static assets safely?
    Fingerprint the file name and serve Cache-Control: public, max-age=31536000, immutable; a new build produces a new URL.
  • Q: How do you size a cache?
    Measure the working set (key frequency distribution), multiply entries by value size plus overhead, add headroom, set maxmemory or maximumSize, and watch hit ratio and evictions. Do not size by table row count.
  • Q: Why is the hit ratio alone not enough?
    It hides tail latency (p99 is a miss) and cost per miss. Track per-endpoint hit ratio, load time percentiles, evictions and memory.
  • Q: What if the cache goes down?
    Short timeouts, circuit breaker on the cache client, fail open to the database under a concurrency limit, serve stale or degrade, then warm up before restoring full traffic. Ask: could the database survive an empty cache?
  • Q: Write-through or write-behind?
    Write-through is consistent and slower; write-behind is fast but acknowledged writes can be lost if the cache dies before the flush. Use write-behind only for loss-tolerant data, with a durable queue.
  • Q: What goes wrong if the cache key omits the tenant or user?
    Responses are shared across users: a data leak. The key must include every input that changes the answer.
  • Q: Scenario: the home page API is slow every day at 09:00 exactly.
    Likely synchronised TTLs on keys warmed at the same time, or a daily deploy emptying L1. Check expiry metrics and load times at that hour; add jitter, stale-while-revalidate and pre-warming.
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
ProblemSymptomFix
Stale after write (race)Old value lives until TTLDelete after commit, short TTL, version + conditional set, event-driven invalidation
StampedeDB burst when a hot key expiresSingle-flight, lock/lease, jitter, XFetch, stale-while-revalidate
PenetrationDB load from nonexistent idsNegative cache (short TTL), Bloom filter, validation
AvalancheDB overload after mass expiry or cache lossJitter, persistence, replicas, load shedding, warm-up
Hot keyOne shard saturatedL1 cache, key replication, client-side caching
Data leakUser sees another user's dataPut every input in the key; private/no-store over HTTP
Cache outageLatency and DB load explodeTimeout, breaker, fail open under limits, stale-if-error
Unbounded growthOOM or evictions everywheremaxmemory/maximumSize, TTL, watch evictions
∑ Caching patterns - what to remember
  • Cache what is read often, expensive, and tolerant of staleness; never what must be exactly current.
  • Cache-aside is the default; write-through trades write latency for freshness; write-behind trades durability for speed.
  • TTL is the safety net; add delete-after-commit or event-driven invalidation for precision, versioned keys for bulk flushes.
  • The read-miss/update interleaving is real: only versions, leases or event ordering truly fix it.
  • Single-flight, jitter and early refresh stop stampedes; negative caching and Bloom filters stop penetration.
  • L1 plus L2 absorbs hot keys; invalidate by event with a short L1 TTL as backstop.
  • HTTP caching is free speed: max-age, s-maxage, no-cache (revalidate), no-store (do not keep), ETag and 304.
  • Size by working set; measure hit ratio, evictions, load time and memory; design for the cache being down.
Version note

Checked on 2026-10-31: the Java demos were compiled with JDK 21 and run (MiniCache and the Caffeine 3.x program, Caffeine on the classpath of a Spring Boot 3.5.16 project); Redis behaviour and Bloom commands by running redis:7 and redis:8 images; the XFetch rule against the VLDB 2015 paper; Cache-Control directive meanings against MDN; client-side caching against redis.io docs. Hit ratio and sizing numbers are labelled worked examples, not benchmarks. Not verified: behaviour of managed cache services and of specific CDNs, which differ in what they honour.

Next in this section