A serverless game leaderboard on Upstash Redis
The scores in Rabbit Hole are just numbers a browser sends me, and I wanted a global leaderboard without standing up a game server or a database for it.
Upstash Redis turned out to be enough on its own.
Here's what I store, and how two Redis data types do most of the work.

The ranking is a sorted set
A leaderboard is exactly what a Redis sorted set is: members ordered by a score.
Submitting a score is one command, and the two flags on it are the whole trick.
const changed = await redis.zadd(
key,
{ gt: true, ch: true },
{ score: entry.score, member: entry.playerId },
);
Each player is a member and their score is the sorted-set score. gt: true means only update if the new score is higher, so the set keeps a single best per player without me reading the old value first. ch: true makes ZADD return how many members actually changed, which I use to decide whether to touch anything else.
More on that in a second.
Reading the top N is one more command, reversed so the highest comes first:
const raw = await redis.zrange(key, 0, limit - 1, { rev: true, withScores: true });
Names live in a companion hash
The sorted set holds the number, but I also want to show a name and the depth reached.
Those go in a parallel hash keyed by the same member, written only when ch told me the score actually improved:
if (changed) {
await redis.hset(hashKey, { [entry.playerId]: JSON.stringify(meta) });
}
That guard matters.
If I wrote the display data on every submit, a later lower score would overwrite the name and depth attached to someone's real best.
And when I read rows back, the sorted-set score wins over anything in the hash.
The number is always right, and if the hash ever drifts out of sync the display heals itself on the next read.
Two boards, rolling on their own
There's an all-time board and a daily one, keyed by the burrow and the date.
Two small things keep them from growing forever, which on a pay-per-use database is the difference between a cheap toy and a surprise bill.
The windows roll themselves because I re-set the expiry on every write.
Daily boards live a week, all-time ninety days, and idle keys just evaporate.
No cleanup job anywhere:
await redis.expire(key, daily ? SEVEN_DAYS : NINETY_DAYS);
And I cap how many members a board keeps.
After each write I drop everything below the top N:
await redis.zremrangebyrank(key, 0, -(KEEP + 1));
Without that, a popular board grows without limit and so does what it costs to store and read.
Trimming to the top entries turns an open-ended cost into a fixed one.
Rate limiting has to be shared
One serverless gotcha worth calling out.
An in-memory rate limiter does nothing when every request can land on a different instance, because each one has its own empty counter.
The counter has to live somewhere shared, so it goes in Redis too, as a fixed window:
const bucket = Math.floor(now / (WINDOW_SEC * 1000));
const n = await redis.incr(`rl:${key}:${bucket}`);
if (n === 1) await redis.expire(`rl:${key}:${bucket}`, WINDOW_SEC);
return n <= MAX;
INCR creates the key at 1 on the first hit of a window, I set the expiry once, and anything past the limit in that window gets turned away.
Wrapping up
No game server, no relational database, and no scheduled cleanup job.
The ranking goes in a sorted set, the display data like names in a hash, the windows roll on expiry, and the size stays capped with a rank trim.
For a small game that just needs a leaderboard, this setup runs for almost nothing and there's very little to maintain.