If your database chokes every time a user loads a page, the problem isn't the database — it's that you're not using the cache the right way. We see this constantly: MySQL queries taking 800ms, APIs responding in 2 seconds, PHP code recalculating the same result over and over. And Redis sits idle, like a Ferrari engine in the garage.
At Meteora Web, we started working with Redis on Laravel and Linux servers back in 2018. Since then, every time we optimize a site or platform, caching is the first switch we flip. Not because we love complexity, but because a piece of data read once and cached can be served in 1ms instead of 200ms. And in a web application, those milliseconds are revenue.
In this guide we dive into three fundamental patterns: cache-aside, write-through, and proper TTL usage. We won't just talk theory: you'll get working code, real scenarios, and the “why” behind each choice.
The root problem: why cache alone isn't enough
Imagine an e-commerce site showing a product catalog. Every request hits MySQL, does join after join, calculates availability. It works — until 100 concurrent users hit it, then the server slows. At 500, it times out.
The first instinct is “cache everything”. You dump queries into Redis, set TTL to 24 hours, and call it done. But then stock updates in real time and the cache serves stale data. Or you delete a key and reload it, but another process already read the old data. Patterns exist precisely to handle these situations: data consistency, latency, and database load.
There's no one-size-fits-all pattern. The choice depends on how volatile the data is, how expensive it is to generate, and how stale you can tolerate. Let's look at each.
Cache-Aside (or Lazy Loading): the most common pattern
How it works
Cache-aside is the simplest and most common pattern. The application doesn't trust the cache as the primary source: first it asks Redis, if it doesn't find (cache miss) it queries the database, populates the cache, and returns the data. On a cache hit, it returns directly.
In PHP code using predis (Redis client for PHP) and a hypothetical repository:
function getProduct(int $id): array
{
$cacheKey = "product:{$id}";
$cached = $this->redis->get($cacheKey);
if ($cached !== false) {
return json_decode($cached, true);
}
// Cache miss: go to database
$product = $this->db->query("SELECT * FROM products WHERE id = ?", [$id]);
if ($product) {
// Set cache with TTL of 3600 seconds (1 hour)
$this->redis->setex($cacheKey, 3600, json_encode($product));
}
return $product;
}Pros: simple, adaptive (only requested data are cached), avoids filling Redis with data never read.
Cons: on cache miss you pay the database cost plus cache write. Under a spike of requests for data not yet cached (e.g., product launch) it can create sudden load. Also, if two concurrent requests both miss, both hit the database (thundering herd).
When to use it
Ideal for frequently read but rarely updated data: user profiles, blog articles, configurations. For our fashion e-commerce client, we used cache-aside for product pages: data changed at most once a day (prices, descriptions), TTL of 12 hours guaranteed freshness.
Operational: implementation with Laravel Cache
Laravel abstracts Redis behind the Cache facade. Here's how to implement cache-aside using the remember method:
use Illuminate\Support\Facades\Cache;
function getProductCached(int $id): array
{
$cacheKey = "product:{$id}";
return Cache::remember($cacheKey, 3600, function () use ($id) {
// callback executed only on cache miss
return Product::find($id)->toArray();
});
}remember atomically handles the cache miss and set. Note: in multi-server environments with high concurrency, this does not prevent thundering herd (two servers might execute the callback at the same time). To mitigate, use Cache::lock or a mutex mechanism.
Write-Through: transparent cache writes
How it works
In write-through, every write goes first through the cache and then to the database (or vice versa, depending on strategy). The idea: when you update a data point, you immediately update it in the cache. This keeps the cache always consistent with the database for written data.
Example: updating product price.
function updateProductPrice(int $id, float $newPrice): void
{
$cacheKey = "product:{$id}";
// 1. Write to database
$this->db->query("UPDATE products SET price = ? WHERE id = ?", [$newPrice, $id]);
// 2. Update cache (or delete, see below)
$product = $this->db->query("SELECT * FROM products WHERE id = ?", [$id]);
$this->redis->setex($cacheKey, 3600, json_encode($product));
}Pros: cache is always fresh after every write. Subsequent reads find the latest data.
Cons: every write makes two operations (DB + Redis). If the data is never read afterward, you've wasted resources. Also, if one store fails, you need transaction handling.
A lighter variant is cache-invalidate on write: instead of rewriting the cache, you delete it. The next read triggers cache-aside and repopulates from DB. This reduces complexity and write load, but introduces a tiny coherence gap (between delete and next read, the cache is empty).
When to use write-through
Data that changes often and is read even more often. For example, an e-commerce shopping cart: every add/remove should be written to DB and cache because the user reloads the cart page multiple times. Write-through ensures the latest modification is immediately visible.
Operational: write-through with Laravel and transactions
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
function updateProductPriceWithCache(int $id, float $newPrice): void
{
DB::transaction(function () use ($id, $newPrice) {
// Update DB
DB::table('products')->where('id', $id)->update(['price' => $newPrice]);
// Fetch updated record
$product = DB::table('products')->find($id);
// Write to cache (write-through)
Cache::put("product:{$id}", (array) $product, 3600);
});
}Using a transaction ensures that if the DB write fails, the cache is not touched. For full consistency, you could also delete the cache instead of updating (write-invalidate), but in this example we keep write-through.
TTL: the freshness thermometer
Why TTL is not optional
Every key in Redis has a Time To Live. Without it, the data stays in memory forever → infinite memory usage, never refreshed. Set it too short and you keep getting cache misses. Too long and you risk serving years-old data.
Rule of thumb: TTL should be comparable to the maximum time the data can be served without causing business errors. For e-commerce prices with daily updates, 12-24 hours is fine. For real-time stock, a few minutes. For an Instagram post, hours.
Managing expiration
In Redis use EXPIRE or SETEX. Expiration is lazy (Redis checks when you access the key) and periodic (scans a sample every 100ms). It's not millisecond-granularity, but fine for 99% of cases.
Warning: if you update a key with SET, you lose the previous TTL. You must explicitly re-set it or use SETEX.
# Set key with TTL of 300 seconds
SET product:123 '{"id":123,"price":29.99}' EX 300
# Check remaining TTL
TTL product:123
# Extend TTL
EXPIRE product:123 600Dynamic TTL strategies
If your data has different update frequencies, you can compute TTL based on popularity: hot data gets longer TTL, cold data shorter. But pragmatically, a sensible constant (e.g., 3600 seconds) works for 90% of cases.
At Meteora Web, we solved a stock issue for a fashion e-commerce using TTL of 60 seconds for availability data (updated from ERP every 30 seconds) and TTL of 3600 seconds for product descriptions. The MySQL database went from 500 queries/sec to 15 queries/sec. Average latency dropped from 400ms to 12ms.
Common mistakes and how to avoid them
- Manual cache invalidation: deleting keys by hand is a nightmare. Always rely on TTL or automatic expiration patterns.
- Forgetting to serialize: Redis stores bytes. If you save PHP objects or complex structures, make sure to serialize (json, igbinary, msgpack). In Laravel,
serializeis preconfigured. - Thundering herd on hot keys: for data that expires and is requested by hundreds of processes simultaneously, use cache lock or mutex to let only one process regenerate the cache.
- Keys without namespace: use prefixes like
product:123,user:456. Avoid collisions and make deletion by pattern easier (DEL product:*is dangerous, prefer SCAN).
In summary — what to do now
- Analyze your bottlenecks. Monitor the slowest queries on your DB. Identify data read much more often than written.
- Choose the right pattern: cache-aside for read-heavy data, write-through for data that changes often and must always be consistent.
- Set mindful TTLs. Never leave a key without expiration. Start with 3600 seconds and adjust after monitoring cache hit ratios.
- Implement with Laravel Cache or raw Redis. Use the examples above as starting points. If on Laravel, leverage
rememberfor cache-aside. - Test concurrency. Simulate 50 simultaneous requests on a non-cached key. If the DB struggles, add a lock or mutex.
If you want to see how we structured a cache layer for a proprietary Laravel platform, check out our guide on JSONB in PostgreSQL: flexible schemas and performant queries — another technique to speed up complex queries.
Remember: caching doesn't solve everything, but without it you pay the database toll every time. We started using Redis in 2018, and from then on every application we build starts from there. Because a fast app isn't a luxury: it's revenue you don't lose.
Sponsored Protocol