Stop Using $model->increment(): Build High-Throughput Counters
The Real-World Bottleneck
Almost every Laravel application has some form of counter:
- Article views or blog metrics
- Product impressions on e-commerce storefronts
- Video plays and streaming metrics
- Profile visits and user portfolios
- Social media post likes and comment etc.
- Download counts for digital assets
- API usage tracking and rate-limiting metrics
The most common implementation looks like this:
$post->increment('views');
Looks Simple. Elegant. And completely fine for low-traffic applications.
The problem starts when traffic grows. Imagine a piece of breaking news or a viral product drop receiving 500 requests per second (RPS). Every single incoming web request executes an isolated database update:
UPDATE posts SET views = views + 1 WHERE id = 123;
At first glance, this seems clean and harmless. After all, the database is only updating a single number so far, right?
But under heavy traffic, hundreds or thousands of concurrent requests are all struggling to modify the exact same row at the exact same millisecond.
To maintain data integrity, your relational database management system (RDBMS) must serialize these requests. Suddenly, a simple counter makes your table one of the busiest tables in your system.
I've seen production systems where a single "views" column generated more database traffic and lock wait times as compared to all other actual business-critical operations combined. The database wasn't struggling because of complex, poorly-indexed JOIN queries; it was struggling because thousands of concurrent users were struggling the update the same locked row in database.
Why Standard Laravel eloquent counter update Fail at such high traffic ?
Let's assume 1,000 users open the same article simultaneously. Laravel receives $post->increment('views') 1,000 times, and fires 1,000 distinct UPDATE queries to MySQL.
Even though the SQL increment itself is atomic (views = views + 1), the physical row still becomes a massive hotspot. Under the hood, storage engines like InnoDB must acquire an exclusive row lock (X lock) to perform the update safely.
When 1,000 requests try to acquire an exclusive lock on row 123 at the same time:
- Request 1 locks the row, updates it, and commits.
- Requests 2 through 1,000 sit in a queue, waiting their turn.
- As the queue grows, thread pools saturate, database CPU spikes to 100%, and application response times degrade drastically.
This approach forces your database to process every single view immediately. But do we really need that? Does the reader care whether the view counter updates the exact millisecond they hit enter? Usually not. If the displayed count updates a few seconds or a minute later, nobody notices.
This gives us the access to implement a reliable architecture: Eventually Consistent Counters.
The Better Approach: Redis as a Write Buffer
Instead of hitting MySQL on every single incoming request, we can leverage an in-memory memory structure to absorb the impact:
- Store and aggregate increments rapidly inside Redis.
- Let Redis handle the high-frequency concurrent write load.
- Periodically flush those counts back to MySQL in a single, batched background operation.
User Request ──► Redis INCR ──► Memory Storage ──► Scheduled Flush ──► MySQL Batched Update
By shifting the heavy lifting to memory, instead of 10,000 individual database updates, we might perform only 1 batched update. That is a 99.99% reduction in direct database load.
Why Redis Works So Well
Redis provides the native INCR (Increment) command. This operation is perfectly suited for this workload because it is:
- Atomic: Redis is single-threaded at its core command execution layer, meaning two operations cannot interfere with each other. There are zero race conditions.
- Extremely Fast: Operating purely in-memory, Redis can easily handle over 100,000 operations per second on an average hardware.
- Non-Blocking: It handles massive concurrency without row-level locking overhead.
Even if thousands of concurrent requests arrive simultaneously, calling Redis::incr('post:123:views') guarantees an accurate value without breaking a sweat or hogging your database CPU.
Step-by-Step Implementation
Step 1: Record Views in Redis with an Active Set Tracking
Instead of using Redis::keys(), we will use a Redis Set (SADD) to keep track of exactly which posts have received traffic. This ensures our background worker doesn't have to scan millions of keys blindly.
final class ViewCounterService
{
private const PREFIX = 'post:views:';
private const TRACKER_SET = 'posts:has_views';
public function increment(int $postId): void
{
// 1. Atomically increment the specific post view count in memory
Redis::incr(self::PREFIX . $postId);
// 2. Track this post ID in a unique set so we know it needs flushing later
Redis::sadd(self::TRACKER_SET, $postId);
}
}
Now, hook this into your controller or middleware layer:
public function show(Post $post, ViewCounterService $counterService)
{
// Absorb the write traffic at memory speed
$counterService->increment($post->id);
return PostResource::make($post);
}
At this point, MySQL is not being touched at all. Your application handles incoming traffic spikes seamlessly because it is writing to RAM rather than disk.
Step 2: Create a Bulletproof Flush Command
Next, we need an Artisan command that periodically shifts these numbers from Redis into MySQL. We must write this carefully to avoid data loss if a database crash happens mid-process.
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\DB;
final class FlushViewCounters extends Command
{
protected $signature = 'views:flush';
protected $description = 'Flush aggregated view counters from Redis to MySQL safely.';
public function handle(): int
{
$trackerSet = 'posts:has_views';
// Fetch all unique Post IDs that received views since the last flush
$postIds = Redis::smembers($trackerSet);
if (empty($postIds)) {
$this->info('No view counters to flush.');
return self::SUCCESS;
}
foreach ($postIds as $postId) {
$redisKey = "post:views:{$postId}";
// Get the current accumulated count
$count = (int) Redis::get($redisKey);
if ($count <= 0) {
Redis::srem($trackerSet, $postId);
continue;
}
// Wrap the DB write and Redis cleanup in a safe transaction pipeline
DB::transaction(function () use ($postId, $count, $redisKey, $trackerSet) {
// Upsert via raw statement to maximize write throughput
DB::statement(
'INSERT INTO post_views (post_id, views, created_at, updated_at)
VALUES (?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE views = views + VALUES(views), updated_at = NOW()',
[$postId, $count]
);
// Atomically decrement the processed count from Redis to prevent losing
// views that occurred *while* this loop was executing.
Redis::decrby($redisKey, $count);
// If it reached 0, clean up the tracking set completely
if ((int) Redis::get($redisKey) <= 0) {
Redis::srem($trackerSet, $postId);
}
});
}
$this->info('Successfully flushed batched views to MySQL.');
return self::SUCCESS;
}
}
Why ON DUPLICATE KEY UPDATE Matters
Without an "Upsert" (Insert or Update) approach, your background script would have to execute an expensive two-step check for every post:
- Run a SELECT query to see if the record exists.
- Run an INSERT if missing, or an UPDATE if present.
This doubles your database round-trips. By using ON DUPLICATE KEY UPDATE, MySQL checks its internal primary/unique indexes directly. If the post_id already exists, it skips the insert and applies the addition in place. One highly efficient database query per active post.
Step 3: Schedule Automatic Flushing
Register your command within your application's scheduler (app/Console/Kernel.php or routes/console.php depending on your Laravel version):
use Illuminate\Support\Facades\Schedule;
Schedule::command('views:flush')->everyMinute();
Now, every 60 seconds, the scheduler wakes up, reads our redis for updates, the groups them together and sync the data to MySQL in batch processes and goes to sleep again.
Solving Advanced Production Edge Cases
Real-Time Counter Displays
When you implement eventual consistency, your backend database lag can confuse frontend users or internal product managers. If they refresh a post they know is viral, the counter won't appear to move for a whole minute.
To fix this, write a hybrid query method on your repository or Model that combines the historical disk value with the hot memory cache value:
use public function getRealTimeViewsAttribute(): int
{
$cachedViews = (int) Redis::get("post:views:{$this->id}");
// Sum the persistent DB integer and the un-flushed memory integer
return $this->views + $cachedViews;
}
This gives you the best of both worlds: 100% accurate, real-time feedback for the end user, with zero write load applied to MySQL.
Multi-Server Multi-App Architecture
If your application scales up horizontally across multiple cloud servers behind a Load Balancer (e.g., AWS EC2 instances or Docker containers), this pattern remains perfectly intact.
As long as all of your independent application instances connect to a single, centralized Redis cluster, Redis serves as the single source of truth for your buffered data. Server 1 and Server 2 can simultaneously issue INCR commands to the same central Redis key without stepping on each other's toes.
The Engineering Trade-Offs
No architectural optimization comes for free. When deciding if you should implement a write buffer, consider this breakdown:
Advantages
✅ Eliminates database hotspots
✅ Removes row locking pressure
✅ Handles massive traffic spikes
✅ Highly accurate counting
✅ Extremely fast writes
✅ Reduces database CPU usage
Disadvantages
❌ Counter updates become eventually consistent
❌ Additional Redis dependency
❌ Flush process must be monitored
❌ Slightly more complex architecture
❌ Potential data loss if Redis isn't configured properly
Final Thoughts
The biggest mistake developers make with heavy metric tracking is treating every simple increment as an immediate database write.
At a small scale, $post->increment('views') is completely acceptable. It’s fast to write and easy to reason about. But when traffic scales, high-frequency counters quickly becomes your application's primary performance bottleneck.
A Redis buffer changes the rules of the game. By letting memory absorb incoming write traffic at rapid speeds, you protect your relational database, lower resource consumption, and ensure your system stays up when traffic spikes.
Remember: Sometimes the fastest database query is the one you never execute.
-compressed.jpg)