Devlog 2: Vote Batching - How I Handle 1000+ Clicks Per Second Without Melting the Database
I ran into my first real problem this week. During testing, I had a friend spam-click one of the vote buttons while I watched the Supabase dashboard. Within seconds, I was sending hundreds of INSERT queries. My free tier quotas were screaming.
The naive approach of "every click = one database write" clearly wasn't going to work. If this thing goes viral and thousands of people are clicking simultaneously, I'd be broke and rate-limited within minutes.
The Problem With Real-Time Sync
Here's the core tension: users want instant feedback when they click (like, under 50ms), but databases don't want to process thousands of tiny writes per second. And I still need the global vote counts to stay in sync across everyone watching the matchup.
So I needed three things:
- Instant UI response when you click
- Efficient database writes that don't destroy my bill
- Everyone sees the same numbers eventually
The Solution: Client-Side Batching

I ended up implementing a simple batching system in the matchup store:
// Accumulate votes locally
let pendingVotesA = 0;
let pendingVotesB = 0;
// Flush every second
setInterval(() => {
if (pendingVotesA > 0 || pendingVotesB > 0) {
flushVotesToServer();
}
}, 1000);
Instead of writing to the database on every click, I accumulate votes in memory for 1 second, then send them all at once. If someone clicks 50 times in a second, that's 1 database write instead of 50.
The trick is making it feel instant even though votes are batched. That's where optimistic updates come in.
Optimistic Updates Make It Feel Real-Time
When you click a vote button, here's what happens:
- Increment the local counter immediately (no waiting)
- Update the vote bar on your screen
- Spawn particle effects
- Keep accumulating clicks
- After 1 second, send the batch to Supabase
- Supabase broadcasts the update to everyone else
From your perspective, it feels instant because I'm not waiting for the server. But from the server's perspective, it's getting nice, manageable batches of votes every second.
Syncing Across Clients
For real-time sync, I'm using Supabase's realtime subscriptions. When the database updates, everyone gets notified:
supabase
.channel(`matchup:${matchupId}`)
.on('postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'matchups' },
(payload) => {
updateGlobalVoteCounts(payload.new);
}
)
.subscribe();
Postgres triggers handle the aggregation server-side. When a batch of votes comes in, a trigger updates the matchup totals and Supabase broadcasts it to all connected clients. No polling needed.
Testing With Multiple Browsers
I tested this by opening the same matchup in three different browser windows and clicking like crazy in all of them. The vote counts stayed in sync, usually within 1-2 seconds. That's fast enough that it feels live without overwhelming the database.
The reduction in database writes is massive. In testing, I went from 500+ writes per minute to about 60. That's a 90% reduction just from batching.
Takeaway
The key insight is that "real-time" doesn't mean "instant server write". It means the UI feels instant and the data eventually converges. As long as the delay is under 2 seconds, people perceive it as real-time.
Batching is one of those optimizations that seems obvious in retrospect but wasn't on my radar initially. I'm glad I ran into the problem early instead of discovering it after launch.
This vote batching strategy works together with other performance optimizations I've implemented, including in-memory caching to reduce database load and comprehensive performance profiling to identify bottlenecks.
Next week I'm focusing on mobile touch optimization. The desktop experience feels pretty good now, but tapping on mobile still has some lag I need to fix.