← all posts
Thu 15 Oct 2026 · 8 min read

SnapJoust: Photo Competitions Decided Head to Head

At work I have been running photo competitions once a quarter or so for about a year now. The process was simple:

  1. People uploaded their photos to a Google Form.
  2. I downloaded them all and stripped anything that gave away who took them1.
  3. I passed the lot to the previous winner, who picked their favourite.
  4. The winner of this round picked the next theme and took over as the judge.
  5. …
  6. Do it all again.

It worked, and people enjoyed it, especially as work was happy to sponsor prizes, but it had a few problems. It was more manual faff for me than I wanted, and the result came down to one person’s taste. Win once and your out of the running for the next comp, which isn’t exactly fun.

So I built SnapJoust, a web app for running photo competitions where everyone gets a say, and the winner is decided by head-to-head voting2.

The SnapJoust home page in dark mode, with the headline "Find photo competitions worth entering", Browse competitions and Start voting buttons, counts of open competitions, votes and entries, and a collage of entered photos.

How It Works

Someone sets up a competition with a theme, and one or more prompts: the questions voters answer, like “which photo has better composition?”. People submit their photos, and once the entries are approved, voting opens.

The SnapJoust competitions page, with featured Old and New and Heat Wave cards, and a searchable list of every competition marked open, voting or closed.
The Heat Wave competition page, with a beach photo as its banner, a Go to voting button, the theme description, and a timeline showing it opened on 9 July and started voting on 7 October, with 6 entries from 4 entrants.

Voting is the “fun” bit. You’re shown two photos side by side and you pick the one you prefer. Then you get another pair, and another, through a set number of duels, skipping any pair you can’t decide on. You’re never shown a pair that includes one of your own photos3, and you can’t vote on the same pair twice4.

The SnapJoust voting screen for the Heat Wave competition, asking which photo has better composition: a cup of chocolate milkshake buried in crushed M&Ms on the left, and a sunset over a race track with red and yellow kerbs on the right, each with a This one has better composition button, and a progress bar showing 10 of 30 duels.

The pairs aren’t completely random. They’re weighted towards photos that haven’t been compared much yet5, so every entry gets a fair look rather than a couple of eye-catching ones hogging all the matchups.

When the competition closes (or earlier, if the organiser wants live results) there’s a ranking and a podium. SnapJoust can also generate a results image to post in a chat channel or anywhere else outside the app.

Why Head to Head?

Asking “which of these two is better?” is a much easier question than “rate this from 1 to 10”. Everyone’s 7 means something different, some people never give anything below a 5, and by the twentieth photo you’ve lost track of what your scores meant in the first place.

Picking between two is quick, it’s hard to get wrong, and it’s surprisingly addictive. Fewer tired voters means more votes, and more votes means a ranking you can actually trust.

The ranking is worked out from all those head-to-head results, either by simply counting wins or with an Elo-style win rate6. There’s also an optional “one prize per person” rule for the podium, so one prolific photographer can’t take gold, silver and bronze.

I’ve built quite a lot of flexibility into setting up the competitions, for example whether people can see other people’s entries before the voting starts7.

The Tech Stack

It’s a SvelteKit app (Svelte 5) running on Bun8, with PostgreSQL accessed through Drizzle ORM. If that sounds familiar, it’s very close to BooMap, just with a bigger database.

Sign-in is Better Auth again9, this time with email and password, Google, two-factor authentication and anonymous guest logins.

Guest mode is switched off for now though. The idea was that you could enter photos or vote without signing up, and later upgrade your account, but in practice it just confused people. They’d enter or vote as a guest, then sign in properly and wonder where their entries and votes had gone, or not realise they were a guest at all. It’s still in the code, so it can come back once I’ve worked out how to make it obvious what’s going on.

There is an admin system that I use for controlling some features and getting data out of the system. If the site ever gets popular, I think I’ll need to expand this to have other levels of admin.

Photos are resized on the server10 with sharp into thumbnail, 1080p and 4K WebP versions, with EXIF rotation sorted out along the way. They live in S3-compatible storage (MinIO in development), but they’re never served straight from the bucket; everything goes through the app11. Storage is optional too: without it configured, the app just carries on without uploads.

