Ranking movies by head-to-head matchups instead of star ratings, using the same math chess uses.
Ask someone to rate a film out of five and they will give half their library a four. Star ratings compress everything into a narrow band, they drift over time, and they do not capture the thing you actually want to know: given these two films, which is better?
Chess solved this problem decades ago. You do not rate players in the abstract. You let them play each other and let the results settle into a number. I wanted to apply the same idea to movies: no abstract scores, just direct comparisons, with rankings that emerge from the matchups.
The interesting engineering is not the Elo formula itself, which is four lines of arithmetic. It is everything around it: which pairs to show, how fast ratings should move, and what happens when two people vote at the same instant.
CinemaRanked is a Node and Express backend over PostgreSQL. Every movie starts at 1500 points. You are shown two films, you pick one, the winner gains points and the loser loses them, and the amount depends on how surprising the result was. Beating a much higher-rated film moves the needle hard. Beating a peer barely moves it.
POST /api/movies/compare — winner-loser pairGET /api/movies/pair — prefetched in parallel via React Query
BEGIN; SELECT … FOR UPDATE — both rows locked in id orderUPDATE elo_rating; INSERT comparison; COMMITposter_path fetched only at seed, never
per-votePOST /compare runs in the foreground (acquires lock, writes ratings, commits) while
GET /pair prefetches the next matchup. Perceived latency is the compare RTT only.
The Elo update itself is small. The expected score for a film is a logistic function of the rating gap, and
the new rating is the old rating plus K times the gap between actual and expected outcome.
The K factor is where the design choices live. A fixed K either makes established
rankings too jittery or makes new films take forever to find their level. So K is dynamic: 40 for
new films with under 30 comparisons so they calibrate fast, 32 for the established middle, and 20 for films at
the top or bottom with a long track record so their ratings stay stable.
Here is the bug that makes this project worth writing about. A comparison is not one database write. It is read both films' current ratings, compute the new ratings, write both back. Four steps. If two users vote on matchups involving the same film at the same time, their transactions interleave.
User A reads the film at 1500. User B also reads it at 1500, a millisecond later, before A has written anything. A computes 1512 and writes it. B computes 1490 from the stale 1500 and writes that. A's update is now gone. The film's rating is wrong and nothing logged an error.
The fix is pessimistic
row locking. When a transaction reads a film's rating with the intent to update it, it takes a
row-level lock with SELECT FOR UPDATE. Any other transaction that wants the same row waits until
the first one commits.
Now B's transaction blocks at the SELECT FOR UPDATE until A commits, then reads the fresh 1512
instead of the stale 1500. The updates serialize. No lost writes. Locking both rows in a single statement,
ordered by id, also avoids the classic deadlock
where two transactions each hold one row and wait for the other.
BEGIN;Tx ASELECT elo FROM movies
WHERE id IN (1,2);Tx A
BEGIN;Tx BSELECT elo FROM movies
WHERE id IN (1,2);Tx B
UPDATE elo = 1512; ✓Tx ACOMMIT;Tx AUPDATE elo = 1490; overwrites A!Tx BCOMMIT; A's +12 is goneTx BInception ends at 1490. The correct 1512 is silently gone.
wins + losses ≠ total_comparisons. The invariant is broken.
BEGIN;Tx ASELECT elo FROM movies
WHERE id IN (1,2)
ORDER BY id FOR UPDATE;Tx ABEGIN;Tx BSELECT … FOR UPDATEWAITINGUPDATE elo = 1512;Tx ACOMMIT; — lock released ✓Tx AUPDATE elo = 1502;Tx BCOMMIT; ✓Tx BBoth votes land. wins + losses = total_comparisons
holds. Tx B waited ~4 ms, correctness costs one extra RTT. Worth it.
recordComparison() in
movieService.js. Rows are locked in ORDER BY id so every concurrent transaction
acquires locks in the same global order; the concurrency test fires 50 simultaneous votes and asserts
wins + losses === total_comparisons.The comparisons table stores the before and after ratings for both films on every vote. That is partly an audit log and partly insurance: if the ranking ever looks wrong, the entire history is replayable from that table.
Random pairing wastes votes. Showing the consensus best film against the consensus worst tells you nothing you did not already know. The information is in the close calls.
So pairing is weighted. The probability of two films being matched is inversely proportional to their rating gap, which means films near each other on the ladder meet far more often than films at opposite ends, while still leaving a small chance of a cross-tier upset.
Leaderboards read constantly, so the sort columns are covered by a composite index on rating and comparison count. Reads stay fast even as the comparisons table grows.
I knew what a database transaction was before this project. What I did not have was the instinct for when
wrapping code in BEGIN and COMMIT is not enough. A transaction gives you atomicity,
all four steps land or none do. It does not, on its own, stop another transaction from reading the same row in
the gap between your read and your write.
That gap is where lost updates live. Closing it needs an explicit choice: pessimistic locking with
SELECT FOR UPDATE, or optimistic locking with a version column and a retry loop. CinemaRanked
uses the pessimistic version because comparisons are short and contention is low. Knowing both exist, and when
each one breaks, is the actual lesson.
On the roadmap: user accounts with personal rankings, genre-specific leaderboards, and rating history graphs per film. The backend and the hard concurrency work are done. The rest is surface.