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).

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