A weekend web toy has a brutal traffic shape: nothing at all, then thousands of concurrent users inside ten minutes, then nothing again. Free tiers do not forgive that. Here is how I built one that stays inside them.
I built HOLD in a weekend. One crown exists. Whoever took it last holds it, and a timer counts their reign. Anyone can take it — but you have to press and hold for three seconds, and once you lose it you sit out for a minute. Longest single reign wins.
That is the entire game, and the game is not the interesting part. The interesting part is that a toy like this either gets almost no traffic or a frightening amount of it, with very little in between — and the version that gets the frightening amount usually falls over, or quietly runs up a bill, at exactly the moment it is working.
One Million Checkboxes is the canonical example. Nolen Royalty expected a few hundred visitors and got 650 million interactions in a fortnight. If you are building this kind of thing, you are writing software for a distribution you cannot predict, on infrastructure you are not paying for.
The naive design, and why it fails
The obvious way to build a live shared leaderboard is websockets. Everyone connects, the server pushes changes, everybody sees the same thing instantly. It is the correct answer on a long-running server you control.
It is a bad answer on serverless. Serverless platforms bill and scale around short requests, not thousands of connections held open for hours. You end up fighting the execution model, and the free tier disappears fast.
So: polling. Every client asks "who holds the crown?" every couple of seconds. Simple, stateless, and it fits the platform. Except the arithmetic is unpleasant:
10,000 concurrent users
÷ 2 second poll interval
= 5,000 requests per second
Upstash free tier: 10,000 commands per day
Time to exhaust it: about two seconds
That is the whole problem in four lines. The database is not slow — it is simply never meant to see a crowd this size for a payload this trivial.
Let the CDN do the fan-out
The thing worth noticing is that every one of those 5,000 requests wants the identical answer. There is one crown. Everybody is asking the same question, and the answer is a few dozen bytes.
So it should be cached — not in the application, but at the edge, in front of it:
export async function GET() {
const state = await readFromRedis();
return new Response(JSON.stringify(state), {
headers: {
'content-type': 'application/json',
'cache-control': 'public, s-maxage=1, stale-while-revalidate=4',
},
});
}
s-maxage=1 tells the CDN it may serve this response to everyone for one second. stale-while-revalidate=4 says that if it is a little older than that, serve it anyway and refresh in the background — so nobody ever waits on a cache miss.
The arithmetic changes completely:
10,000 users × 0.5 requests/second = 5,000 req/s at the edge
= 1 req/s at the origin
Redis load: unchanged whether ten people are playing
or ten thousand
State can be up to a second stale. For a leaderboard where reigns are measured in minutes and hours, that is invisible. I traded one second of accuracy for four orders of magnitude of headroom, and it is the single decision that makes the whole thing viable on a free tier.
Five consecutive requests against production, straight after deploying:
MISS → HIT → STALE → HIT → STALE
Only the first touched the origin. Everything after came from the edge.
Counting people without storing people
"N here now" is the small piece of theatre that makes a live page feel live. The naive implementation is a set of session IDs, which grows with your traffic — precisely the wrong shape.
Redis has a better answer. HyperLogLog is a probabilistic counter: it will tell you roughly how many distinct things it has seen, using a fixed twelve kilobytes, whether that is a hundred people or ten million. The error is around 0.8%, which for a number in the corner of a screen is not error at all.
PFADD seen:<minute> <session-id>
PFCOUNT seen:<minute>
Bucketed by the minute and expired after three, so it stays a rolling count and cleans up after itself.
Making cheating boring
A game where a single HTTP request wins dies within an hour of going viral. Somebody writes twelve lines of Python, the leaderboard fills with a machine, and every human leaves. So the first question is not "how do I stop bots" but "how do I make botting dull enough that nobody bothers?"
Three things, none of them clever:
- Hold, don't click. Three seconds of continuous pointer contact. The client reports how long it held; the server rejects anything under the threshold. It does not stop a scripted client — it means a bot has to wait three seconds too, which removes the entire advantage of being a bot.
- A cooldown on the loser. Sixty seconds before whoever just lost the crown can take it back, keyed to their session. This stops two accounts trading it back and forth and, more importantly, gives the crown time to feel held.
- Per-IP rate limiting. Twenty attempts a minute, as a fixed window in Redis.
None of this survives a determined attacker with a residential proxy pool. That was never the goal. The goal is that the marginal reward for cheating is lower than the effort, and against that bar it holds up.
What I would do differently
The hold-duration check trusts a number the client sends. A scripted client can lie about it. I could sign a server-issued token at press-start and verify the elapsed time server-side — it is maybe thirty lines. I left it out because the failure mode is somebody cheating at a toy, and shipping this weekend was worth more than closing that hole.
The other thing: the leaderboard only records a reign when it ends. If somebody takes the crown and the game goes quiet for six hours, their reign is real but invisible until someone dethrones them. Fixing that properly needs a scheduled job, which is a lot of machinery for an edge case.
Both of those are the correct kind of shortcut. They are documented, they are bounded, and neither of them is load-bearing.
The shape of the whole thing
| Concern | Approach |
|---|---|
| Live state for many readers | Edge cache, s-maxage=1 |
| Presence counting | HyperLogLog, fixed 12 KB |
| Write contention | Uncached, rate limited |
| Bot resistance | Hold-to-act + cooldown |
| Diagnosis | /api/health |
That last row earned its place the hard way. The first deploy returned blank 500s with no error text, and I spent longer than I would like admitting reading logs that said "message": "". So I added a health endpoint that reports, in plain words, whether the environment variables are present, whether the URL is well formed, and whether it can reach Redis. It found the problem in one request.
If a system can fail in production, it should be able to explain how it failed without you reading its source code.
Was it worth a weekend?
Honestly, most things like this get almost no traffic. That is the base rate and no amount of craft changes it — Royalty expected a few hundred visitors.
But the interesting engineering here had nothing to do with whether anyone plays. Making a CDN absorb a crowd on behalf of a database is a real technique, and it applies to any read-heavy endpoint where everybody wants the same answer: a status page, a live score, a stock ticker, a queue position. The toy was just a cheap excuse to build the thing properly.
Play it at hold-theta.vercel.app. The source is on GitHub.
I build and run production software end to end — most recently a hospital ERP live in Nepal, and three React Native apps with 100,000+ installs. Available for remote contract work. More about me · aryalsujay@gmail.com