Master Laravel cache warming using Redis Streams and Bloom Filters. Reduce database load and slash latency with this deterministic pre-computation pipeline.
We’ve all been there: a massive marketing push hits, and your database CPU spikes because your cache invalidation strategy is slightly too aggressive. I recently spent about three days refactoring a dashboard that was suffering from "cold start" latency, where the first request after an invalidation took roughly 450ms. It wasn't just slow; it was unpredictable.
To fix this, we moved away from reactive caching—waiting for a user to request data before computing it—and toward a deterministic cache warming pipeline. By combining Laravel’s event system with Redis Streams and Bloom Filters, we ensured the cache is almost always hot before the user even clicks the link.
Reactive caching relies on the "Cache-Aside" pattern. You check the cache, miss, hit the database, and write back. In high-concurrency environments, this causes a stampede. If a popular resource expires, twenty requests might simultaneously trigger a heavy Eloquent query, effectively DDOSing your own read replica.
We initially tried simple job batching, but that didn't account for state. We were re-warming keys that didn't need it or missing keys that were about to become hot. That’s when we pivoted to a predictive model.
To build a deterministic pipeline, you need to track what's "interesting" to the system. We use Bloom filters for efficient membership testing in high-cardinality data to quickly determine if a requested resource is a candidate for pre-computation.
Here is how the architecture looks:
Using Redis Streams (XADD) is superior to standard Laravel queues for this because it allows for consumer groups. If one worker dies, another picks up the exact state of the stream.
PHP#6A9955">// Inside your Event Listener use Illuminate\Support\Facades\Redis; public function handle(ProductUpdated $event) { Redis::xadd('cache-warming-stream', '*', [ 'resource_id' => $event->product->id, 'type' => 'product_detail' ]); }
By decoupling the "trigger" from the "computation," we ensure that our primary request cycle finishes in milliseconds. The heavy lifting happens asynchronously, and the cache is updated before the next user request hits.
The biggest risk in cache warming is "over-warming." You don't want to cache every single record in your database. This is where Bloom filters shine. Because they are probabilistic, they occupy very little memory while telling us if a key has a high probability of being requested.
If you’re interested in how to keep your job processing efficient, I’ve previously discussed Laravel Performance Optimization: Building Content-Aware Batching Pipelines to handle these bursts.
When the worker pulls an item from the stream, it performs a membership test:
PHPif ($this->bloomFilter->exists($productId)) { $data = $this->expensiveCalculation($productId); Cache::put("product_{$productId}", $data, now()->addHours(2)); }
This prevents the worker from warming obscure, rarely-accessed data, keeping your cache hit ratio high and your memory usage predictable.
We initially tried using standard Redis Sets to track hot keys, but the memory footprint grew too fast as our user base expanded. The Bloom filter approach reduced our memory overhead by about 70% compared to a standard set.
One caveat: Bloom filters have a false-positive rate. In our case, this is acceptable—the worst-case scenario is we cache a key that isn't actually hot, which is far better than the alternative of a slow database query during peak traffic.
If you're managing complex job dependencies, you might also find Laravel Job Queuing: Architecting Weighted Fair Queuing with Redis useful for prioritizing these warm-up jobs over background cleanup tasks.
Does this increase Redis memory usage? Yes, but minimally. A Bloom filter with 100,000 items and a 1% error rate takes up roughly 120KB. Compare that to the several megabytes of RAM needed for a standard PHP array or Redis Set.
What happens if the Redis Stream grows too large?
Use XTRIM to cap the stream length. We currently cap ours at 50,000 entries. Anything older than that is likely stale anyway.
Is this overkill for small apps?
Probably. If your database handles the load easily, stick to standard Cache::remember(). This architecture is intended for applications where the cost of a cache miss is measured in seconds of downtime or significant user frustration.
Next time, I want to experiment with using Redis::eval() to handle the Bloom filter logic directly in Lua, which would shave off another 2–3ms of network round-trip time. It’s a minor gain, but at scale, those milliseconds add up to a much smoother experience.
Laravel Job Queuing often struggles with priority starvation. Learn how to architect a Weighted Fair Queuing system using Redis Sorted Sets for better throughput.