River supports PgBouncer. Insert-only clients can use transaction pooling. For running clients, a direct Postgres or session-pooled connection is the simplest configuration across languages because some runtime services use session-scoped database features.
Application queries inside workers can use a separate transaction-pooled connection pool, as shown below.
PgBouncer modes
As background, PgBouncer supports three modes:
Session pooling: Pooling occurs at the session level. When a client requests a session, it’s assigned a “real” Postgres connection, and is allowed to keep it until explicitly released.
Transaction pooling: Pooling occurs at the transaction level. When a client starts a transaction, it’s affixed to a single Postgres connection until the transaction commits or is rolled back.
Statement pooling: Pooling occurs at the statement level. Clients only stay affixed to a Postgres connection for as long as it takes to execute a single statement. Multi-statement transactions aren’t allowed.
Ways to use River client
An insert-only client is never started and needs transactions for features such as unique insertion. Statement pooling is unsuitable.
A running client claims jobs, handles queue control, and may become the maintenance leader. Go, Rust, and TypeScript use Postgres notifications unless configured for polling; Ruby currently polls. Disabling notifications does not by itself establish that every other session-scoped service will work through transaction pooling. Use direct or session-pooled connections for the runtime unless you have verified the full configuration.
Workers may use a separate pool for their own application queries. When an application transaction also inserts jobs, use an insert client with the matching driver and transaction connection.
A realistic scenario
A plausible scenario where River and PgBouncer are used together is to configure PgBouncer with transaction pooling, and use it for inserting all jobs and performing all work, but giving a raw Postgres connection pool to work coordinators.
Each running client can work many jobs concurrently, so a relatively small number of worker processes can handle substantial throughput. Size pools for the runtime’s reserved connections and the application queries the workers issue; increasing concurrency does not create additional database capacity.
Insert clients
To make this concrete, here’s an insert-only client like might be used in a web or API process, configured to use PgBouncer as its pool:
dbPool, err := pgxpool.New(ctx, os.Getenv("PGBOUNCER_DATABASE_URL"))if err != nil { // handle error}
riverClient, err := river.NewClient(riverpgxv5.New(dbPool), &river.Config{})if err != nil { // handle error}
// client NOT started_, err = riverClient.Insert(ctx, DatabaseArgs{SQL: "SELECT 1"}, nil)if err != nil { // handle error}Connect the insertion client’s Sequel driver to PgBouncer using the producer database URL:
producer_db = Sequel.connect(ENV.fetch("PGBOUNCER_DATABASE_URL"))producer = River::Client.new(River::Driver::Sequel.new(producer_db))
producer.insert argsBuild the insertion client with a Postgres pool connected to PgBouncer:
let producer_pool = riverqueue::sqlx::PgPool::connect(&std::env::var("PGBOUNCER_DATABASE_URL")?).await?;let producer = riverqueue::Client::builder(producer_pool).build()?;
producer.insert(args).await?;Point the producer’s Postgres pool at PgBouncer and pass it to an insertion client:
const producerPool = new Pool({ connectionString: process.env.PGBOUNCER_DATABASE_URL });const producer = new Client(new PgDriver(producerPool));
await producer.insert(myJob, args);Workers
Individual workers also use PgBouncer. They’re worked by a work coordinator, but don’t need to use the same database pool configuration as their progenitor:
type DatabaseArgs struct { SQL string `json:"sql"`}
func (DatabaseArgs) Kind() string { return "database" }
type DatabaseWorker struct { river.WorkerDefaults[DatabaseArgs] dbPool *pgxpool.Pool}
func (w *DatabaseWorker) Work(ctx context.Context, job *river.Job[DatabaseArgs]) error { _, err := w.dbPool.Exec(ctx, job.Args.SQL) if err != nil { return err }
return nil}dbPool, err := pgxpool.New(ctx, os.Getenv("PGBOUNCER_DATABASE_URL"))if err != nil { // handle error}
workers := river.NewWorkers()river.AddWorker(workers, &DatabaseWorker{dbPool: dbPool})Application work can use its own pool:
APP_DB = Sequel.connect(ENV.fetch("PGBOUNCER_DATABASE_URL"))
class DatabaseWorker def self.kind = "database"
def work(_job) APP_DB.fetch("SELECT 1").all endendCapture or store a separate application pool in the worker. Queries through that pool use PgBouncer:
riverqueue::sqlx::query("SELECT 1").execute(&application_pool).await?;Give worker handlers a separate pool for application queries through PgBouncer. This pool is independent of the one used by River’s runtime:
const applicationPool = new Pool({ connectionString: process.env.PGBOUNCER_DATABASE_URL });
workers.add(databaseJob, async () => { await applicationPool.query("SELECT 1");});Work coordinator
The work coordinator needs a real Postgres database pool so it can use LISTEN/NOTIFY, but is configured with high concurrency so relatively few total coordinators are required:
dbPool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) // NOT PgBouncerif err != nil { // handle error}
riverClient, err := river.NewClient(riverpgxv5.New(dbPool), &river.Config{ Queues: map[string]river.QueueConfig{ river.QueueDefault: {MaxWorkers: 1_000}, }, Workers: workers,})if err != nil { // handle error}
if err := riverClient.Start(ctx); err != nil { // handle error}Use a direct or session-pooled database connection for the client that starts workers:
runtime_db = Sequel.connect(ENV.fetch("DATABASE_URL")) # Direct or session-pooled.
client = River::Client.new(River::Driver::Sequel.new(runtime_db), config: River::Config.new(queues: {default: 10}, workers: workers))
client.startBuild the working client with a pool connected directly to Postgres or through session pooling:
let pool = riverqueue::sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let client = riverqueue::Client::builder(pool) .workers(workers) .queue("default", riverqueue::QueueConfig::new(100)) .build()?;
let mut run = client.start()?;Use a direct or session-pooled URL for this pool.
Give the working client its own direct or session-pooled connection, separate from any transaction-pooled application queries:
const runtimePool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = new Client(new PgDriver(runtimePool), { workers, queues: { default: { maxWorkers: 100 } },});
const run = await client.start();Use a direct or session-pooled URL for this pool and stop the client before ending it.