Read Replicas

Configure one or more read replicas and Turbine load-balances read-only queries across them, round-robin, while every write stays on the primary. It's one config field, no query-site changes, no separate client.

import { turbine } from './generated/turbine';
 
const db = turbine({
  connectionString: process.env.DATABASE_URL,          // primary
  replicas: [
    process.env.REPLICA_URL_1,
    process.env.REPLICA_URL_2,
  ],
});

Reads now round-robin across the two replicas; writes go to the primary. Nothing else changes.

What routes where#

The split is by operation, not by table. Read-only operations issued outside a transaction go to a replica; everything else uses the primary.

Goes to a replicaAlways uses the primary
findMany, findFirst, findUniquecreate, createMany
findFirstOrThrow, findUniqueOrThrowupdate, updateMany, delete, deleteMany, upsert
count, aggregate, groupByEverything inside a $transaction body (reads included)
findManyStreampipeline, raw, sql
$listen / $notify, observability flushes

Reads inside a transaction stay on the primary because a transaction is a single connection, splitting it across pools would break its consistency guarantees.

Replica entries, strings vs pools#

Each entry in replicas is either a connection string or an external pool, and the two follow different lifecycles:

  • Connection string, Turbine constructs an owned pg.Pool for it, with the same pool tuning and one-time type-parser registration as the primary. disconnect() closes it.
  • PgCompatPool (Neon, Vercel Postgres, a shared pg.Pool), Turbine registers no type parsers and never ends it. The caller owns its lifecycle, exactly like the external-pool contract for the primary.

Omitting replicas (or passing []) leaves the default single-pool path completely unchanged, there's no proxy, no routing overhead.

Replication lag, reading your own writes#

Replicas are eventually consistent. A row you just wrote on the primary may not be on a replica yet, so a read routed there can miss it. When you need read-your-writes consistency, pin the read to the primary with $primary():

const created = await db.orders.create({ data: { total: 100 } });
 
// This read might hit a lagging replica and not see `created`:
const maybeStale = await db.orders.findUnique({ where: { id: created.id } });
 
// Force the primary, guaranteed to see the write:
const fresh = await db.$primary().orders.findUnique({ where: { id: created.id } });

$primary() returns a view of the client, same schema, same config, with every operation (reads included) pinned to the primary. The instance is cached, so calling it repeatedly is cheap. When no replicas are configured, $primary() returns the client itself.

Note: Turbine does not detect lag or "wait for" a replica to catch up. Route reads that must observe a recent write through $primary() (or run them inside the same $transaction as the write).

PowDB: snapshot serving as the replica#

The replicas config above is Postgres-only, but PowDB (≥ 0.14) has its own replica shape: read-only snapshot serving. A restored backup or checkpointed copy of the data directory is served strictly read-only, powdb-server --readonly, or in-process via turbinePowDB({ embedded: dir, readonly: true }, schema), with any number of concurrent readers.

The routing is manual but the errors do the work: any write against the snapshot throws a typed ReadOnlyError (TURBINE_E018) with reason: 'snapshot', so applications can catch it and route the write to the primary. Passing readonly: true on the Turbine client makes the refusal local and immediate (no round-trip). Freshness is your snapshot cadence. This is snapshot serving, not streaming replication, so treat it like a cache tier that updates on your backup schedule. See PowDB read-only snapshots and ReadOnlyError.

Interaction with global filters#

Read routing is orthogonal to global filters. A filtered read routed to a replica carries the same compiled WHERE it would on the primary, soft-delete and tenancy filters apply identically wherever the query runs.

See also#