Engineering
How we implement a mass-scale thumbs up and thumbs down
Anonymous ratings, no accounts, no login wall — and no database read on the hot path. Here is the exact stack behind the thumbs on Vibro Music, from the Cloudflare Turnstile challenge down to the Wilson-score ranking job.
Why a thumbs button is harder than it looks
Vibro Music generates 10,000 deterministic visuals — every four-digit seed produces the same scene forever — plus a curated shader library. We wanted the crowd to surface the good ones. The obvious answer is a thumbs up and a thumbs down button.
The obvious answer is also where the problems start. We had four constraints that ruled out most off-the-shelf approaches:
- No accounts. The whole product is "open the page, point it at music." A login wall to rate a visual would kill the experience.
- No CAPTCHA friction. Nobody solves a puzzle grid to press a thumbs up. Whatever bot defence we picked had to be invisible in the normal case.
- Bounded cost. This is a free static site on a hobby budget. A viral day must not produce a surprise bill, which means the architecture needs a hard ceiling, not an autoscaler.
- Reads must be free. The "Top visuals" list is shown to everyone. If every page view queried a database for live counts, cost would scale with traffic — exactly the thing we cannot afford.
Without accounts you have no stable identity, so "one person, one vote" becomes "make ballot stuffing expensive enough that it is not worth it." That reframing is the key design move in this whole article. We are not trying to make cheating impossible. We are trying to make it cost more than the reward, at every layer independently.
The shape of the system
The single most important decision was splitting the write path from the read path completely. They share nothing but a table name.
The write path is a small, deliberately throttled Lambda behind CloudFront. It accepts one vote at a time, validates it through five independent layers, and writes a single item to DynamoDB. It has reservedConcurrentExecutions: 2 — a hard concurrency ceiling of two. That is not a typo. Voting is not latency-critical; if a spike queues briefly, nobody notices, and the ceiling makes runaway cost structurally impossible.
The read path is a JSON file. A scheduled job aggregates every vote once a day, computes the ranking, and writes /rankings/top-visuals-v1.json into the same S3 bucket that serves the site. The browser fetches it through the CDN like any other static asset. Ten million people can read the leaderboard and the database is not touched once.
Browser ──POST /api/v1/vote──▶ CloudFront ──OAC/SigV4──▶ Lambda URL ──▶ DynamoDB
│
▼
Cloudflare siteverify
EventBridge (daily) ──▶ Ranking Lambda ──Scan──▶ DynamoDB
│
└──PutObject──▶ S3 /rankings/top-visuals-v1.json
│
Browser ──GET /rankings/...json──▶ CloudFront ──────────┘ (cached, no compute)
The rule of thumb: if a number is shown to everyone but changed by almost nobody, precompute it. Voting systems are the textbook case — the write-to-read ratio is often 1:10,000. Optimising the read path to "static file on a CDN" is nearly free and removes your entire scaling problem.
Layer 1: Turnstile, invisibly
Cloudflare Turnstile is a CAPTCHA replacement that, in the overwhelming majority of cases, shows the user nothing at all. It runs a set of browser challenges — proof-of-work, behavioural signals, browser-integrity probes — and issues a short-lived token. Only when those signals look suspicious does it escalate to something the user has to interact with.
Three configuration choices matter more than the rest:
execution: 'execute'— do not run a challenge on page load. Run it the moment the user actually presses a thumb. A visualizer session where nobody votes never pays the cost.appearance: 'interaction-only'— the widget stays invisible unless it genuinely needs a human.action: 'rate_visual'— stamps the token with what it was issued for. We check this again on the server, so a token minted on some other form on some other page cannot be replayed against the vote endpoint.
www/ratings.js — requesting a token on demand
async function getTurnstileToken() {
const turnstile = await loadTurnstile();
return new Promise((resolve, reject) => {
const options = {
sitekey: config.turnstileSiteKey,
action: 'rate_visual',
execution: 'execute',
appearance: 'interaction-only',
callback: resolve,
'error-callback': () => reject(new Error('Bot check failed. Please try again.')),
'expired-callback': () => reject(new Error('Bot check expired. Please try again.')),
'timeout-callback': () => reject(new Error('Bot check timed out. Please try again.'))
};
if (widgetId === undefined) widgetId = turnstile.render(elements.turnstileMount, options);
turnstile.execute(widgetId);
});
}
The Turnstile script itself is loaded lazily, on first vote, and memoised in a promise so concurrent presses share one load. The ?render=explicit query parameter stops the library from auto-scanning the DOM, which we do not want on a full-screen canvas app.
One detail that is easy to miss: a Turnstile token is single-use. After every vote — success or failure — we remove the widget so the next vote mints a fresh token.
function resetTurnstile() {
try {
if (widgetId !== undefined && window.turnstile) window.turnstile.remove(widgetId);
} catch {
// The challenge may already have removed an expired widget.
}
widgetId = undefined;
}
Layer 2: verifying the token server-side
A token the client sends is a claim, not a fact. The server exchanges it with Cloudflare's siteverify endpoint using the secret key, and then — this is the part people skip — checks the response fields, not just success.
server/vote-handler.mjs
async function validateTurnstile(token, viewerAddress) {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
secret: TURNSTILE_SECRET,
response: token,
remoteip: extractViewerIp(viewerAddress)
})
});
if (!response.ok) throw requestError(403, 'Challenge rejected.');
const result = await response.json();
const validHostname = result.hostname === DOMAIN_NAME
|| result.hostname === `www.${DOMAIN_NAME}`
|| result.hostname === 'localhost';
if (!result.success || !validHostname || result.action !== 'rate_visual') {
throw requestError(403, 'Challenge rejected.');
}
}
Three assertions, three distinct attacks closed:
result.success— the token is real and unused.result.hostname— the token was minted on our domain. Without this check, an attacker embeds your site key on their own page, farms tokens from real visitors there, and replays them at your API.result.action— the token was minted for the vote button specifically.
We also pass remoteip, taken from CloudFront's CloudFront-Viewer-Address header rather than anything client-supplied, which lets Cloudflare cross-check the token against the network it was issued to.
Do not trust X-Forwarded-For. It is client-writable unless something upstream strips and rewrites it. We use the CloudFront-injected CloudFront-Viewer-Address header via the AWS-managed AllViewerExceptHostHeader origin request policy, and the Lambda function URL is AWS_IAM-authenticated with an Origin Access Control so it cannot be called directly at all. If the header is missing, the request is rejected rather than defaulted.
Layer 3: identity without accounts
Turnstile proves "a browser that looks human did this." It does not say which browser, so it cannot stop the same person voting a thousand times. For that we need a durable pseudonymous identity, and we build one from an HMAC-signed cookie.
Everything hangs off one master secret, but that secret is never used directly. Each purpose gets its own derived key, so a leak in one context cannot forge another:
export function deriveKey(masterSecret, label) {
return createHmac('sha256', masterSecret).update(`vibromusic:${label}:v1`).digest();
}
The cookie holds a random identifier, an expiry, and a version, signed together with the hostname:
export function signVoterCookie(masterSecret, hostname, nowSeconds, voterId = randomBytes(16).toString('base64url')) {
const expires = nowSeconds + 365 * DAY_SECONDS;
const payload = `${voterId}.${expires}.1`;
const signature = createHmac('sha256', deriveKey(masterSecret, 'cookie'))
.update(`${payload}.${hostname}`)
.digest('base64url');
return { voterId, value: `${payload}.${signature}`, expires };
}
It is issued Secure; HttpOnly; SameSite=Lax and scoped to Path=/api — the cookie is never sent on ordinary page or asset requests, so it costs nothing on the cached read path and JavaScript cannot read it.
Verification is strict about shape before it spends a hash, and the comparison is constant-time:
const parts = value.split('.');
if (parts.length !== 4 || parts[2] !== '1' || !/^\d+$/.test(parts[1])) return null;
const [voterId, expiryText, version, supplied] = parts;
if (!/^[A-Za-z0-9_-]{20,30}$/.test(voterId) || expires <= nowSeconds) return null;
// ... recompute expected signature ...
return safeEqualText(supplied, expected) ? { voterId, expires } : null;
Finally — and this is the privacy-relevant bit — the voter ID from the cookie is never what we store. We store a one-way HMAC of it under a different derived key:
export function voterHash(voterId, masterSecret) {
return createHmac('sha256', deriveKey(masterSecret, 'voter')).update(voterId).digest('hex');
}
Someone with read access to the table sees opaque hashes. They cannot turn a row back into a cookie, and they cannot take a cookie they captured and find its rows without the master secret.
Of course, a cookie can be cleared. That is fine — it is one layer, and clearing it drops you into the next one.
Layer 4: hashed network buckets
Clearing cookies is free. Changing networks is not. So the second identity signal is the viewer's network prefix — not the full address, and never stored raw:
export function networkHash(viewerAddress, masterSecret, utcDate) {
const ip = extractViewerIp(viewerAddress);
const normalized = normalizeNetwork(ip); // /24 for IPv4, /56 for IPv6
return createHmac('sha256', deriveKey(masterSecret, 'network'))
.update(`${utcDate}:${normalized}`)
.digest('hex');
}
Three properties make this defensible rather than creepy:
- Truncated. IPv4 collapses to a
/24, IPv6 to a/56. We are identifying a neighbourhood, not a household. This matters for IPv6 in particular, where a single subscriber is routinely handed a whole/64or larger and naive per-address limits are useless. - Hashed. The stored value is an HMAC. There is no IP address anywhere in the table or the logs.
- Rotated daily. The UTC date is part of the HMAC input, so the same network produces a different hash tomorrow. The value is useful for a 24-hour rate-limit window and worthless as a long-term tracking key.
The IPv6 normaliser is worth showing, because :: expansion is where hand-rolled parsers usually break:
export function normalizeNetwork(ip) {
const version = isIP(ip);
if (version === 4) return `${ip.split('.').slice(0, 3).join('.')}.0/24`;
if (version !== 6) throw requestError(403, 'Request rejected.');
const groups = expandIpv6(ip);
const fourth = Number.parseInt(groups[3], 16) & 0xff00;
return `${groups[0]}:${groups[1]}:${groups[2]}:${fourth.toString(16)}::/56`;
}
Layer 5: atomic rate limits
Now that we have two identity signals, we spend them on quotas. Four counters guard every vote:
Every counter is a DynamoDB item with a TTL. Breaching any one of them returns HTTP 429.
| Counter | Key | Limit | Stops |
|---|---|---|---|
| Per voter, per day | LIMIT#<date> / VOTER#<hash> | 100 | A single cookie grinding through the catalogue |
| Per network, per day | LIMIT#<date> / NETWORK#<hash> | 300 | Cookie-clearing loops from one place |
| Per network, per target | LIMIT#<date>#<target> / NETWORK#<hash> | 10 | Brigading one specific visual |
| Global, per month | QUOTA#<month> / GLOBAL | 100,000 | The bill |
The third one is the interesting limit. Per-voter and per-network caps are generous by design — a genuinely enthusiastic user should never hit them. But there is no legitimate reason for one network to push ten votes at one visual in a day, and that is precisely the shape of a manipulation attempt.
Read-then-write would race under concurrency. Instead each counter is a single conditional update — DynamoDB evaluates the condition and the increment atomically, and throws if the limit is already reached:
async function incrementLimit(key, limit, expiresAt) {
await documentClient.send(new UpdateCommand({
TableName: TABLE_NAME,
Key: key,
UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :one, expiresAt = :expiresAt, entity = :entity',
ConditionExpression: 'attribute_not_exists(#count) OR #count < :limit',
ExpressionAttributeNames: { '#count': 'count' },
ExpressionAttributeValues: { ':zero': 0, ':one': 1, ':limit': limit, ':expiresAt': expiresAt, ':entity': 'limit' }
}));
}
A failed condition surfaces as ConditionalCheckFailedException, which the handler maps straight to a 429. The daily counters carry a two-day TTL, so DynamoDB deletes them for free and the table never accumulates limit garbage.
Writing the vote
The vote itself is one item in a single-table design:
PK: TARGET#seed:v1:0042
SK: VOTER#<identityHash>
choice: 1 | -1
targetType, targetId, familyId, familyName
createdAt, updatedAt
expiresAt: now + 400 days ← TTL
Because the voter hash is the sort key, re-voting is naturally idempotent. Pressing the same thumb twice is a no-op that short-circuits before any counter is touched — so repeated presses cannot burn a user's daily quota:
const previous = await documentClient.send(new GetCommand({
TableName: TABLE_NAME, Key: voteKey, ConsistentRead: true
}));
if (previous.Item?.choice === parsed.body.choice && previous.Item.expiresAt > nowSeconds) {
return success('unchanged', parsed.target.key, parsed.body.choice, setCookie);
}
Changing your mind overwrites the item and preserves the original createdAt. The response tells the client which of the three things happened — accepted, changed, or unchanged.
Before any of that runs, the request itself has to survive parsing. parseVoteRequest is intentionally hostile:
- Method must be
POST, path must be exactly/api/v1/vote. Content-Typemust beapplication/json.Originmust be in an allowlist — no CORS preflight games.- Body must be under 4 KB.
- The
x-amz-content-sha256header must match the SHA-256 of the raw body, compared withtimingSafeEqual. This falls out of SigV4 signing for Lambda function URL origins, and doubles as a tamper check. - The JSON must be a plain object whose keys are a subset of exactly five allowed names. An unknown field is a rejection, not something to ignore.
choicemust be the integer1or-1. Not"1", not1.0.catalogVersionmust match the server's, otherwise 409 — this is how we retire a catalogue without corrupting its history.
Every one of these is a cheap rejection that happens before the expensive Cloudflare round trip.
The read path is a static file
Once a day, EventBridge triggers the ranking Lambda. It scans the table, aggregates votes per target, ranks them, and writes a small JSON artifact into the site bucket. That artifact is the only thing the browser ever reads.
The client still refuses to trust it blindly. A stale or malformed artifact is worse than no leaderboard, so the schema, catalogue version, freshness, and rank ordering are all validated before anything renders:
export function validRanking(artifact, catalogVersion = 1, now = Date.now()) {
if (!artifact || artifact.schemaVersion !== 1 || artifact.catalogVersion !== catalogVersion
|| !Array.isArray(artifact.items)) return false;
const generatedAt = Date.parse(artifact.generatedAt);
if (!Number.isFinite(generatedAt) || now - generatedAt > MAX_RANKING_AGE_MS
|| generatedAt - now > 60 * 60 * 1000) return false;
return artifact.items.every((item, index) => item
&& item.rank === index + 1
&& ['seed', 'library'].includes(item.targetType)
&& typeof item.targetId === 'string'
&& (item.targetType === 'seed' ? /^\d{4}$/.test(item.targetId) : /^[a-z0-9-]+$/.test(item.targetId)));
}
Note the clock check runs in both directions: older than seven days is stale, and more than an hour in the future means something is wrong with the generator. If validation fails, the UI degrades to "Top visuals are being updated" rather than rendering nonsense.
The user's own thumbs are a separate concern. They live in localStorage, capped at 256 entries and pruned by recency, so the buttons show the right state instantly on reload without a round trip. The write to localStorage is wrapped in a try/catch — private browsing modes throw on write, and a rating feature must not break the visualizer because storage is disabled.
Ranking math: why not just count
Sorting by net score (up - down) buries new items forever. Sorting by ratio (up / total) is worse: a visual with one upvote and no downvotes scores a perfect 100% and beats one with 900 up and 100 down. Both are wrong in the same way — they ignore how much evidence you actually have.
The standard fix is the Wilson score lower bound: the low end of the confidence interval for the true approval rate given the sample you have. Few votes means a wide interval, so the lower bound sits far below the observed ratio. As votes accumulate, the interval narrows and the bound climbs toward the truth. Small samples are penalised automatically, without a hand-tuned fudge factor.
export function wilsonLowerBound(up, total, z = 1.96) {
if (!total) return 0;
const proportion = up / total;
const z2 = z * z;
return (proportion + z2 / (2 * total) - z * Math.sqrt((proportion * (1 - proportion) + z2 / (4 * total)) / total))
/ (1 + z2 / total);
}
At z = 1.96 that is a 95% confidence bound. One upvote out of one scores about 0.21; 900 out of 1000 scores about 0.88. The ordering now matches intuition.
On top of the score sit hard eligibility gates, because no amount of clever maths rescues a five-vote sample:
const eligible = enriched.filter((entry) =>
entry.total >= 5 && entry.up >= 3 && entry.approval >= 0.55);
eligible.sort((a, b) => b.up - a.up
|| b.wilson - a.wilson
|| b.net - a.net
|| b.total - a.total
|| a.key.localeCompare(b.key));
return {
status: eligible.length >= 5 ? 'ready' : 'insufficient-data',
entries: eligible.length >= 5 ? eligible.slice(0, 50) : []
};
Two details in that sort are deliberate. First, the final tiebreaker is a.key.localeCompare(b.key) — a total order. Without it, ties break arbitrarily and the leaderboard shuffles between runs for no reason, which looks like a bug and churns the CDN cache. Second, if fewer than five targets qualify, we publish nothing and the UI says the list is being updated. An almost-empty leaderboard is a worse experience than an honest placeholder, and it is trivially gameable on day one.
What it costs
The whole thing runs on pay-per-request infrastructure with explicit ceilings:
| Component | Setting | Why |
|---|---|---|
| Vote Lambda | 256 MB, 3 s timeout, 2 reserved concurrent | A hard cap on spend and on blast radius |
| Ranking Lambda | 512 MB, 600 s timeout, 1 reserved concurrent | Runs once daily; cannot overlap itself |
| DynamoDB | On-demand, max 25 RCU / 15 WCU | On-demand with a ceiling, not unbounded |
| Vote items | 400-day TTL | Storage stays bounded automatically |
| Limit items | 2-day TTL | Counters evaporate; deletes are free |
| Leaderboard reads | Static JSON on CloudFront | Zero compute, zero database, at any traffic |
Reserved concurrency of two is the single most effective cost control in the stack. If someone points a botnet at the endpoint, they do not generate a bill — they generate a queue, and the rate limiters reject the traffic anyway. CloudWatch alarms watch Lambda errors and throttles so we find out either way.
Failure modes and lessons
Never let ratings break the product
The entire ratings system is behind a feature flag (VOTING_ENABLED), and the handler returns 503 if any of the table name, master secret, or Turnstile secret is missing. On the client, every failure path is caught and turned into a short message. If Cloudflare is down, if the Lambda is throttled, if localStorage throws — the visualizer keeps running. A thumbs button is a nice-to-have; the product is the visuals.
Fail closed on identity, open on features
A missing CloudFront-Viewer-Address header rejects the request rather than falling back to a default bucket, because a default bucket is a free pass around the network limiter. But a missing ranking artifact just hides the leaderboard. Security-relevant signals fail closed; cosmetic ones fail open.
Version the catalogue from day one
Every target key embeds a catalogue version — seed:v1:0042. When the shader catalogue changes meaningfully, bumping the version starts fresh rankings without deleting history or silently attributing old votes to a visual that no longer looks the same. Retrofitting this later is painful; adding it costs one field.
What we would do differently
The ranking job uses a full table Scan. That is completely fine at our scale and would be the first thing to change at ten million votes — most likely a DynamoDB Stream maintaining running aggregates, with the daily job demoted to a reconciliation pass. The lesson is not "Scan is bad," it is that the read path being a static file means we can change how that file is produced without touching the client at all.
The short version. Turnstile stops scripted traffic. A signed cookie stops the casual repeat voter. A hashed network bucket stops the cookie-clearing one. Conditional counters stop the persistent one. Reserved concurrency stops the bill. Wilson scoring stops the maths from lying. No single layer is airtight — together they make ballot stuffing more expensive than it is worth, which is the only goal that was ever achievable without accounts.