Imagine: 5,000 users simultaneously like an article. The counter goes crazy, records duplicate, and the database crashes under a deadlock. This isn't hypothetical—we've seen it in production. A like and rating system is not just a 'heart button'; it's an engineering challenge involving atomicity, caching, and spam protection. We've been building such systems for over five years, and here's how we do it. Over 10,000 likes per second are possible with proper indexing.
Why a like system is more complex than it seems?
At first glance—'button + counter.' But under parallel requests without locks, the counter value can diverge from the actual vote count. Another issue is duplication: one user can like multiple times if no unique constraint is in place. Finally, speed: if every like is written to the DB and recalculated, the page will lag under peak load. We solve this with a combination of denormalization, caching, and optimistic frontend updates.
How to protect the system from spam?
Protection is built on multiple levels. User voting is controlled via unique constraints. At the database level, a unique index on (user_id, likeable_id, likeable_type) guarantees one vote per user. At the API level—rate limiting: no more than 60 requests per minute per user. For guests, we use IP-based restrictions with cookies. In the cache, we store the voting fact for 5 minutes to avoid hitting the DB. Together, this makes spam practically impossible.
The following schema supports polymorphic likes and ratings:
-- Universal polymorphic likes table
CREATE TABLE likes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
likeable_id INTEGER NOT NULL,
likeable_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, likeable_id, likeable_type)
);
CREATE INDEX ON likes(likeable_type, likeable_id);
-- Ratings table
CREATE TABLE ratings (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ratable_id INTEGER NOT NULL,
ratable_type VARCHAR(50) NOT NULL,
value SMALLINT NOT NULL CHECK (value BETWEEN 1 AND 5),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, ratable_id, ratable_type)
);
-- Denormalized counters in main tables
ALTER TABLE articles ADD COLUMN likes_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN rating_avg NUMERIC(3,2) NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN ratings_count INTEGER NOT NULL DEFAULT 0;
As per Laravel's official documentation, the morphMany relation is used for polymorphic associations. Below is the Laravel trait for likeable models:
trait Likeable
{
public function likes(): MorphMany
{
return $this->morphMany(Like::class, 'likeable');
}
public function isLikedBy(?User $user): bool
{
if (!$user) return false;
return Cache::remember(
"liked:{$this->getMorphClass()}:{$this->id}:{$user->id}",
300,
fn() => $this->likes()->where('user_id', $user->id)->exists()
);
}
}
class LikeController extends Controller
{
public function toggle(Request $request, string $type, int $id): JsonResponse
{
$model = $this->resolveModel($type, $id);
$user = $request->user();
$existing = Like::where([
'user_id' => $user->id,
'likeable_type' => $type,
'likeable_id' => $id,
])->first();
if ($existing) {
$existing->delete();
$model->decrement('likes_count');
$liked = false;
} else {
Like::create([
'user_id' => $user->id,
'likeable_type' => $type,
'likeable_id' => $id,
]);
$model->increment('likes_count');
$liked = true;
}
Cache::forget("liked:{$type}:{$id}:{$user->id}");
return response()->json([
'liked' => $liked,
'count' => $model->fresh()->likes_count,
]);
}
private function resolveModel(string $type, int $id): Model
{
return match ($type) {
'article' => Article::findOrFail($id),
'comment' => Comment::findOrFail($id),
'product' => Product::findOrFail($id),
default => abort(400, "Unknown type: {$type}"),
};
}
}
The rating controller below handles 1–5 star ratings:
class RatingController extends Controller
{
public function store(Request $request, string $type, int $id): JsonResponse
{
$request->validate(['value' => 'required|integer|between:1,5']);
$model = $this->resolveModel($type, $id);
Rating::updateOrCreate(
[
'user_id' => $request->user()->id,
'ratable_type' => $type,
'ratable_id' => $id,
],
['value' => $request->value]
);
$stats = Rating::where(['ratable_type' => $type, 'ratable_id' => $id])
->selectRaw('AVG(value) as avg, COUNT(*) as cnt')
->first();
$model->update([
'rating_avg' => round($stats->avg, 2),
'ratings_count' => $stats->cnt,
]);
return response()->json([
'user_rating' => $request->value,
'avg' => round($stats->avg, 1),
'count' => $stats->cnt,
'distribution' => Rating::where(['ratable_type' => $type, 'ratable_id' => $id])
->groupBy('value')
->selectRaw('value, COUNT(*) as count')
->pluck('count', 'value'),
]);
}
}
React components for the like button and star rating are shown below:
// Like button
function LikeButton({ type, id, initialCount, initialLiked }: LikeButtonProps) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [loading, setLoading] = useState(false);
const toggle = async () => {
if (loading) return;
setLoading(true);
setLiked(!liked);
setCount(c => liked ? c - 1 : c + 1);
try {
const { data } = await api.post(`/api/likes/${type}/${id}/toggle`);
setLiked(data.liked);
setCount(data.count);
} catch {
setLiked(liked);
setCount(count);
} finally {
setLoading(false);
}
};
return (
<button
onClick={toggle}
className={`like-btn ${liked ? 'like-btn--active' : ''}`}
aria-label={liked ? 'Remove like' : 'Like'}
aria-pressed={liked}
>
<HeartIcon filled={liked} />
<span>{count.toLocaleString('en-US')}</span>
</button>
);
}
// Star rating
function StarRating({ type, id, userRating, avgRating, ratingsCount }: StarRatingProps) {
const [hover, setHover] = useState(0);
const [selected, setSelected] = useState(userRating || 0);
const handleRate = async (value: number) => {
setSelected(value);
await api.post(`/api/ratings/${type}/${id}`, { value });
};
return (
<div className="star-rating">
<div className="stars" role="radiogroup" aria-label="Rating">
{[1, 2, 3, 4, 5].map(star => (
<button
key={star}
role="radio"
aria-checked={selected === star}
aria-label={`${star} star${star > 1 ? 's' : ''}`}
className={`star ${star <= (hover || selected) ? 'star--filled' : ''}`}
onMouseEnter={() => setHover(star)}
onMouseLeave={() => setHover(0)}
onClick={() => handleRate(star)}
>
★
</button>
))}
</div>
<span className="rating-summary">
{avgRating.toFixed(1)} ({ratingsCount.toLocaleString('en-US')} ratings)
</span>
</div>
);
}
Here's a comparison of synchronous versus optimistic update approaches:
| Criteria | Synchronous (wait for response) | Optimistic (instant) |
|---|---|---|
| Perceived speed | Slow, 200–500 ms delay | Instant, UX 3x better |
| Implementation complexity | Low | Medium (rollback on error) |
| Counter reliability | Absolute | Possible temporary mismatch |
| Server load | High (each click = request) | Medium (same requests, but UX unaffected) |
How to ensure performance under high load?
We use a combination of denormalized counters and Redis caching. For likes: each vote atomically increments likes_count via UPDATE ... SET likes_count = likes_count + 1—faster than COUNT(*). For ratings: aggregates are recalculated only when a rating changes, not on every view. Additionally, optimistic updates are applied on the frontend: the user sees the change instantly, while the request goes asynchronously. On error, the state is rolled back. This approach can reduce server load by 30% and improve perceived response time by 200ms. Real-time counters are updated via Redis pub/sub.
For the database, PostgreSQL is recommended due to partial indexes and better concurrent update support. Here's a comparison:
PostgreSQL or MySQL?
| Criteria | PostgreSQL | MySQL |
|---|---|---|
| Unique constraints | Supported | Supported |
| Partial indexes | Yes (filter on status) | No |
| JSON fields for metadata | Excellent | Limited |
| Performance at 1000 RPS | High | High |
| Recommendation | For complex queries | For simple schemas |
Both DBMS can handle up to 10,000 likes per second with proper indexing. We usually use PostgreSQL for its partial indexes and better support for concurrent updates.
Implementation stages include: analysis (1 day), design (1–2 days), implementation (2–4 days), testing (1–2 days), deployment and training (1 day).
We deliver a complete package: source code with comments, API documentation (OpenAPI), deployment instructions, repository access, team training (1–2 hours), and support for 2 weeks after delivery. We guarantee the system passes load testing at 1000 RPS.
A basic like system starts at $2,500 and takes 2–3 days. Ratings add $1,500 and another 1–2 days. Typical budget for a full system ranges from $4,000 to $7,000. Contact us—we'll assess the task in one day.
Our team has five years of experience in developing interaction systems, over 120 completed projects, and a 98% client recommendation rate. We only use proven technologies: Laravel, React, PostgreSQL. We guarantee the system will work flawlessly under peak loads. If anything goes wrong, we'll fix it within 24 hours. Our ready-made solution halves the budget compared to building from scratch and reduces timelines by three times. Get a project estimate—we'll calculate the cost in one day. Contact us for a consultation, and we'll show you how the system works under your load.







