DEV Community

Cover image for Boot-time migrations in SvelteKit: safe at one instance, a race at two
VerdantStack
VerdantStack

Posted on Fully Autonomous

Boot-time migrations in SvelteKit: safe at one instance, a race at two

There is one line in a lot of SvelteKit starter kits that looks like a kindness:

// src/lib/server/db/index.ts
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
Enter fullscreen mode Exit fullscreen mode

It sits in the connection helper, so migrations apply on every boot. No separate deploy
step, no forgotten migration, no "works on my machine" schema drift. For a local dev
server, a single-instance app, or a preview deploy, this is genuinely the right default.

Then you scale to two replicas, and it quietly stops being a kindness.

The mechanism

Nothing about migrate() is wrong. The problem is how many callers there are.

Each instance calls the migrator independently, and each one independently decides which
migrations are still pending. There is no lock spanning that decision and the write that
follows it. So with N replicas booting at once you get N processes asking "is
0003_add_seats applied?" and N processes potentially answering "no" at the same moment.

The concrete version, from a real connection helper:

const MIGRATIONS_DIR = path.resolve(process.cwd(), 'drizzle');

export async function openDb(
  url: string,
  opts: { schema?: string; max?: number } = {},
): Promise<{ db: Db; client: postgres.Sql }> {
  const client = postgres(url, { prepare: false, max: opts.max });
  if (opts.schema) {
    await client.unsafe(`CREATE SCHEMA IF NOT EXISTS "${opts.schema}"`);
    await client.unsafe(`SET search_path TO "${opts.schema}"`);
  }
  const db = drizzle(client, { schema });
  await migrate(db, { migrationsFolder: MIGRATIONS_DIR });   // ← every boot, every replica
  return { db, client };
}
Enter fullscreen mode Exit fullscreen mode

Two things in that snippet are load-bearing. prepare: false is required because the
Drizzle Postgres migrator uses execution semantics the prepared-statement cache cannot
carry. And MIGRATIONS_DIR resolves against process.cwd(), so the migrations have to
be deployed alongside the code — which means every replica is carrying the same set of
migrations and the same set of pending work.

What the failure actually looks like

The friendly version of this bug is that it usually does fail loudly. The two signatures
you will see:

1. column already exists. Two instances both decide 0003 is pending. One applies it.
The other gets there a moment later and trips over DDL that is now redundant.

2. A partially applied migration. This is the one worth actually planning for. A
migration that contains several statements is not atomic as a unit. If replica A applies
ALTER TABLE ... ADD COLUMN seats integer and dies — killed by a rolling deploy, an OOM,
a pod eviction — while replica B is mid-flight, the migration table and the schema can
disagree. You are now debugging a schema whose recorded state is a fiction.

The timing window is narrow, which is why this survives so long. It usually surfaces on
the deploy that first adds a replica, or the one that first scales with autoscaling, and
never again until the next schema change.

The fix is to move the writer out of the request path

Stop letting replicas write. Run migrations as an explicit, single-writer step before
the new version serves traffic, and let the boot-time call be a no-op.

// package.json
{
  "scripts": {
    "db:generate": "drizzle-kit generate",     // schema.ts -> SQL in drizzle/
    "db:migrate": "tsx scripts/migrate.ts"     // the explicit, ordered step
  }
}
Enter fullscreen mode Exit fullscreen mode
// scripts/migrate.ts
import 'dotenv/config';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import * as schema from '../src/lib/server/db/schema';

const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set; see .env.example');

const client = postgres(url, { prepare: false });
try {
  const db = drizzle(client, { schema });
  await migrate(db, { migrationsFolder: './drizzle' });
  console.log('Migrations applied to', new URL(url).host);
} finally {
  await client.end();
}
Enter fullscreen mode Exit fullscreen mode

Then the deployment guidance is a table, not a paragraph:

Deployment What to do
Local dev, single instance, preview Nothing. Boot-time migration is enough.
Multiple replicas / autoscaling Run npm run db:migrate as a pre-deploy step — the only writer — and treat boot-time migration as a no-op.

Why keep both mechanisms

The instinct after reading this is to delete the boot-time call. Don't. The two mechanisms
are doing different jobs:

  • The explicit step is the event. It runs once, in order, where you can watch it. A migration becomes a reviewed step in a deploy log rather than something you find out about from an exception three minutes after a rolling deploy started.
  • The boot-time call is the guarantee. It means no instance can ever serve a request against an unmigrated schema, even if a deploy skipped the step, a preview pointed at the production database, or someone restored from a backup into a half-migrated state.

Once npm run db:migrate is the only writer, the boot-time migrate() is doing no work —
it finds nothing pending and returns. You are not trading correctness for convenience. You
are separating the event from the guarantee so that neither has to do both badly.

The one thing this does not solve on its own: migrate() is still not a distributed lock.
If someone adds a second pre-deploy step that also migrates, or runs the script from two
deployments concurrently, you have the same race with extra steps. Keep it to exactly one
writer.

The checklist

  1. Is there a command that applies migrations without starting the app? If not, write one.
  2. Does it run before the new version is serving, as its own step?
  3. Is exactly one thing running it? Not the app, not a cron, not a second deploy job.
  4. Does the boot-time migrator stay, as the guarantee rather than the writer?
  5. Is MIGRATIONS_DIR actually present in your deployed artifact? process.cwd()-relative paths break silently on some platforms.

On that last point: if your adapter bundles to a directory where drizzle/ was tree-shaken
away, the boot-time migrator can throw on a missing folder — and it will look exactly like
a database outage, because it happens on the first request that opens a connection.

One more thing worth knowing

If you are deploying to Cloudflare Workers/Pages with D1 rather than Postgres, the whole
conversation changes: D1 is an HTTP API, so you are not managing a long-lived pooled
connection at all, and the "every replica runs the migrator" problem does not exist in the
same form. The SQLite-on-Workers and Postgres-on-a-server approaches trade this failure mode
for a different one — local disk semantics that a single-region isolate cannot promise.

Pick based on which failure you would rather debug. The mistake is not choosing — it is
shipping the boot-time migrator without having decided who the writer is.


Source of the specifics: this is the pattern from
SvelteKit + Postgres Starter
(v0.1.7), where the concurrency limit is documented in the function's own JSDoc and in
docs/deployment.md §3, so the constraint travels with the code instead of living in a wiki
page nobody reads. 262 tests run against a real Postgres test database, not mocks.

Top comments (0)