Why

A tournament is a natural unit of data. It is written heavily for a few days, read for a while, then never changes again. Almost every write touches only that tournament. So we gave each one its own object, addressed by tournament id, with SQLite on the same machine as the code.

  1. Data sits next to the code

    Queries are in-process calls into a local SQLite file, not network round trips. A request pays one hop to reach the tournament; after that, ten queries cost about the same as one.

  2. Multi-row writes are atomic

    Recording a result updates the match, advances the winner in the draw, sometimes cascades through a walkover, frees the court and writes an audit row. That all goes in one SQLite transaction. The platform holds the response until the write is durable, so a client never sees a commit that could still be lost.

  3. Writes are serialised for free

    An object handles one event at a time. Two desks recording the same match, or one umpire assigned to two courts from two laptops, resolve deterministically: the second write sees the first and gets a clear conflict. No row locks, no retry loops, no isolation-level surprises.

  4. No noisy neighbours

    Saturday at 9 a.m. is everyone’s peak. A national championship generating 64 draws doesn’t share a write queue, connection pool or lock table with a club event recording its first results.

  5. Realtime comes from the writer

    The object that commits a change also holds the hibernating WebSockets for Match Control. It pushes after commit. There is no separate pub/sub layer to keep consistent with the database.

  6. Small blast radius

    A bad migration, a hot loop or a point-in-time restore affects one tournament, not the fleet.

The latency sums

Light in fibre travels about 200 km per millisecond. Pune to northern Virginia is about 13,000 km great-circle, so the physical floor is roughly 130 ms per round trip; real routes land nearer 220. Recording a result needs at least six dependent queries.

Edge code, shared DB in us-east: 15 + 6 × 220 ≈ 1,335 ms
App and DB together in us-east: 220 + 6 × ~1 ≈ 226 ms
Tournament’s own object: ~50 + 6 × <0.1 ≈ 50 ms

A Durable Object is created in a data centre near its first request, which for us is the organiser setting the tournament up. The 50 ms is that one hop.

Is one object enough?

Single-threaded sounds scary until you count rallies.

900 matches × ~2⅓ games × ~37 rallies ≈ 78,000 rallies per tournament
20 courts × one rally per ~20 s ≈ 1 write/s at peak
Cloudflare soft limit: ~1,000 requests/s per object

Scoring pads don’t send a request per tap, so real request rates are lower still. We load-test a single tournament at 1,200 players, 64 events, 900 matches and 500,000 scoring events, more than six times that rally count.

Trade-offs we accepted

  1. No transactions across objects

    Anything that spans a tournament and the central store (identity, directory, archive) can’t be one commit. Those paths run as resumable, idempotent workflows, and side effects like messages leave through an outbox after commit.

  2. Transactions must be synchronous

    No await, fetch or other I/O inside a transaction. Expensive planning reads a versioned snapshot, computes outside, then commits with compare-and-set against that version.

  3. Two read paths

    Live tournaments are read from their object. Finished ones are sealed into a shared archive database, partitioned by tournament, which is what makes cross-tournament player history cheap. The same read services run against both, so every archive query must be scoped by tournament id, and a test catches a query that forgets.

  4. Schema migrations run per object

    There’s no single ALTER TABLE for the fleet. Each object migrates itself when it wakes, so several schema versions coexist until each tournament is touched or an optional sweep visits it. Migrations have to be forward-only and safe to run late.

  5. Ids are only unique per tournament

    Autoincrement ids collide across objects. Every URL, cache key, browser storage key and archive row carries the tournament id too.

  6. Objects don’t move

    Placement is fixed at creation. A tournament created far from where it’s played pays the longer hop for its whole life.

  7. A per-object ceiling

    One thread per tournament caps throughput. Bulk setup is chunked into bounded transactions so a large import can’t stall live scoring.

Sources

  1. Cloudflare, Durable Objects documentation, including limits and data location.
  2. Cloudflare, “Zero-latency SQLite storage in every Durable Object”, 2024.
  3. SQLite, Transactions.