Arts, 2026. Design and build. Live.
SF Queer Film Festival audience award
I built the festival's audience ballot so visitors could vote and staff could review the results before announcing the winners.

Scroll inside the screen to read the whole site. Or open the live site in a new tab.
What happened.
227
ballots counted, 2 thrown out by the rules
SF Queer Film Festival audience award
56
films across eight categories on one online ballot
SF Queer Film Festival audience award, 2026
- Closed by the server at 06:59:59 UTC on 24 August 2026, which is 11:59:59 pm Pacific on the 23rd, with nothing switched off by hand
- A share graphic and a QR code generated from the same source as the ballot, so the address on the poster couldn't differ from the address that worked
Where it started
The SF Queer Film Festival needed an audience award ballot. Fifty-six films in eight categories, open for the run of the festival, closed at midnight on the final Sunday. Picture the lobby after a screening. A crowd on the venue's Wi-Fi, all voting from their phones at once, and to the internet they look like one person. Staff had to count the result and stand behind it.
The usual answer is a form builder, and a form builder can't do three things this needed. It can't shut at an exact instant without a person doing it. It can't tell a lobby full of people on one network apart from one person voting forty times. And it can't let staff throw out a ballot without deleting the evidence that it existed. So I built a small custom app instead.
What made it hard
- A packed venue on one network looks exactly like ballot stuffing. Any per-network limit had to be generous enough to survive a full house.
- No names and no raw network addresses are stored. Voters get a receipt and a short ballot ID. The server keeps a scrambled, one-way version of the network address that can group ballots by network but can't be turned back into the address.
- The deadline was 11:59:59 pm Pacific on 23 August, and it had to hold whether or not anyone remembered it was that night.
- Three films were entered in two categories each, and a vote for both had to count as two votes. That's the festival's convention, and not what an off-the-shelf setup would do.
What I built
The deadline closes itself
Nobody on staff should have to stay up to turn the ballot off. The closing time is written into the app's settings as one line, stored in universal time so no clock change can move it. After that instant the ballot page becomes a closed notice, and any late submission is refused, including one from a tab somebody opened at 11:58 and submitted at 12:01. There was nothing to turn off.
Four safeguards against stuffing, each honest about what it stops
A cookie set on the voter's browser stops the casual second vote. A bot check, Cloudflare Turnstile, stays invisible for most voters and stops scripts. A six-second pause between submissions from the same network stops floods without blocking the lobby. And the scrambled network address lets staff see any group of devices that submitted four or more ballots. None of that stops a determined person with a phone in airplane mode, and the documentation says so.
Void, never delete
Staff sometimes have to throw a ballot out. Every ballot on the results page has a void button, and voiding takes a reason. The ballot leaves the count and stays in the record. Two were voided this year. If anyone ever asks why, the answer is on the ballot itself, not in someone's memory.
Staff certify before the public sees a winner
Until staff type a confirmation word on the results page, the public winners page shows a teaser. Certifying publishes it, and unpublishing is one button away if a count has to be revisited. The running tally is visible to staff throughout, behind a password, with the record of every void next to it.
What isn't measured
- No count of how many people opened the ballot and left. There's no analytics on the page, on purpose, and that's the trade.
- No record of how many submissions the bot check challenged or refused. It logs nothing on success.
- No prior-year turnout figure to compare against. I don't have one.
Built with
See for yourself
The winners page is public, and the app's status page still reports the closing time. Staff pages are behind a sign-in and aren't shown here.

Notes
For anyone who wants the build: it’s a single Cloudflare Worker with a SQLite database (Cloudflare D1) behind it. No framework, no build step, no runtime dependencies, under three thousand lines of plain JavaScript.
The database is two tables and an index. Ballots, with a void flag and a reason. Votes, keyed on ballot and category, so a ballot can hold at most one pick per category and it’s the database that enforces that rather than the form.

Tell me what you're working on.
Send me a little about your project or the role you're hiring for. I'll tell you whether it sounds like a fit.