Oliver White
19 May 2026 · 8 min read
The vote endpoint started as five sequential database calls: check the contest is active, verify the artwork exists, check for a duplicate vote, insert the vote row, increment the artwork count. It worked in testing. It was also fragile, slow, and had a race condition hiding in plain sight — the kind that only fires when two users click at the same millisecond.
This is part of the Directed Output build log — every architectural decision behind AI Art Arena documented as it happened. The methodology that drives these decisions lives at /process.
Each database query from a Vercel serverless function to Supabase involves a network round trip. On a good day that is 30-50ms per hop. Five hops means 150-250ms minimum before a response leaves the server — before rate limiting, before the response is serialised, before the client receives anything. For a vote button that should feel instant, that is a long time to wait.
But the latency was not the real problem. The real problem was the gap between the duplicate-vote check and the vote insert. Two requests arriving within the same millisecond both pass the check — neither sees the other's vote yet, because neither has been written. Both insert. One user gets counted twice. The vote count is wrong, permanently, and there is no clean way to fix it without auditing every vote.
5 sequential queries
1 atomic RPC function
PostgreSQL SECURITY DEFINER functions run as the function owner, not the calling user. More importantly for this use case: they execute as a single atomic transaction. No other query can interleave with them. The check-then-write pattern that creates a race condition in application code is immune to races inside a single transaction.
The first working version of this function shipped a bug that only surfaced after the first real votes were cast. When the RETURNS TABLE declaration includes a column named vote_count, and the UPDATE clause does RETURNING vote_count, PostgreSQL cannot resolve which one you mean. It throws ERROR 42702: column reference is ambiguous — and every single vote submission returned a 500.
Fix: alias the table in the UPDATE clause. UPDATE artworks AS aw SET vote_count = aw.vote_count + 1 RETURNING aw.vote_count. The table alias gives PostgreSQL an unambiguous reference. Without the alias, it sees two columns named vote_count and refuses to proceed.
This is the kind of bug the refinement loop catches — not by anticipating every edge case upfront, but by refusing to stop at 'it compiles and passes tests.' You push until you understand every line, then you push a bit further. The ambiguous column reference was not in the first version. It appeared in migration 17, after an iteration that changed the function signature. Catching it required reading the error, understanding what PostgreSQL was complaining about, and writing a migration that fixed it cleanly.
The function went through five migrations before it reached its current form. Each migration fixed something the previous version got wrong: the first added the function, the second fixed a double-increment trigger that was running alongside it, the third resolved the ambiguous column reference, the fourth removed a parameter that the client should never have been providing (contest_id — the function now derives it from artwork_id to prevent a client from lying about which contest it is voting on), and the fifth fixed a search_path security regression introduced by the fourth.
The full Directed Output methodology — how the refinement loop works and why the first response is never the answer — is at /process.
Before (5 queries)
~200ms
sequential round trips
After (1 RPC)
~40ms
single atomic transaction
Latency reduction
80%
p50 vote endpoint
Migrations to get it right
5
each one caught something real
Built with this methodology
Five sequential database queries in a vote handler create race conditions and ~200ms latency. One atomic PostgreSQL function eliminates both. Here is the exact problem, the fix, and what the refinement loop looked like getting there.
From the build log