16.2 Application Caching
Table of Contents
Why Application Caching Matters
Application caching is the technique of storing the result of expensive work so that you can reuse it instead of doing the work again.
In backend development this usually means:
- Storing computed data in memory instead of recomputing it
- Storing frequently requested database results so you do not hit the database every time
- Storing prepared responses so you can send them back very quickly
Caching can drastically reduce:
- Response times
- Database load
- CPU usage
and can often delay or avoid the need for expensive infrastructure scaling.
Key idea: Cache what is expensive to do and cheap to store.
Example situation:
- An endpoint
/reports/dailyruns 10 SQL queries and some heavy calculations - It takes 2 seconds to run
- Users might hit it hundreds of times per hour
- The underlying data only changes once every 10 minutes
In this case it is very effective to compute the report once, cache the result for 10 minutes, and serve the cached result for all other requests during that time.
Types of Application Caches
Application caching happens inside or very close to your backend application. Common types:
| Type | Where stored | Typical scope |
|---|---|---|
| In‑process / in‑memory | Inside app process RAM | Single instance |
| Shared cache (Redis, etc.) | Separate cache service | Multiple instances / whole cluster |
| Local on-disk cache | Local disk / filesystem | Single server |
HTTP caching is covered separately in the HTTP Caching chapter. Here we focus on what your application code controls directly.
In‑Process / In‑Memory Cache
This is the simplest form of application caching.
- Data is stored in variables or in an in‑memory structure inside your app
- When the app process restarts, the cache is lost
- Each app instance has its own separate cache
Example in Python:
product_cache = {} # simple in‑memory cache
def get_product(product_id: int):
if product_id in product_cache:
return product_cache[product_id] # cached value
product = query_product_from_database(product_id) # expensive
product_cache[product_id] = product
return productThis is fast and easy, but it does not work well when you have many application instances, because each instance has its own copy of the cache.
Shared Cache (Using Redis or Similar)
A shared cache is a separate service, such as Redis, that all your application instances talk to.
- All instances can read and write the same cache keys
- When you add more app servers, they all share the same cache
- Cache can survive application restarts, as long as the cache server is running
Example pattern:
Client -> Your app -> Redis (cache) -> Database- App checks Redis for cached value
- If found, use that value
- If not found, query the database, store result in Redis, and return it
Shared caches are the most common choice in production systems.
What to Cache in Your Application
You should not cache everything. Cache the things that:
- Are expensive to compute or retrieve
- Are requested often
- Do not change very frequently
Common examples:
| What | Why cache it? |
|---|---|
| Product catalog | Many reads, few writes |
| User profiles (public data) | Often shown, not updated on every request |
| Configuration / feature flags | Rarely change, used in many places |
| Computed statistics | Expensive aggregation queries |
| HTML templates after rendering | For non personal content |
Avoid Caching Highly Dynamic or Personal Data
Data that changes very often or is specific to each user is usually a poor cache candidate, unless you control it carefully.
Examples:
- A stock price that updates every second
- A personal dashboard with live notifications
- A one‑time password or login code
You might still cache parts of such data, for example, the list of products that is the same for everyone, and then add per user data on top of it without caching.
Cache-aside Pattern (The Most Common Pattern)
The most used pattern in application caching is called cache‑aside.
The algorithm is:
- Check the cache for the value
- If it exists, return it
- If it does not exist, fetch from the original source (usually database)
- Store the new value in the cache
- Return the value
Cache-aside rule:
Application reads first from cache. On miss, read from database and then write to cache.
Example of Cache-aside in Python
Imagine you want to cache user profiles by user id.
import json
from typing import Optional
import redis
r = redis.Redis(host="localhost", port=6379, db=0)
def get_user_from_db(user_id: int) -> dict:
# very simplified example
# in a real app you'd use an ORM like SQLAlchemy
return {"id": user_id, "name": "Alice", "age": 30}
def get_user(user_id: int) -> Optional[dict]:
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached is not None:
return json.loads(cached)
# cache miss
user = get_user_from_db(user_id)
if user is None:
return None
# write to cache with expiration of 1 hour (3600 seconds)
r.setex(cache_key, 3600, json.dumps(user))
return userThis code:
- Tries Redis first
- If no cached value, hits the database
- Stores the result back into Redis with expiration
Time-to-Live (TTL) and Expiration
Every cache entry should have a lifetime. This is how long the entry is considered valid.
TTL is usually expressed in seconds, for example:
- 60 seconds for very dynamic content
- 300 seconds (5 minutes) for frequently updated but not critical data
- 3600 seconds (1 hour) or more for mostly static data
Important rule:
Never rely on the cache as the only source of truth. Always be ready to reload from the database or original source when cache entries expire or are missing.
Example: Setting a TTL in Redis
# cache for 10 minutes
r.setex("config:homepage", 600, json.dumps(config))Choosing TTL Values
Use these rough guidelines:
| Data type | Suggested TTL |
|---|---|
| Feature flags, configs that rarely change | 10 minutes to 24 hours |
| Product catalog | 5 to 60 minutes |
| Home page blocks / marketing banners | 1 to 15 minutes |
| Dashboard statistics | 30 seconds to 5 minutes |
| OAuth tokens | Same as token lifetime |
TTL choice is always a trade‑off between performance and freshness.
Cache Keys and Namespacing
A cache key is a string that identifies one cached value.
Typical structure:
<namespace>:<resource_type>:<identifier>Examples:
user:profile:123product:details:42stats:dashboard:globalconfig:feature_flags:v1
Why Namespaces Matter
Namespaces make it easy to:
- Avoid key collisions
- Group related keys
- Clear groups of keys when data is updated
For example, if all your product related keys start with product:, you can delete them using a pattern when product data changes.
Cache Invalidation Basics
Cache invalidation is the process of removing or updating cached data when the underlying data changes.
It is the hardest part of caching, because:
- If you forget to invalidate, users see stale data
- If you invalidate too often, you lose the performance benefit
There are three basic strategies:
- TTL based
Let the cache expire automatically after a certain time. - Write-through / write-behind
Update both cache and database when data changes. - Explicit invalidation
When updating data in the database, also delete or update related cache keys.
Example: Explicit Invalidation
Imagine you cache product:details:<id> and you have an API to update products.
def update_product(product_id: int, data: dict) -> dict:
updated = update_product_in_db(product_id, data)
# Invalidate related cache
cache_key = f"product:details:{product_id}"
r.delete(cache_key)
return updatedNext time a client asks for the product, the cache will miss, and your app will fetch the fresh data from the database and store it again in the cache.
Cache Hit and Cache Miss
Two important terms:
- Cache hit, the value was found in the cache
- Cache miss, the value was not found, you have to load it from the original source
You want a high hit rate, but not necessarily 100 percent. Sometimes it is fine that some requests go to the database, as long as your system stays fast and stable.
You can calculate hit rate like this:
$$
\text{hit\_rate} = \frac{\text{hits}}{\text{hits} + \text{misses}}
$$
Remember:
Caching only helps when the hit rate is high enough to reduce the real load on your database or service.
Function-level Caching (Memoization)
You can cache the results of function calls based on their arguments. This pattern is called memoization.
A simple example in Python using functools.lru_cache:
from functools import lru_cache
@lru_cache(maxsize=1024)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)This is very helpful for:
- Pure functions where output depends only on inputs
- Repeated computations inside your application process
In web backends, this is often used for:
- Loading configuration from disk or environment
- Parsing large static files
- Transforming reference data that does not change during runtime
Caching Database Queries
Many backend bottlenecks come from heavy or frequently used database queries.
A common pattern is:
- Build a cache key from the query parameters
- Check the cache before running the query
- Cache the result if it is not in the cache
Example: Caching a List of Products
Imagine an endpoint /products?category=phone&page=1.
You might build the key like this:
def build_product_list_key(category: str, page: int, page_size: int) -> str:
return f"product:list:category={category}:page={page}:size={page_size}"And then:
def list_products(category: str, page: int, page_size: int = 20):
cache_key = build_product_list_key(category, page, page_size)
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# expensive DB query
products = query_products_from_db(category, page, page_size)
r.setex(cache_key, 60, json.dumps(products)) # cache for 1 minute
return productsNotice that the function includes all relevant parameters in the cache key. This is very important, otherwise you might return results for the wrong query.
Caching Partial Responses
Sometimes you do not want to cache an entire API response, because it depends on a mix of static and dynamic data.
For example, an article page might include:
- The article body and title, which barely change
- A view count, which changes often
- A personalized recommendation list
A good approach is:
- Cache the article data by article id
- Fetch the view count and recommendations separately, without caching or with shorter TTL
- Combine them in the response
This gives you a large performance win while keeping dynamic parts fresh.
Dealing With Stale Data
Cached data is almost always slightly out of date. You must decide how much staleness is acceptable.
Options:
- Strong freshness, always fetch from the source when something changes, and immediately update or invalidate the cache.
- Eventual consistency, allow cached data to be a bit old for a defined period, like 1 or 5 minutes.
For many use cases, eventual consistency is fine. For example:
- Product price changes might take 1 minute to appear everywhere
- Blog post edits might take a few seconds to update on the public page
For very sensitive data, like balances in a banking system, you might avoid caching entirely or use very careful designs that combine transactions and cache updates.
Cache Warmup and Cold Starts
When your application or cache server starts and the cache is empty, you have a cold cache. This can cause a spike in load on your database, because every request is a cache miss at first.
To avoid this, sometimes you:
- Run a script that preloads important cache keys at startup
- Slowly ramp up traffic to a new deployment, instead of all at once
Example warmup script:
def warmup_cache():
# preload popular products
for product_id in [1, 2, 3, 4, 5]:
product = get_product_from_db(product_id)
r.setex(f"product:details:{product_id}", 3600, json.dumps(product))When Not to Use Application Caching
Caching is powerful, but not always appropriate.
Avoid or be very careful with caching when:
- You need strong consistency, for example, financial transactions and balances
- Data changes very frequently and is almost never read twice in a short time
- The cost of recomputing is very low compared to the complexity of caching
- Security or privacy concerns make storing data in a cache risky
In many small projects, you can start without caching, measure performance, and add caching only where it clearly helps.
Practical Guidelines for Application Caching
To use application caching effectively, keep these principles in mind:
- Start with measurement
Find slow endpoints or high load queries before adding caches. - Cache only what matters
Focus on the top 10 to 20 percent of operations that cause most of the load. - Choose clear cache keys
Use namespaces and include all relevant parameters in the key. - Always set an expiration
Do not store data in the cache forever, unless you know exactly why. - Plan invalidation
Decide how and when to clear or update caches when data changes. - Monitor hit rate
Track how often cache is used. If hit rate is low, your cache design might not be effective. - Keep the database the source of truth
Treat cache as a helper, not a replacement for your primary storage.
By following these guidelines, you will be able to add application caching to your backend in a safe and effective way, and you will avoid many common pitfalls such as stale data, inconsistent behavior, or wasted complexity.
Views: 7
KAHIBARO