It all runs on AWS ECS Fargate as a single long-lived server, not serverless functions. That choice turned out to shape a lot of what came next.

Waking a Sleeping Database

The database is Aurora Serverless, which can pause itself when nobody’s using it. That’s great for the bill on a site that sits idle between competitions, less great when the first visitor of the morning waits several seconds for it to wake up.

Rather than make people stare at a blank page, the app uses stale-while-revalidate. Pages that look the same for every anonymous visitor, like the competition list or public results, are kept in a plain in-memory LRU cache12. If the database is asleep, you get the cached version straight away with a “stale” flag, and the page quietly polls in the background and swaps in fresh data once the database answers.

Anything that depends on who you are is never cached13. For those pages the app races the session lookup against a timeout. If the database is too slow, you get a placeholder straight away and the real content fills in when it arrives.

No Load Balancer

The usual way to put a container on the internet in AWS is a load balancer in front of it. SnapJoust doesn’t have one. Instead, a Cloudflare Tunnel runs as a sidecar container next to the app and connects outwards to Cloudflare, which sends visitors down the tunnel. This is mostly to save money.

It also means the service’s security group has no inbound rules at all. There’s nothing listening for the internet to find14. And it gives zero-downtime deploys almost for free: the new task’s tunnel joins as a second replica, and the old task only drains once the new one is taking traffic.

Thanks

A big thanks to Paul (of sticker fame) for testing SnapJoust and breaking it in more ways than I thought possible. Every time I told him something was finished, he found a new way to prove it wasn’t.

What’s Next

  • Bring guest mode back, once it’s clear to people what being a guest actually means.
  • Settle on a sensible set of default competition options, after a few more rounds.
  • More levels of admin, if the site ever gets popular enough to need them.
  • Maybe move off Bun, depending on how the drama shakes out.

Give It a Go

The work competitions are much less effort for me now. I set up the competition and approve the entries, and everyone else does the judging, two photos at a time. Everyone gets a say instead of one judge, and nobody has to wait for me to anonymise a folder of JPEGs.

If you run photo competitions at work, at a club, or just with friends, have a look at SnapJoust. And if you find a way to break it, you’re in good company.


  1. Mostly renaming files and removing anything identifying, so the judge couldn’t tell whose photo was whose. Tedious, and easy to get wrong. ↩︎

  2. This video from Tom Scott back in 2020, 1,204,986 Votes Decided: What Is The Best Thing?, gave me the idea to just use head-to-head voting. ↩︎

  3. Partly to stop people voting for themselves, and partly because nobody can judge their own photo fairly anyway. ↩︎

  4. Each vote is stored against a key made of the prompt, the voter and the two photo ids (sorted, so A vs B and B vs A are the same pair), with a unique constraint in the database so a duplicate can’t sneak in. ↩︎

  5. Each pair’s weight is 1 / (voteCount + 1), so a pair nobody has voted on is twice as likely to come up as one with a single vote. Coverage spreads out evenly across the pool. ↩︎

  6. Elo is the rating system from chess, where beating a strong opponent counts for more than beating a weak one. The rankings are worked out on the fly from the raw votes rather than stored, so switching method is just a setting. ↩︎

  7. I am probably going to settle down on a set of options that works best and make these the default at some point, but we need to run a few rounds first. ↩︎

  8. I might change this with all the drama that’s happening over with Bun at the moment. ↩︎

  9. As I said in the BooMap post, Better Auth really takes the pain out of auth. ↩︎

  10. Thanks to Paul for making me realise that some people’s phones take much lower resolution photos than I expected. ↩︎

  11. Uploads that never get attached to a submission are cleaned up by an S3 lifecycle rule after a day, so abandoned uploads don’t pile up. ↩︎

  12. An in-memory cache is only correct because there’s exactly one instance of the app. With two, each would have its own cache and they’d drift apart. If it ever needs to scale out, this is the first thing that has to change. ↩︎

  13. That includes guests, when guest mode is on. A guest session might look anonymous, but it still knows whether you own a competition, have voted or have entered, so caching it would show one guest’s state to another. ↩︎

  14. For poking at the database by hand there’s optional RDS Data API access, locked down to a single named IAM role and off by default. ↩︎

search.exe
esc to close