Note

We picked the wrong database.

An outside review told us to get off Cloudflare D1 before we started. We wrote up why the review was wrong, stayed, and migrated anyway thirteen months later.

Pick a datastore for a product with a fixed launch date. The first question anyone asks is how many users it has to handle at once. We answered that one carefully, with a model and a document. It was the wrong question, and this is the story of finding that out.

Kickgeist is our own product: a group-first prediction game for the 2026 World Cup, one Flutter codebase on iOS, Android and web. The first release, v25.12.3, went out on 30 December 2025. The stores went global on 1 March 2026 and the tournament ran from 11 June to 19 July. Every figure below was read from the repository, the database or the CI configuration on 5 August 2026.

The advice we turned down

In December 2024, before the build started, an outside review told us to drop Cloudflare D1. We didn’t take that at face value. We went through the reasoning, and it didn’t hold up. The review assumed around 2,000 concurrent users. Our own modelling for a tournament audience put the peak at 15,000 to 22,500.

We also looked at something the review hadn’t, which was the write pattern. A prediction game gets a burst of writes in the hour before kickoff and is mostly reads once a match is running. So we wrote all of this up as an architecture decision record and stayed on D1. On those numbers it was the right call. It still is.

Then production disagreed

The symptom showed up in January 2026, a few weeks after the first release. Signups and predictions failed with a connection error, but only sometimes. Not under load, not at a particular time of day, not on one platform. The first reports looked like flaky phones.

They weren’t. We had a fixture sync running on a two-minute cron against the sports data provider, and each run batched over a hundred statements. D1 is SQLite underneath, and SQLite allows exactly one writer. Those batches held the write lock for two to five seconds. Every user request that arrived inside that window blocked, then failed.

We measured the wrong axis

Our concurrency model was right about the thing it measured. It just wasn’t the thing that broke. The failure had nothing to do with how many people were online. It depended on how long one writer held the lock, and how often it took it.

Two thousand concurrent users would have hit that window. So would two hundred. The traffic analysis was real work, and it protected nothing. That’s the uncomfortable part, and it’s why we’re writing it down.

The migration deleted more than it added

We moved to Postgres in one go, in January 2026, rather than in stages. Staging it would have meant two datastores and two authorisation models running side by side while we rewrote the security boundary. A clean cutover is a better place to be during an incident than a hybrid.

The cutover also took the custom authentication layer and the API tier with it. The app now talks to Postgres directly, and row-level security is the authorisation boundary. 25 tables, 51 database functions and 48 policies decide what any request may do. Because the boundary moved into the database, the migration removed more code than it introduced. We shipped 18 releases that month, more than in any other month of the project.

The timing is what we’d point at if you’re planning something similar. This happened in January; the stores went global on 1 March. We found the failure while the user base was still small enough to absorb it. That isn’t luck. Shipping to real people early is what buys you the chance to be wrong cheaply.

What it cost us

The new boundary has a failure mode that says nothing at all. Every new table needs an explicit grant next to its row-level policy. A policy on its own returns a silent 403 instead of an error anybody can read. It bit us once, and it’s now in the repository’s operating manual, right where a new table gets created.

What we got in return is a check we couldn’t have had before. A CI job applies every migration to a clean, real Postgres and runs the SQL test suites against it. So the exact statements a deploy will run have already run once. The deploy order matters too. The guarded fixture-sync function ships ahead of the migrations, so the cron job never meets a schema it doesn’t understand.

The rule we took away

Model the lock, not the load. Three questions do the work the capacity question was supposed to do:

  • How long does the longest writer hold the lock?
  • How often does it take it?
  • What happens to a user request that arrives in the middle?

For a read-heavy product with one small periodic writer, D1 is still a reasonable choice. For one whose periodic writer batches a hundred statements against the same tables users write to, it isn’t, at any concurrency.

So: an outside reviewer told us to leave. We published a document explaining why their reasoning was wrong. They were right about the conclusion anyway. We’d rather keep a record with both halves in it than one with only the flattering half.

One mail. No form.

Tell us what is in the way.

Say what you're building and where it stopped.

hello@beevelop.com

Copy it into your mail client if that is easier.

The draft opens with

  1. What you are building
  2. What is in the way
  3. The stack
  4. When you want to start

The contact page says who answers, and what the reply contains.

What procurement will ask.