A soundboard needs a "what's hot" page. The obvious version is a list sorted by total plays, and it goes stale almost immediately. The clips that were funny two years ago have a huge head start, so they sit at the top forever and nothing new can climb past them.
What people actually want is "what is everyone tapping right now". On Meme Tap the trending page is described as the sound buttons people are tapping the most this week. That one sentence hides a few real engineering decisions: how to score recency, how to count plays without accounts, how to stop one bored person with an autoclicker from owning the page, and how to serve the list cheaply.
This post walks through a pattern I like for this: an exponentially decayed score stored in log space, a small play-counting endpoint, and a cached read path in the Next.js App Router. The code is a general version of the idea, not a copy of any production codebase.
Why a 7-day window isn't quite enough
The first thing most people try is "count plays in the last 7 days":
SELECT sound_id, count(*) AS plays
FROM play_events
WHERE created_at > now() - interval '7 days'
GROUP BY sound_id
ORDER BY plays DESC
LIMIT 48;
It works, but it has two problems.
- The cliff. A play from 6 days 23 hours ago counts fully; a minute later it counts for nothing. Rankings jump around at odd moments for no visible reason.
- Cost. You have to keep every raw event and scan a week of them on every refresh. For a busy board that's a lot of rows to aggregate just to render one page.
What we want is a score where recent plays count a lot, older plays count a little, and nothing falls off a cliff.
Exponential decay with a half-life
Give every play a weight that halves every H hours:
weight(play) = 2 ^ ( -(now - playedAt) / H )
score(sound) = sum of weight over all its plays
With H = 48, a play right now is worth 1, a play two days ago is worth 0.5, four days ago 0.25, and a play from last month is effectively zero. A sound that is being tapped a lot today will overtake one that had a big week a fortnight ago, which is exactly the "trending" feeling.
The naive implementation still needs every event, because now keeps moving. The trick is to flip the reference point.
Store the score relative to a fixed epoch
Instead of decaying old plays towards now, inflate new plays relative to a fixed point in the past:
increment = 2 ^ ( (playedAt - EPOCH) / H )
Every sound's score is multiplied by the same factor as time passes, so the ordering never changes when you just wait. That means you can store a single number per sound, add to it on every play, and sort by it directly. No background job has to touch every row.
The catch is that 2^(t/H) grows without limit and will overflow a float within months. So store the logarithm instead and add in log space:
// lib/trending.ts
const HALF_LIFE_HOURS = 48;
const EPOCH_MS = Date.UTC(2026, 0, 1);
/** log2 of one play's weight, relative to the epoch */
export function playExponent(playedAtMs: number): number {
return (playedAtMs - EPOCH_MS) / (HALF_LIFE_HOURS * 3_600_000);
}
/** log2(2^a + 2^b) without overflow */
export function logAdd(a: number, b: number): number {
if (!Number.isFinite(a)) return b; // a = -Infinity means "no plays yet"
const hi = Math.max(a, b);
const lo = Math.min(a, b);
return hi + Math.log2(1 + 2 ** (lo - hi));
}
/** Convert a stored log score into "plays-equivalent right now", for display or debugging */
export function currentWeight(logScore: number, nowMs = Date.now()): number {
return 2 ** (logScore - playExponent(nowMs));
}
logAdd keeps everything in a range a double handles comfortably. currentWeight is handy when you want to sanity-check the numbers: "this sound is worth about 37 fresh plays right now".
The table and the upsert
In Postgres the whole thing is one row per sound:
CREATE TABLE sound_trending (
sound_id text PRIMARY KEY,
log_score double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sound_trending_rank ON sound_trending (log_score DESC);
And the log-space addition can happen inside the database, so concurrent plays don't race each other in application code:
INSERT INTO sound_trending (sound_id, log_score)
VALUES ($1, $2)
ON CONFLICT (sound_id) DO UPDATE SET
log_score = GREATEST(sound_trending.log_score, EXCLUDED.log_score)
+ ln(1 + power(2, LEAST(sound_trending.log_score, EXCLUDED.log_score)
- GREATEST(sound_trending.log_score, EXCLUDED.log_score))) / ln(2),
updated_at = now();
$2 is playExponent(Date.now()). The expression is the same logAdd as above, written in SQL.
Counting a play without accounts
A soundboard with no sign-up has no user id to deduplicate on, and it shouldn't try to build one. What you can do is make counting cheap to send and hard to inflate.
On the client, send the play in the background so it never delays the sound:
function reportPlay(soundId) {
const body = JSON.stringify({ soundId });
if (!navigator.sendBeacon?.('/api/play', new Blob([body], { type: 'application/json' }))) {
fetch('/api/play', { method: 'POST', body, keepalive: true }).catch(() => {});
}
}
On the server, a route handler applies two cheap rules before touching the table: the same visitor tapping the same sound counts once per window, and each visitor has a small budget of counted plays per minute. Everything above that still plays; it just doesn't move the ranking.
// app/api/play/route.ts
import { createHash } from 'node:crypto';
import { playExponent } from '@/lib/trending';
import { db, kv, UPSERT_SQL } from '@/lib/server'; // UPSERT_SQL = the statement above
export async function POST(req: Request) {
const { soundId } = await req.json().catch(() => ({}));
if (typeof soundId !== 'string' || soundId.length > 120) return new Response(null, { status: 400 });
const day = new Date().toISOString().slice(0, 10);
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '';
const ua = req.headers.get('user-agent') ?? '';
// Short-lived, salted fingerprint: good enough for rate limiting, useless for tracking
const visitor = createHash('sha256').update(`${process.env.PLAY_SALT}:${day}:${ip}:${ua}`).digest('hex').slice(0, 16);
const firstTapInWindow = await kv.set(`p:${visitor}:${soundId}`, 1, { nx: true, ex: 30 * 60 });
const minuteKey = `r:${visitor}:${Math.floor(Date.now() / 60_000)}`;
const perMinute = await kv.incr(minuteKey);
if (perMinute === 1) await kv.expire(minuteKey, 120);
if (firstTapInWindow && perMinute <= 20) {
await db.query(UPSERT_SQL, [soundId, playExponent(Date.now())]);
}
return new Response(null, { status: 204 });
}
kv here is any store with SET NX EX and INCR (Redis or a hosted equivalent). The fingerprint rotates daily with the salt, so it's only useful for the thing it's for. Treat it as a speed bump, not a security boundary: anyone determined can rotate IPs, and that's fine for a meme page. Also check what your hosting platform puts in x-forwarded-for, because that varies.
Reading the list cheaply
Rendering the page is now a plain indexed query, and it doesn't need to be live to the second. In the App Router you can let the page be statically rendered and revalidated on a timer:
// app/trending/page.tsx
import { db } from '@/lib/server';
export const revalidate = 600; // rebuild at most every 10 minutes
export default async function TrendingPage() {
const { rows } = await db.query(
`SELECT s.id, s.title, s.slug, s.audio_url
FROM sound_trending t JOIN sounds s ON s.id = t.sound_id
ORDER BY t.log_score DESC
LIMIT 48`
);
return <SoundGrid sounds={rows} />;
}
Ten minutes of staleness is invisible on a "this week" page, and it means the database sees a handful of reads an hour instead of one per visitor.
Tuning it
A few things I'd adjust after watching real data:
- Half-life. 24 hours feels jumpy; a week feels like "all time" again. Something around two to three days matches the "this week" wording.
- Brand-new uploads. A clip added this morning has had no time to collect plays. Rather than boosting it artificially, give new clips their own newest-first page and let trending stay honest.
-
Sanity checks. Log
currentWeightfor the top few sounds now and then. If one sound is worth thousands of fresh plays while the next is worth twenty, look at the rate limits before you celebrate.
You can see what the end result feels like on Meme Tap's trending sound buttons page, a mix of meme buttons, funny sounds and short effects that people are playing most at the moment. The pattern itself works for anything with a "hot right now" list: posts, products, search terms or snippets.
Abid Ali is a full-stack web developer in Pakistan. He builds Meme Tap, a free browser soundboard with one-tap meme sound buttons.













