Engineering
Tunji Oyeniran
Why Pepvote runs on Convex
A voting app must count correctly while the result changes on screen. This is the backend I chose for that job, what it gave us, and what it costs.

The result page is the hardest page in a voting product. During a busy vote the counts change every few seconds. Everyone who watches must see the same numbers, and the numbers must be right.
When I started the new Pepvote in January, I wanted a backend that made that page easy to get right. I picked Convex. This post explains why, and what I gave up.
What Convex is
Convex is a backend where the database, the server functions, and the realtime sync are one system. You write all of it in TypeScript. There is no separate API layer to build and no socket server to run.
Pepvote uses it with Next.js and React for the app, Better Auth for sign-in, and Resend for email.
Results update without extra code
In Convex, a query is a TypeScript function that reads the database. When a React component uses a query, it subscribes to it. If a write changes any data the query read, Convex runs the query again and sends the new answer to every subscribed browser.
This is the line that loads the public result page:
const result = useQuery(api.publicResult.getVotingEventResultPublic, {
votingEventCode,
});That line is the whole realtime feature. There is no polling timer, no refresh button, and no cache to clear when a vote arrives. An officer can put the result page on a projector and leave it there.
Cast a vote and watch three open result pages
President
A voter's ballot
Result query
Dashboard
- AdaezeAdaeze Okafor12
- BolaBola Fashola9
- ChidiChidi Nwosu5
Projector
- AdaezeAdaeze Okafor12
- BolaBola Fashola9
- ChidiChidi Nwosu5
Phone
- AdaezeAdaeze Okafor12
- BolaBola Fashola9
- ChidiChidi Nwosu5
26 votes. No page here has a refresh button or a timer.
One ballot is one transaction
A ballot is more than one row. Pepvote stores one vote for each position, marks the voter as voted, and updates the counts. If only half of that is saved, the result is wrong and nobody can tell.
Every Convex mutation is a transaction. The function that casts a ballot does all of its work or none of it:
- Check that the event is open and that this voter may vote.
- Check that the voter has not voted before.
- Save one vote for each position, with a confirmation code.
- Mark the voter as voted and update the counts.
Two requests can arrive together, for example when a voter taps Submit twice or has two tabs open. Convex notices that both read the same voter record. It lets one finish and runs the other again against the new data. The second request then fails at step 2. Convex does this on its own, so I wrote no extra code to stop two requests from clashing.
A voter taps Submit twice
First tap
Read
Voter has not voted
Write
One vote per position
Write
Mark voter as voted
Commit
Ballot saved
Second tap
Read
Voter has not voted
Conflict
Voter record changed
Run again
Voter has voted
Refused
Nothing is written
1 ballot saved. The second request was refused.
One language from the database to the button
The schema is a TypeScript file. Each function declares the arguments it accepts, and Convex checks them on every call. The app imports generated types for every table and function.
So when I rename a field, the type checker lists every form, table, and export that still uses the old name. For a product where one wrong field can change who may vote, that check is worth a lot.
Background work is built in
A voting event produces slow work. Some examples are thousands of invite emails, copying the voters from last year's event into this year's, and the removal of images that nothing points to any more.
Convex has scheduled functions and cron jobs, so this work stays in the same code and uses the same transactions. A large job, such as that copy, runs as a chain of small batches. Each batch schedules the next one. If one batch fails, the others are not lost.
What it costs
Convex is not free of trade-offs. These are the ones I feel.
- There is no SQL. I cannot write a join or run
COUNT(*)on a table. Each query reads through an index that I define first. - Counting is a design task. A result must not count rows when someone opens the page. Pepvote keeps running counts and updates them as each vote arrives, inside the same transaction as the vote.
- The product depends on one vendor. I accepted this because the alternative was to build and operate the realtime and transaction layers myself, and I would rather spend that time working on the product.
Would I choose it again?
Yes. The first commit was on January 24. Two days later the app had elections, positions, candidates, voters, and votes. The limits also pushed me toward bounded reads and running counts early, before any large event needed them.
I will write more engineering posts here as the system grows. If you want to see the result of this one, try the product and watch a result page while you vote.