We picked the wrong database.

An outside review told us to leave Cloudflare D1. We wrote down why the review was wrong, stayed, and had to migrate anyway thirteen months later.

2 to 5secondsthe write lock, held by one sync run
SQLite takes one writer; each run batched over a hundred statementsRepository, database and CI configuration, 5 August 2026
18releasesshipped in the migration month
January 2026, the highest cadence of the projectRepository, database and CI configuration, 5 August 2026

Choose a datastore for a product with a fixed launch date, and the first question you are asked is capacity. How many people at once? That is the wrong question. We can say so with some confidence, because we answered it correctly and still got the decision wrong.

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

The advice we refused

In December 2024 an outside review recommended abandoning Cloudflare D1 before the build started. We stress-tested the recommendation rather than taking it, and the model underneath it did not hold. The review assumed roughly 2,000 concurrent users. Our own peak modelling for a tournament audience gave 15,000 to 22,500.

We also characterised the write pattern, which the review had not. For a prediction game it is bursty in the hour before kickoff and read-dominated once a match is running. So we wrote the reasoning down as an architecture decision record and stayed on D1. On the numbers we had, that was the correct call. It is still the correct call on those numbers today.

Production disagreed

The symptom arrived 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 hour, not on one platform. The first reports read as flaky phones.

They were not. The fixture sync ran on a two-minute cron against the sports data provider, and each run batched over a hundred statements. D1 is SQLite underneath, and SQLite takes exactly one writer. Those batches held the write lock for two to five seconds. Every user request arriving inside that window blocked, then failed.

The axis, not the number

Our concurrency model was right about the axis it measured. It was also irrelevant to the axis that broke. The failure was never a function of how many people were present. It was a function of 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 is the part we would rather not write down, and the part worth reading.

The migration deleted more code than it added

We moved to Postgres in one go in January 2026, rather than in stages. A staged migration means two datastores and two authorisation models running at once. The security boundary was about to be rewritten, and a cutover is a better place to be during an incident than a hybrid.

That cutover 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 sits in the database, the migration removed more code than it introduced. Eighteen releases shipped that month, the highest cadence of the project.

The timing is the part worth copying. This happened in January; the stores went global on 1 March 2026. The failure was found on a user base small enough to absorb it. That is not luck. Shipping to a real audience early is what buys the chance to be wrong cheaply.

What it cost

The new boundary has a failure mode that says nothing at all. Every new table needs an explicit grant alongside its row-level policy. A policy on its own returns a silent 403 rather than an error anybody can read. That is a real footgun. It is written into the repository’s operating manual, and it is written there because it bit us once.

What the move bought in return is a check that could not exist 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 execute run before the deploy does. The order is deliberate too. The guarded fixture-sync function ships ahead of the migrations, so the cron job never meets a schema it does not 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 does a user request do when it arrives in the middle?

For a read-dominated product with one small periodic writer, D1 remains a reasonable answer. For one whose periodic writer batches a hundred statements against the tables users are writing to, it is not, at any concurrency.

The honest summary is uncomfortable in both directions. An outside reviewer told us to leave. We published a document explaining why their reasoning was wrong. They were right about the conclusion anyway. Both halves are true. Only one is pleasant to publish, and a record that carries only the pleasant half is not a record.

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.