Build1 publisher2 min readPublished
SvelteKit starter kits' boot-time migrations race once a second replica starts
Many SvelteKit starter kits run Drizzle migrations on every boot, a default that turns into a race once two replicas start at the same moment. A dev.to post moves the writes into one explicit step before traffic and keeps the boot call as a backstop.
The Engineer · Build desk

What happened
- Each replica decides on its own which migrations are pending, and no lock spans that check and the write, so several replicas can conclude 0003_add_seats is unapplied at once.
- A 'column already exists' error is the mild failure, thrown by the replica that reaches a migration after another replica has already applied it.
- Worse is a multi-statement migration left half done when a replica dies mid-run, leaving the migration table and the actual schema in disagreement.
- Because the window is narrow, the bug usually appears on the first deploy that adds a replica or turns on autoscaling, then stays hidden until the next schema change.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Going from one replica to two makes schema changes a pipeline job: someone has to own a migrate step that runs once, in order, before traffic moves to the new version.
- exposure A clean first scale-out proves little. The race can sit unnoticed through quiet deploys and fire on the next release that carries a schema change.
- constraint The single-writer fix holds only while the explicit step always runs first; one skipped step on a multi-replica deploy sends every replica back through the unlocked boot path.
The call sits inside `openDb`, the connection helper in `src/lib/server/db/index.ts` [1]. In the version the post quotes, `openDb` builds a postgres client with `prepare: false`, optionally creates a schema and points `search_path` at it, wraps the client in Drizzle, and awaits `migrate()` before returning [8]. No replica gets a database handle until it has run the migrator on its own [8]. For a local dev server, a single-instance app or a preview deploy, the author calls this the right default [2].
Two details in that helper constrain any replacement. According to the post, `prepare: false` is required because the Drizzle Postgres migrator uses execution semantics the prepared-statement cache cannot carry [9]. `MIGRATIONS_DIR` resolves against `process.cwd()`, so the SQL ships with the code and every replica boots holding the same pending work [10].
"Nothing about migrate() is wrong. The problem is how many callers there are," the author wrote [4]. The proposed fix cuts the callers to one. Migrations run as an explicit, single-writer step before the new version serves traffic, and the boot-time call becomes a no-op [11]. In `package.json`, `db:generate` runs `drizzle-kit generate` and `db:migrate` runs `tsx scripts/migrate.ts` [12]. The script throws if `DATABASE_URL` is unset, opens a client with the same `prepare: false`, applies `./drizzle`, prints `Migrations applied to` with the target host, and closes the client in a `finally` block [13]. The host line is a small, good decision. The deploy log records which database was changed [13].
The post then keeps the boot-time call. The explicit step "is the event" and the boot-time call "is the guarantee," the author wrote [14]. That guarantee covers a deploy that skipped the step, a preview pointed at the production database, and a restore from backup into a half-migrated state [15].
The fix takes no database lock [2]. Its safety comes from order. One writer runs first, so the replicas boot and find nothing left to apply [11]. The backstop is the same unlocked code path, now with a nicer name [1]. If the step is skipped on a deploy where two or more replicas boot at once, each replica runs the pending check the post describes as a race [1].
I think the design is right for a team that ships through one pipeline and fails the deploy when `db:migrate` fails. In that setup the boot-time call only runs against a database the step has already brought up to date [11]. Where previews or restores can reach production outside that pipeline, the single writer is a convention. Closing that gap takes a lock inside the migrator, and the post does not include one [2].
What to watch
- Whether Drizzle's Postgres migrator adds a lock spanning the pending check and the write; that would make the boot-time backstop safe at any replica count.
- Whether SvelteKit starter kits move migrate() out of the connection helper or ship a db:migrate script by default.