Analogy
Imagine you have built a backend API that works perfectly.
You deploy it. Users start coming in. At first, everything feels fast.
Then the traffic grows.
Suddenly, your API is receiving thousands of requests every minute. Many of those requests are asking for the same data:
- The same user profile
- The same product information
- The same popular posts
- The same configuration
- The same exchange rates
- The same dashboard statistics
Your application keeps sending the same queries to PostgreSQL over and over again.
The database starts working harder.
Response times increase.
Eventually, your application may become slow or even unavailable.
So, how do large-scale backend systems avoid doing the same expensive work repeatedly?
Caching.
Caching is one of the most important concepts a backend developer should understand because it sits at the intersection of performance, databases, APIs, scalability, and system design.
In this article, we'll understand how caching works, where Redis fits into the picture, how to implement a basic caching strategy in Go, and some of the problems that caching introduces.
What Exactly Is Caching?
A cache is a temporary storage location for data that is expensive or time-consuming to retrieve.
The basic idea is simple:
If we already calculated or retrieved something recently, why do the same work again?
Consider a simple API:
Client
|
v
API Server
|
v
PostgreSQL
Suppose a client requests:
GET /users/42
The backend might execute:
SELECT * FROM users WHERE id = 42;
PostgreSQL finds the user and returns the data.
Nothing is wrong with this.
But imagine that 10,000 users request the same resource within a short period.
Your backend could potentially execute the same database query thousands of times.
Instead, we can introduce a cache:
Client
|
v
API Server
|
v
Cache
|
|-- Cache Hit --> Return data
|
|-- Cache Miss
|
v
PostgreSQL
|
v
Cache
|
v
API Server
The first request retrieves the data from PostgreSQL.
The backend then stores the result in the cache.
The next request can retrieve the data directly from the cache instead of querying PostgreSQL again.
That is the fundamental idea behind caching.
Cache Hits and Cache Misses
Two terms appear constantly when working with caches:
Cache hit and cache miss.
Cache hit
A cache hit occurs when the requested data already exists in the cache.
Request
|
v
Cache
|
|--- Found!
|
v
Return data
This is the ideal scenario.
The application avoids an expensive database operation.
Cache miss
A cache miss occurs when the requested data is not in the cache.
Request
|
v
Cache
|
|--- Not found
|
v
Database
The application retrieves the data from the database and can then store it in the cache for future requests.
A simplified flow looks like this:
Request
|
v
Check Cache
|
+------ Found ------> Return cached data
|
+------ Not Found --> Query Database
|
v
Store in Cache
|
v
Return data
This pattern is extremely common in backend systems.
Why Not Just Use the Database?
A reasonable question is:
"If PostgreSQL already stores the data, why do we need another system?"
Because databases are designed primarily for durable storage and querying, while caches are designed for fast access.
A database might need to:
- parse a query
- find the appropriate table
- use indexes
- read data
- perform filtering
- manage transactions
- handle concurrency
A cache can often retrieve a value directly using a key.
For example:
user:42
can map directly to:
{
"id": 42,
"name": "Evans",
"role": "developer"
}
The cache doesn't need to perform a complex SQL query.
This is why caching can dramatically reduce database load and improve response times.
But there is an important trade-off:
A cache is usually not the source of truth.
The database normally remains the authoritative source of the data.
The cache is there to make frequently accessed data faster to retrieve.
Where Does Redis Come In?
This is where Redis becomes useful.
Redis is an in-memory data store that is commonly used for:
- caching
- session storage
- counters
- rate limiting
- queues
- temporary data
- distributed locks
- pub/sub
For caching, Redis is particularly useful because data is kept in memory, making access extremely fast.
Instead of asking PostgreSQL:
SELECT * FROM products WHERE id = 100;
your application might ask Redis:
GET product:100
If the value exists, Redis returns it.
The application can then send the response without contacting PostgreSQL.
The Cache-Aside Pattern
One of the most common caching strategies is called cache-aside.
The application is responsible for checking the cache and loading data into it when necessary.
The process looks like this:
1. Client requests data
|
v
2. Application checks Redis
|
+--+--+
| |
Hit Miss
| |
| v
| Query DB
| |
| v
| Store in Redis
| |
+-----+
|
v
Return response
Let's walk through an example.
The client requests:
GET /products/100
The application generates a Redis key:
product:100
It checks Redis.
If Redis contains the value:
product:100 -> {"id":100,"name":"Laptop"}
the application immediately returns it.
If Redis doesn't contain it, the application queries PostgreSQL.
After retrieving the product, it stores the result:
product:100 -> {"id":100,"name":"Laptop"}
Now future requests can use Redis.
This pattern is simple, powerful, and widely applicable.
What Is TTL?
One of the biggest problems with caching is that cached data can become outdated.
Imagine a user changes their profile name.
PostgreSQL contains:
Evans Juma
but Redis still contains:
Evans
If the application keeps returning the cached value forever, users may receive stale information.
This is where TTL, or Time To Live, comes in.
TTL defines how long a cached value should remain available.
For example:
product:100
TTL = 60 seconds
After 60 seconds, Redis can automatically remove the key.
The next request becomes a cache miss:
Redis
|
|-- expired
|
v
PostgreSQL
The application retrieves the latest value and puts it back into Redis.
TTL is useful because it provides a balance between:
Performance
and
Freshness
For example:
| Data | Possible TTL |
|---|---|
| User profile | 5–15 minutes |
| Product information | 5–30 minutes |
| News feed | Seconds–minutes |
| Configuration | Minutes–hours |
| Exchange rates | Minutes |
| Static reference data | Hours |
These are examples, not universal rules.
The correct TTL depends on how frequently the underlying data changes and how stale the application can tolerate the data becoming.
A Simple Go + Redis Example
Let's make this more concrete.
Suppose we're building a Go API that retrieves users.
Without caching, the flow might look like:
func GetUser(id int64) (*User, error) {
return database.GetUser(id)
}
Every request goes directly to the database.
With Redis, the logic becomes:
func GetUser(id int64) (*User, error) {
key := fmt.Sprintf("user:%d", id)
cached, err := redisClient.Get(ctx, key).Result()
if err == nil {
var user User
if err := json.Unmarshal([]byte(cached), &user); err == nil {
return &user, nil
}
}
user, err := database.GetUser(id)
if err != nil {
return nil, err
}
data, err := json.Marshal(user)
if err != nil {
return nil, err
}
err = redisClient.Set(
ctx,
key,
data,
10*time.Minute,
).Err()
if err != nil {
// Log the cache error, but don't fail
// the request if the database succeeded.
}
return user, nil
}
The important part is understanding the flow:
Check Redis
|
+---- Found ----> Return cached user
|
+---- Not found
|
v
Query database
|
v
Store in Redis
|
v
Return user
This is the kind of pattern you'll encounter when building production APIs.
The Hardest Problem: Cache Invalidation
There's a famous saying in software engineering:
"There are only two hard things in Computer Science: cache invalidation and naming things."
The joke exists for a reason.
Suppose we have:
Database:
username = "Evans"
Redis:
username = "Evans"
A user changes their username to:
"Evans Juma"
The database is updated.
But what about Redis?
If we don't update or remove the cached value, Redis might continue returning:
"Evans"
even though the database contains:
"Evans Juma"
One solution is to delete the cached value when the database changes.
UPDATE DATABASE
|
v
DELETE CACHE
The next request produces a cache miss, retrieves the latest value from the database, and repopulates the cache.
Another strategy is to update both the database and cache.
The correct approach depends on the application's consistency requirements.
Caching Isn't Always Good
Caching sounds like a magic solution.
It isn't.
Caching introduces another layer of complexity.
You now have:
Application
|
Redis
|
PostgreSQL
instead of:
Application
|
PostgreSQL
That means you now need to think about:
- stale data
- cache invalidation
- cache expiration
- memory usage
- cache failures
- serialization
- cache consistency
- monitoring
And there is another important problem.
What happens when the cache goes down?
Your application shouldn't necessarily become completely unavailable just because Redis is unavailable.
A well-designed system can treat Redis as an optimization layer.
For example:
Request
|
v
Redis
|
X Redis unavailable
|
v
Database
|
v
Response
The request might be slower, but the system can still function.
This is an important backend design principle:
A cache should not automatically become a single point of failure for your application.
The Cache Stampede Problem
There's another interesting problem called a cache stampede.
Imagine a popular cache entry expires:
product:100
TTL expired
Now thousands of requests arrive at almost exactly the same time.
Every request checks Redis:
MISS
MISS
MISS
MISS
MISS
...
All of them then hit PostgreSQL.
Instead of reducing database traffic, your cache expiration has suddenly created a huge spike in database traffic.
This can be dangerous.
Solutions include techniques such as:
- locking
- request coalescing
- staggered expiration
- background refresh
- adding jitter to TTLs
The important thing is to recognize that caching creates new system-design problems that you have to solve.
When Should You Use Caching?
Caching is particularly useful when:
1. Data is read frequently
If thousands of requests repeatedly access the same data, caching can be extremely valuable.
2. Data is expensive to calculate
For example, an API might perform an expensive aggregation:
SELECT ...
GROUP BY ...
Caching the result can avoid repeating that computation.
3. Data doesn't change frequently
If data changes every millisecond, caching becomes more complicated.
If it changes every few hours, caching becomes much easier.
4. Low latency matters
Applications such as:
- social networks
- e-commerce platforms
- financial dashboards
- gaming systems
- recommendation systems
often benefit heavily from caching.
When Should You Avoid Caching?
Not everything needs to be cached.
Avoid blindly caching data simply because Redis is available.
Caching might be unnecessary when:
- the data is rarely requested
- the database query is already extremely cheap
- the data changes constantly
- stale data would cause serious problems
- the added complexity isn't worth the performance gain
The goal isn't:
"Cache everything."
The goal is:
Cache the right things.
A Production-Style Architecture
A more realistic backend might look like this:
┌──────────────┐
│ Client │
└──────┬───────┘
│
▼
┌──────────────┐
│ API Server │
└──────┬───────┘
│
▼
┌──────────────┐
│ Redis │
│ Cache │
└──────┬───────┘
│
Cache Miss
│
▼
┌──────────────┐
│ PostgreSQL │
│ Database │
└──────────────┘
With more traffic, the architecture can evolve further:
┌─────────────┐
│ Clients │
└──────┬──────┘
│
▼
┌───────────────┐
│ Load Balancer │
└───────┬───────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
API #1 API #2 API #3
│ │ │
└──────────┼──────────┘
│
▼
┌───────────┐
│ Redis │
└─────┬─────┘
│
▼
┌───────────┐
│ PostgreSQL│
└───────────┘
Now we have a system where multiple API servers can share the same cache.
This is particularly useful when your backend is horizontally scaled.
The Bigger Lesson
Caching isn't just about making an API faster.
It teaches an important lesson about backend engineering:
Every system has a cost.
A database query costs CPU, memory, disk I/O, connections, and time.
If you repeatedly perform the same expensive operation, you're wasting resources.
Caching allows us to trade some memory and complexity for:
- lower latency
- fewer database queries
- higher throughput
- better scalability
But that trade-off comes with responsibilities.
You have to think about:
What should we cache?
↓
How long should we cache it?
↓
When should it expire?
↓
What happens when the data changes?
↓
What happens when Redis fails?
↓
What happens when thousands of requests miss simultaneously?
These are no longer just programming questions.
They're system design questions.
Final Thoughts
Caching is one of those backend concepts that looks incredibly simple at first.
Store data somewhere faster.
Retrieve it later.
Done.
But once you start building real systems, you discover that caching is much deeper than that.
You have to understand cache hits, cache misses, TTLs, invalidation, consistency, failure handling, cache stampedes, and memory management.
Redis makes implementing many caching strategies relatively straightforward, but Redis itself isn't the solution to every performance problem.
Before introducing a cache, understand what is actually slow.
Measure your application.
Look at database queries.
Check response times.
Find bottlenecks.
Then decide whether caching is the right tool.
Because good backend engineering isn't about adding more technology.
It's about understanding the problem well enough to know when a technology is actually necessary.
And once your application starts receiving thousands or millions of requests, that difference between:
"Query the database every time."
and:
"Query the database only when necessary."
can become the difference between a backend that struggles under load and one that scales.
Key Takeaways
- Caching stores frequently accessed data closer to the application.
- Cache hits avoid expensive database operations.
- Cache misses retrieve data from the original source.
- Redis is a popular in-memory data store used for caching.
- Cache-aside is one of the most common caching patterns.
- TTL prevents cached data from living forever.
- Cache invalidation is one of the hardest parts of caching.
- Cache stampedes can cause sudden database overload.
- Redis failures should ideally not bring down the entire application.
- Not every piece of data needs to be cached.
- Caching is a performance optimization, not a replacement for your database.
The best cache is not the one that stores the most data. It's the one that eliminates the right work.
Top comments (0)