Read replica routing in SvelteKit with Drizzle

At scale, your Postgres primary is both your write path and your read path — and every analytics query, audit-log grep, and list endpoint competes for the same connections and CPU as your inserts. Read replicas let you move lag-tolerant reads onto copies of the data so the primary spends its capacity on writes. This guide explains why that matters, the honest catch (replication lag), and the exact two-handle pattern from docs/deployment.md §7 that routes reads and writes through separate Drizzle handles.

Why read replicas matter at scale

A single Postgres server has one resource ceiling: a fixed connection budget (PG_MAX_CONNECTIONS, default 20 in the kit) and one CPU/memory pool shared by every query. In a small app, reads and writes share that comfortably. As you grow, three workload shapes start to crowd the primary:

  • Horizontal read scaling: most SaaS workloads are read-heavy — a handful of writes (signups, invites, membership changes) behind a much larger volume of page loads, dashboards, and list views. Adding replicas spreads the read load across multiple read-only copies rather than asking one primary to serve it all.
  • Analytics and reporting off the primary: heavy reporting queries — audit-log greps over the append-only table, cohort analyses, full scans that answer "who did what this month" — are exactly the queries you do not want competing with live request traffic. A replica isolates them so a slow report does not stall a signup.
  • Read parallelism without write contention: because replicas only ever read (from the primary's WAL), you can scale read throughput independently of write throughput, and keep the primary's own load focused on writes and consistency-critical reads.

The starter's audit log is the canonical example: audit_log is append-only by construction with no UPDATE/DELETE path, and its seq-ordered, org-scoped entries are read far more often than they are written. Service-side reads over the audit log are a natural candidate for replica routing.

The honest caveat: replication lag

Read replicas are asynchronous. A replica applies streaming changes from the primary's write-ahead log (WAL) on a delay — typically milliseconds, but variable under load. That means a read routed to a replica can return data a moment older than the primary. Replica routing is therefore only safe for lag-tolerant reads: reads where returning slightly-stale data for a moment is harmless. It is unsafe for reads where freshness is a correctness or security requirement.

Concretely, this kit's data has two classes of rows where replicas are dangerous:

  • Anything the user just wrote: after a user creates an organization, adds a member, or accepts an invite, the very next screen must show that result. If that next load reads from a lagging replica, the user can see an action "vanish" — they just did it and the UI shows it never happened. Writes you are about to confirm must be read back on the primary.
  • Authoritative counts and state that gate actions: seat counts (seatsUsed = COUNT(memberships), enforced at invite-accept time via assertSeatAvailable) and invite validity (accepted_at_ms IS NULL, revoked_at_ms IS NULL) decide whether an operation may proceed. Reading a stale count or a not-yet-revoked invite can let an over-limit join or a revoked-invite accept slip through. These stay on the primary.

The rule of thumb: use a replica for reads that describe past state and tolerate a momentary snapshot; keep every read that decides a write or reflects a just-completed action on the primary.

The Drizzle two-handle pattern from deployment.md §7

Drizzle supports separate handles for reads and writes. The documented pattern in docs/deployment.md §7 opens two postgres.js clients — a write pointed at DATABASE_URL and a read pointed at REPLICA_DATABASE_URL (falling back to DATABASE_URL so the setup stays safe before a replica exists) — then derives two Drizzle handles:

// src/lib/server/db/index.ts (add-yourself pattern, docs/deployment.md §7)
const write = postgres(process.env.DATABASE_URL!);
const read = postgres(process.env.REPLICA_DATABASE_URL! ?? process.env.DATABASE_URL!);
export const db = drizzle(write, { schema });
export const readDb = drizzle(read, { schema });

The two handles are typed identically against the same schema, so readDb is usable anywhere db is — same tables, same columns, same Db type. The pattern's elegance is that services are unchanged: a service function that today takes db and performs a read can accept readDb instead without any other modification, because both are the same Drizzle type. Unchanged services, new handles.

How to decide which queries use readDb

Route to readDb the reads that tolerate replication lag:

  • Read-only dashboards: aggregate views, "recent activity" panels, and org overviews that describe historical state. A few milliseconds of lag on a dashboard is invisible.
  • Audit log greps: append-only entries queried by org_id + seq ordering. Because new entries only ever append, reading a replica yields a correct-but-maybe-not-yet-newest view — ideal for investigative and reporting scans.
  • List endpoints: member lists, org lists, long read-heavy enumerations where the exact instant of each row's appearance is not load-bearing.

Keep on db (the primary) every mutating service and every read whose correctness depends on freshness:

  • Anything the user just wrote — read-back of a just-created org, membership, or invite result must come from the primary.
  • Seat countsseatsUsed = COUNT(memberships) gates invite acceptance and must reflect current state.
  • Invite validity — a single-use invite whose accepted_at_ms IS NULL and revoked_at_ms IS NULL is checked by the conditional UPDATE ... WHERE ... RETURNING on the primary; treat it as a write-path read, never a replica read.

When in doubt, the conservative default is correct: only move a read to a replica when you can point at a concrete, lag-tolerant use and you have a way to observe lag (your provider's replica status or a built-in replication-delay metric).

How to wire it in db/index.ts

In the shipped src/lib/server/db/index.ts, a single memoized singleton getDb() opens one postgres() client bound to DATABASE_URL and returns one Drizzle handle:

// src/lib/server/db/index.ts (shipped)
export async function getDb(): Promise<Db> {
  if (!singleton) {
    const url = process.env.DATABASE_URL;
    if (!url) throw new Error('DATABASE_URL is not set; see .env.example');
    const max = Number.parseInt(process.env.PG_MAX_CONNECTIONS ?? '', 10);
    singleton = (await openDb(url, { max: Number.isFinite(max) && max > 0 ? max : 20 })).db;
  }
  return singleton;
}

To add replica routing you keep this primary handle as db (all mutating services and consistency-critical reads keep using it) and add a second handle readDb bound to the replica connection string, following the §7 snippet. Because openDb() runs the Drizzle migrator, only the write handle should run migrations — a replica is read-only and must not apply DDL. Practically, that means applying the §7 pattern to the post-migration layer (or opening the replica with a non-migrating connection), keeping getDb()/migrations on the primary.

Then, in a +page.server.ts load function or a service read, you pass the appropriate handle: read-dashboard queries receive readDb; every write path continues to go through a service taking db. This preserves the kit's layering rule — routes never touch the database directly except through services — because the handle a service is handed is the only change.

Operational notes: REPLICA_DATABASE_URL and failover

The replica connection string is supplied via a second environment variable, REPLICA_DATABASE_URL, matching the §7 snippet. The read handle falls back to DATABASE_URL when the variable is unset, so the pattern degrades gracefully to a single primary before you provision a replica. Mark the variable as optional in your env template and set the same PG_* pool tuning (PG_MAX_CONNECTIONS, PG_IDLE_TIMEOUT, PG_CONNECT_TIMEOUT) on both handles so idle and connection-timeout behavior stays consistent with what docs/deployment.md §5 documents.

Failover: replicas are read-only and do not automatically take over the primary in this kit. Document the real topology you deploy on and decide, per read path, what a primary outage or a replica outage means:

  • Primary outage: writes fail everywhere; reads that already fell back to DATABASE_URL fail too. Replica-only reads may keep working. Plan promotion (a provider-managed read-replica promotion or your own) outside the app code.
  • Replica outage / lag spike: the safe default is to fail the replica read toward the primary, not to keep serving stale data. Because the fallback to DATABASE_URL is a configuration fallback (at connection-open time), not a runtime per-query fallback, you should wrap replica reads in an explicit try/catch that retries on db if the replica is down — and decide deliberately whether to accept lag for each path rather than relying on an implicit fallback.

Whatever you pick, make the policy explicit and documented, and keep the security-critical reads (sessions, seat counts, invites) pinned to the primary regardless of replica health.

A note on security-sensitive reads

Session verification is tempting to move to a replica because it runs on nearly every request. Resist that unless you control lag tightly: a session row verified against a stale replica can keep a revoked session alive (a revoked session is deleted from sessions, and a lagging replica may still show it), and can fail to see a freshly-issued session. Session management has the same two-handle consideration and is covered in the companion guide — keep consistency-critical auth reads on the primary.

Production checklist

  • Read replicas are a documented add-yourself pattern, not shipped in v0.1. You add two connections and two Drizzle handles by hand following the snippet in docs/deployment.md §7 — the kit ships one handle over the primary by default.
  • Route to readDb ONLY reads that tolerate replication lag. Anything the user just wrote, seat counts that gate an action, and invariant checks must stay on db so they never see stale data.
  • Set REPLICA_DATABASE_URL to your replica connection string; the fallback to DATABASE_URL keeps the pattern safe before a replica exists.
  • Keep every mutating service on db. Unchanged services, new handles — the service layer code does not change, only the handle a read path is given.
  • Consider session verification as a read path that still must hit the primary, or a replica you control a tight lag budget on — a cached session row that ignores a revocation is a security hole, not a performance win.
  • Plan failover: document what a primary outage means for a replica-only read path, and keep the read fallback to the primary explicit rather than implicit.

Related guide: Connection pooling · Session management

Related reading

Get in touch

Questions about the product, team licenses, or anything else? We'll respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.