All work

CinemaRanked

Ranking movies by head-to-head matchups instead of star ratings, using the same math chess uses.

Year
2025
Stack
Node.js · Express · PostgreSQL · TMDB
Code
01 Why

Star ratings are bad at ranking things.

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.

02 What I built

An Elo engine, a pairing strategy, and a concurrency story.

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.

SYSTEM ARCHITECTURE — A VOTE, START TO FINISH
1 2 3 4 5 6 NEXT.JS 14 · REACT QUERY Browser useCompare() · useMoviePair() · useLeaderboard() NODE.JS · EXPRESS REST API — server.js rateLimiter · helmet · comparisonLimiter CORS maxAge = 86400 pg POOL CLIENT Transaction locks rows in ORDER BY id POSTGRESQL movies · comparisons elo_rating, wins, losses, total TMDB API poster_path seed only
  1. 1 POST /api/movies/compare — winner-loser pair
  2. 2 GET /api/movies/pair — prefetched in parallel via React Query
  3. 3 BEGIN; SELECT … FOR UPDATE — both rows locked in id order
  4. 4 UPDATE elo_rating; INSERT comparison; COMMIT
  5. 5 Response: new ratings — UI updates without refetch
  6. 6 poster_path fetched only at seed, never per-vote
FIG. 1: A click on the winner fires two requests in parallel: POST /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.

// Elo update for a single comparison function expectedScore(ratingA, ratingB) { return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400)); } function newRating(old, K, actual, expected) { return old + K * (actual - expected); }

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.

03 The interesting part

Two votes, one millisecond, and a race condition.

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.

A lost update is the worst kind of bug. No crash, no stack trace, no exception. The number is just quietly wrong, and you only notice if you happen to audit the math by hand.

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.

-- Inside a transaction: lock both films before touching ratings BEGIN; SELECT id, elo_rating FROM movies WHERE id IN ($winnerId, $loserId) FOR UPDATE; -- compute new ratings in application code, then: UPDATE movies SET elo_rating = $newWinner WHERE id = $winnerId; UPDATE movies SET elo_rating = $newLoser WHERE id = $loserId; INSERT INTO comparisons (winner_id, loser_id, winner_before, loser_before, winner_after, loser_after) VALUES ($winnerId, $loserId, $wBefore, $lBefore, $wAfter, $lAfter); COMMIT;

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.

recordComparison() — TWO CONCURRENT VOTES ON THE SAME PAIR
WITHOUT LOCK LOST UPDATE
  1. Tx A: vote for Inception
  2. BEGIN;Tx A
  3. SELECT elo FROM movies
    WHERE id IN (1,2);
    Tx A
  4. elo = 1500Tx A
  5. Tx B: same pair, 1 ms later
  6. BEGIN;Tx B
  7. SELECT elo FROM movies
    WHERE id IN (1,2);
    Tx B
  8. elo = 1500 ← stale!Tx B
  9. A computes Δ = +12 → 1512
  10. UPDATE elo = 1512;Tx A
  11. COMMIT;Tx A
  12. B computes Δ = −10 → 1490
    (from stale 1500)
  13. UPDATE elo = 1490; overwrites A!Tx B
  14. COMMIT; A's +12 is goneTx B

Inception ends at 1490. The correct 1512 is silently gone. wins + losses ≠ total_comparisons. The invariant is broken.

SELECT … FOR UPDATE SERIALIZED
  1. Rows always locked in ascending id order
  2. BEGIN;Tx A
  3. SELECT elo FROM movies
    WHERE id IN (1,2)
    ORDER BY id FOR UPDATE;
    Tx A
  4. rows 1 & 2 locked · elo = 1500Tx A
  5. Tx B arrives: same ORDER BY id lock order
  6. BEGIN;Tx B
  7. SELECT … FOR UPDATEWAITING
  8. ··· blocked on row 1 ···
  9. A computes Δ = +12 → 1512
  10. UPDATE elo = 1512;Tx A
  11. COMMIT; — lock released ✓Tx A
  12. elo = 1512 ← fresh ✓Tx B
  13. B computes Δ = −10 → 1502
  14. UPDATE elo = 1502;Tx B
  15. COMMIT;Tx B

Both votes land. wins + losses = total_comparisons holds. Tx B waited ~4 ms, correctness costs one extra RTT. Worth it.

FIG. 2: Real SQL from 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.

04 Pairing

Good matchups are close matchups.

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.

Starting rating, every film 1500
K factor, new films (<30 comparisons) 40
K factor, established films 32
K factor, top or bottom tier (>100 comparisons) 20
Leaderboard query composite index on (elo_rating, total_comparisons)

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.

05 What I learned

Transactions are not the same thing as correctness.

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.

Next project
Queued