Introduction
Not all popular videos are trending. A video with 100 million views that got them over 5 years isn't trending. A video with 500,000 views that got them in the last 6 hours is. The difference is velocity, and building a proper trending score algorithm is essential for any video platform. Here's how I built it for ViralVidVault.
The Formula
Our trending score combines three time-decayed factors:
trending_score = (velocity_score * 0.4) + (engagement_score * 0.35) + (recency_score * 0.25)
Each component is normalized to 0-100 before weighting.
Implementation
<?php
class TrendingScorer
{
private const VELOCITY_WEIGHT = 0.40;
private const ENGAGEMENT_WEIGHT = 0.35;
private const RECENCY_WEIGHT = 0.25;
// Half-life in hours for time decay
private const HALF_LIFE_HOURS = 12;
public function score(array $video, array $previousMetrics = []): float
{
$velocity = $this->velocityScore($video, $previousMetrics);
$engagement = $this->engagementScore($video);
$recency = $this->recencyScore($video);
return round(
($velocity * self::VELOCITY_WEIGHT)
+ ($engagement * self::ENGAGEMENT_WEIGHT)
+ ($recency * self::RECENCY_WEIGHT),
2
);
}
private function velocityScore(array $video, array $previous): float
{
if (empty($previous)) return 0;
$viewDelta = (int)$video['views'] - (int)$previous['views'];
$timeDelta = strtotime($video['checked_at']) - strtotime($previous['checked_at']);
if ($timeDelta <= 0) return 0;
$viewsPerHour = ($viewDelta / $timeDelta) * 3600;
// Normalize: 10,000 views/hour = score of 100
return min(100, ($viewsPerHour / 10000) * 100);
}
private function engagementScore(array $video): float
{
$views = max(1, (int)$video['views']);
$likes = (int)($video['likes'] ?? 0);
$comments = (int)($video['comments'] ?? 0);
// Engagement rate as percentage, comments weighted 2x
$rate = (($likes + $comments * 2) / $views) * 100;
// Normalize: 5% engagement = score of 100
return min(100, ($rate / 5) * 100);
}
private function recencyScore(array $video): float
{
$publishedAt = strtotime($video['published_at']);
$hoursAgo = (time() - $publishedAt) / 3600;
// Exponential decay with half-life
$decay = pow(0.5, $hoursAgo / self::HALF_LIFE_HOURS);
return round($decay * 100, 2);
}
}
Time Decay: The Secret Ingredient
The recency score uses exponential decay with a 12-hour half-life. This means:
| Hours since publish | Recency Score |
|---|---|
| 0 | 100.0 |
| 6 | 70.7 |
| 12 | 50.0 |
| 24 | 25.0 |
| 48 | 6.25 |
| 72 | 1.56 |
After 3 days, even a high-velocity video gets pushed down by fresher content. This keeps the trending feed feeling dynamic.
Batch Scoring in SQL
For large-scale scoring, doing it in PHP per-video is slow. Here's a SQL approach:
public function batchScore(): void
{
$this->db->exec('
UPDATE videos SET trending_score = (
-- Velocity component (requires previous metrics)
COALESCE(
(SELECT
MIN(100, ((v2.views - v1.views) / MAX(1,
(CAST(strftime("%s", v2.checked_at) AS INTEGER) -
CAST(strftime("%s", v1.checked_at) AS INTEGER))
) * 3600 / 10000.0) * 100) * 0.4
FROM video_metrics v1
JOIN video_metrics v2 ON v1.video_id = v2.video_id
AND v2.rowid = v1.rowid + 1
WHERE v1.video_id = videos.id
ORDER BY v2.checked_at DESC LIMIT 1),
0)
+
-- Engagement component
MIN(100, ((CAST(likes AS REAL) + comments * 2) / MAX(1, views) * 100 / 5) * 100) * 0.35
+
-- Recency component (12h half-life decay)
(POWER(0.5, (CAST(strftime("%s", "now") AS INTEGER) -
CAST(strftime("%s", published_at) AS INTEGER)) / 43200.0) * 100) * 0.25
)
WHERE is_active = 1
');
}
Querying the Trending Feed
public function getTrending(int $limit = 20, ?string $category = null): array
{
$sql = 'SELECT * FROM videos WHERE is_active = 1';
$params = [];
if ($category) {
$sql .= ' AND category_id = :cat';
$params[':cat'] = $category;
}
$sql .= ' ORDER BY trending_score DESC LIMIT :limit';
$params[':limit'] = $limit;
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
Tuning the Weights
The weights (0.4 velocity, 0.35 engagement, 0.25 recency) were tuned by observing what "felt right" on ViralVidVault. If velocity is weighted too high, clickbait dominates. If engagement is too high, niche content with dedicated fans ranks above broadly popular videos. The current balance works well for a general viral video platform.
Explore the trending feed at viralvidvault.com to see this algorithm in action.
Part of the "Building ViralVidVault" series.
Top comments (0)