What is the difference between a database and a cache, and how does cache-aside work?
A database normally owns durable, authoritative records and transactional constraints. A cache keeps a temporary copy optimized for repeated low-latency reads. In cache-aside, the application checks the cache, reads the database on a miss, then populates the cache with a TTL; correctness still requires invalidation, stampede protection, and database fallback.
How to think about it
The short answer
A database is the durable, authoritative home for business data; a cache is a faster, expendable copy that may be stale and can usually be rebuilt. With cache-aside, the application checks the cache first, reads the database on a miss, returns the result, and fills the cache so later requests avoid the database.
Why the distinction matters
Imagine a product page for SKU 42 receiving thousands of requests per minute. The page changes twice an hour, but every request still needs the same product name, image URL, and display price. Asking the database to perform that same read 5,000 times is wasteful. It consumes database connections, CPU, and network bandwidth that should be available for writes and less repetitive queries.
A database normally acts as the system of record, meaning the place the service trusts as canonical. It provides durable storage, indexes, transactions, constraints, backups, and recovery. If the process restarts, the database is expected to still contain the product and its price.
A cache is optimized for quickly answering repeated lookups. It often stores data in memory or a fast distributed key-value service. It may evict entries when memory is full, expire them after a TTL, or time to live, and lose everything during a failure. That is acceptable because the application can fetch the data again from the database.
The important distinction is not simply “PostgreSQL versus Redis.” A database may have internal caches, and a cache may have disk persistence and replication. The distinction is the contract. If losing a component destroys the only copy of business data, that component is functioning as a database regardless of what its product name says.
Cache-aside is called “aside” because the cache sits beside the database and the application manages both. The cache does not automatically know how to load a product. Application code explicitly performs this sequence:
- Look for the key in the cache.
- If it exists, return the cached value.
- If it does not exist, read the database.
- Put the database result into the cache with an expiry.
- Return the result.
A cache hit means the requested value is present and usable. A cache miss means it is absent, expired, evicted, or otherwise unusable. A hit is faster, but it is not automatically fresher.
A concrete example
Suppose the product endpoint uses keys such as product:v3:42. The version in the key gives you room to change the serialized shape later without accidentally reading an old format.
Here is Python-like pseudocode for the read path:
import json
def get_product(product_id):
key = f"product:v3:{product_id}"
value = cache.get(key)
if value == "NOT_FOUND":
return None
if value is not None:
return json.loads(value)
product = db.query_one(
"SELECT id, name, price_cents FROM products WHERE id = %s",
(product_id,),
)
if product is None:
cache.set(key, "NOT_FOUND", ex=30)
return None
cache.set(key, json.dumps(product), ex=300)
return product
The normal product receives a five-minute TTL. A missing product receives a much shorter thirty-second negative-cache entry. Negative caching means caching the fact that a record was not found. It prevents someone repeatedly requesting product:999999 from forcing the database to perform the same unsuccessful lookup.
For arithmetic, assume the endpoint gets 5,000 requests per minute and achieves a 98 percent cache-hit rate. About 4,900 requests are served from the cache, leaving about 100 database reads. The database read load is therefore 50 times smaller than serving every request from the database.
If a cache hit takes an assumed one millisecond and a complete miss path takes an assumed twenty milliseconds, the simplified average is:
0.98 × 1ms + 0.02 × 20ms = 1.38ms
Those are design assumptions, not universal benchmarks. Payload size, serialization, network distance, contention, and database indexes all matter.
Now suppose the product price changes from 2,999 cents to 3,199 cents. A common write path is:
1. Commit the price update in the database.
2. Delete product:v3:42 from the cache.
The next read misses, obtains the new price from the database, and repopulates the cache. The database is updated first because it remains authoritative if the cache is unavailable.
The production nuance
The simple diagram hides the difficult part: cache-aside does not provide consistency by itself.
A five-minute TTL is a limit on how long an entry can remain without expiring. It is not immediate invalidation. If the database changes one second after a cache entry is created and deletion fails, readers may see the old value for almost five minutes.
Common mistake: Treating TTL as proof that the cache is fresh. TTL is a safety net. Explicit invalidation is what normally makes a write visible quickly.
There is also a race between readers and writers:
- A reader misses the cache and starts a database query for the old price.
- A writer commits the new price and deletes the cache key.
- The reader’s already-running query returns the old price.
- The reader stores that old result in the now-empty cache.
The cache is stale again even though invalidation succeeded. This is why systems with strict freshness requirements use additional measures such as versioned values, conditional writes, short-lived locks, or a second invalidation after a delay. An event-driven invalidation path should also be retryable. A transactional outbox is one common pattern: the database transaction records an invalidation event, and a worker publishes that event until the cache deletion succeeds.
The cache can fail in several ordinary ways:
- A cache
GETtimes out. - A cache node evicts a popular key.
- A value cannot be deserialized after a deployment.
- A cache
SETfails after the database read. - A hot key overloads one cache shard.
The usual read policy is to fail open for cache misses: use a short cache timeout, fall back to the database, and treat a failed cache write as a performance problem rather than a data-loss problem. That fallback needs protection. If the cache is down, every request becomes a database request. Without a circuit breaker, request limit, or load shedding, the fallback can exhaust the database connection pool and turn a cache outage into a full service outage.
A particularly visible failure mode is a cache stampede, where many requests load the same missing key simultaneously. The first symptom is often a sudden spike in database CPU and connection usage at the exact moment a popular key expires. The cache may look healthy overall; one expired key is enough to cause trouble.
For an expensive key, the service can allow only one request to load it while other requests wait briefly. This is called request coalescing or single-flight loading. A distributed lock can provide similar coordination, but it needs an owner token and an expiry so a crashed process does not hold the lock forever. TTL jitter, which adds a small random amount to each expiry, prevents thousands of keys from expiring at the same instant. Serving a slightly stale value while one request refreshes it is another useful option when the business allows it.
Cache the right data. Product descriptions and public catalog metadata are good candidates because they are read-heavy, expensive enough to repeat, and usually tolerant of brief staleness. Account balances, authorization decisions, inventory at checkout, and one-time tokens need much stricter treatment. A stale product-page price may be acceptable as a display detail; charging that stale price is not. The checkout path should revalidate against the authoritative pricing and inventory source.
The cache key must describe every input that changes the response. A key like product:42 is unsafe if the response varies by currency, language, tenant, or permissions. A response that differs by currency might need a key such as product:v3:42:USD. A shared cache must never return one tenant’s authorized data to another tenant merely because both requested object 42.
Do not use cache-aside when the data is rarely read, changes constantly, or cannot tolerate eventual consistency. The extra invalidation code, memory cost, failure modes, and operational monitoring may cost more than the database reads it saves.
What they’ll ask next
How is cache-aside different from write-through?
With cache-aside, application code reads the database on a miss and explicitly populates or invalidates the cache. With write-through, the cache layer participates in writes and synchronously writes the backing store as part of that operation. Write-through can centralize cache behavior, but it makes the cache part of the write path and therefore adds latency and another failure boundary.
What should happen if the cache is unavailable?
Reads should usually use a bounded-time fallback to the database, while writes continue to the database and skip cache population if necessary. The service must protect the database with timeouts, rate limits, circuit breaking, or load shedding. Blindly retrying the cache and then blindly retrying the database is how a small cache incident becomes a large database incident.
How do you handle stale values and stampedes?
Invalidate after a successful database commit, keep a TTL as a backstop, and make invalidation retryable. For hot keys, use single-flight loading, a short distributed lock, TTL jitter, or stale-while-revalidate. If strict ordering matters, store a version with the cached value and reject an older result from overwriting a newer one.
Say this in the interview
“A database is the durable source of truth, while a cache is a reconstructible, possibly stale copy for fast repeated reads; in cache-aside, the application reads the cache first, loads the database on a miss, populates the cache with a TTL, and separately handles invalidation, stampedes, failures, and freshness requirements.”