Learn how to install River, run migrations to get Riverās database schema in place, and create an initial worker and client to start inserting and working jobs.
Prerequisites
River supports PostgreSQL and SQLite. This guide uses PostgreSQL, Riverās primary database target. River is tested using the three most recent major versions of PostgreSQL. For experimental SQLite support, see Using with SQLite.
Installation
To install River, run the following in the directory of a Go project (where a go.mod file is present):
go get github.com/riverqueue/rivergo get github.com/riverqueue/river/riverdriver/riverpgxv5Alternatively, the riverdatabasesql driver can be used instead of riverpgxv5 for compatibility with Goās built-in database/sql. See inserting jobs with Bun or GORM.
Use Java 21 or newer. The Java client is available from Riverās Maven repository at https://riverqueue.com/maven. Open-source River requires no account, credentials, or settings.xml server entry.
For Maven, add the repository, River, and a JDBC driver to your applicationās pom.xml:
<repositories> <repository> <id>river</id> <url>https://riverqueue.com/maven</url> <releases><checksumPolicy>fail</checksumPolicy></releases> <snapshots><enabled>false</enabled></snapshots> </repository></repositories><dependencies> <dependency> <groupId>com.riverqueue</groupId> <artifactId>river</artifactId> <version>0.2.0</version> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.12</version> </dependency></dependencies>For Gradleās Kotlin DSL (build.gradle.kts):
repositories { mavenCentral() maven { url = uri("https://riverqueue.com/maven") content { includeGroup("com.riverqueue") } }}dependencies { implementation("com.riverqueue:river:0.2.0") implementation("org.postgresql:postgresql:42.7.12")}The River repository also supplies the river-java parent POM; Maven Central supplies third-party dependencies and build plugins. For SQLite, use org.xerial:sqlite-jdbc:3.53.4.0 instead of the Postgres driver. See the Java API guide for an overview of the client.
Add a River driver and database adapter to your Gemfile (Ruby 3.2 or newer):
gem "riverqueue-sequel"gem "pg"Run bundle install. Rails applications can use riverqueue-activerecord instead of the Sequel driver.
Add River and its serialization and runtime dependencies (Rust 1.95 or newer):
cargo add riverqueuecargo add serde --features derivecargo add tokio --features macros,rt-multi-thread,signalThe default River feature includes Postgres support. River re-exports its compatible SQLx version as riverqueue::sqlx.
Use Node.js 26 or newer, with native Temporal support, and an ESM project. For TypeScript, use TypeScript 6 and @types/node 26 or newer:
npm install riverqueue @riverqueue/driver-pg pg zodnpm install --save-dev typescript @types/node @types/pgKeep Riverās core, driver, and optional packages on matching versions.
Running migrations
Run migrations before starting workers. Every language uses the same River schema and migration history; use one migration tool for a database shared by multiple languages. See migrations for deployment and programmatic options.
River persists jobs to a Postgres database, and needs a small set of tables created to insert jobs and carry out leader election. Itās bundled with a command line tool which executes migrations, and which future-proofs River in case other migration steps need to be run in future versions.
From the same directory as above, install the River CLI:
go install github.com/riverqueue/river/cmd/river@latestWith the DATABASE_URL of a target database (looks like postgres://host:5432/db), migrate up:
river migrate-up --database-url "$DATABASE_URL"See also migrations.
Install the Java CLI with JBang, using the same River version as your application:
RIVER_VERSION=0.2.0jbang app install --name river-java --java 21 \ --java-options=--enable-native-access=ALL-UNNAMED \ --repos https://riverqueue.com/maven --repos central \ --main com.riverqueue.cli.MigrationCli \ com.riverqueue:river-cli:${RIVER_VERSION}export PATH="$HOME/.jbang/bin:$PATH"river-java migrate-up --database-url "$DATABASE_URL"JBang downloads and caches the CLI and its dependencies, including both JDBC drivers. The CLI is a thin JAR and needs these dependencies to run. See migrations for an alternative using Maven and Java.
Run the bundled river command through Bundler to apply migrations to the database identified by DATABASE_URL:
bundle exec river migrate-up --database-url "$DATABASE_URL"Install riverqueue-cli with Cargo, then use its riverqueue command to apply the database migrations:
cargo install riverqueue-cliriverqueue migrate-up --database-url "$DATABASE_URL"Install the CLI as a development dependency, then run migrate-up against your database:
npm install --save-dev @riverqueue/clinpx riverqueue migrate-up --database-url "$DATABASE_URL"Job args and workers
Each job kind has a stable string identifier, JSON-encoded arguments, and a worker that executes it. Use the same kind and JSON field names when inserting jobs from another language.
Each kind of job in River requires two types: a JobArgs struct and a Worker[T JobArgs]. The JobArgs struct has two purposes:
- It defines the structured arguments for your worker. These arguments are serialized to JSON before the job is stored in the database.
- It defines a
Kind() stringmethod that will be used to uniquely identify the kind of job in the database.
Here is a simple Worker and JobArgs setup for a SortWorker which will sort and print a list of strings provided in its arguments:
type SortArgs struct { // Strings is a slice of strings to sort. Strings []string `json:"strings"`}
func (SortArgs) Kind() string { return "sort" }type SortWorker struct { // An embedded WorkerDefaults sets up default methods to fulfill the rest of // the Worker interface: river.WorkerDefaults[SortArgs]}
func (w *SortWorker) Work(ctx context.Context, job *river.Job[SortArgs]) error { sort.Strings(job.Args.Strings) fmt.Printf("Sorted strings: %+v\n", job.Args.Strings) return nil}Generics
River utilizes Go generics to simplify your Worker definitions. This means that your worker only needs to deal with fully structured and typed set of arguments. As in the example above, a Worker has a 1:1 relationship with the JobArgs type it handles.
Define arguments as a record and give the job a stable kind with JobType. A worker receives typed arguments through its WorkContext:
import com.riverqueue.JobType;import com.riverqueue.Workers;import java.util.List;
record SortArgs(List<String> strings) {}
var sort = JobType.of("sort", SortArgs.class);Workers.Handler<SortArgs> sortStrings = context -> { var sorted = context.args().strings().stream().sorted().toList(); System.out.println("Sorted strings: " + sorted);};Record properties use snake_case in JSON by default: userId becomes user_id.
Define an args object that supplies the job kind and JSON payload, then a worker with the same kind. The worker reads the decoded arguments from job.args:
require "riverqueue-sequel"
class SortArgs attr_reader :strings
def initialize(strings:) @strings = strings end
def kind = "sort"
def to_json = JSON.generate(strings: strings)end
class SortWorker def self.kind = "sort"
def work(job) puts "Sorted strings: #{job.args.fetch('strings').sort.inspect}" endendDerive JobArgs and Serdeās serialization traits for the args struct, then write an async worker that accepts Job<SortArgs>:
use riverqueue::{BoxError, Job, JobArgs, WorkContext, WorkOutcome};use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, JobArgs, Serialize)]#[river(kind = "sort")]struct SortArgs { strings: Vec<String>,}
async fn sort(_context: WorkContext, mut job: Job<SortArgs>) -> Result<WorkOutcome, BoxError> { job.args.strings.sort(); println!("Sorted strings: {:?}", job.args.strings); Ok(WorkOutcome::Complete)}Use defineJob to give the job a kind and an argument schema. The handler receives those arguments on job.args:
import { Client, Workers, defineJob } from "riverqueue";import { z } from "zod";
const sort = defineJob({ kind: "sort", schema: z.object({ strings: z.array(z.string()) }),});
async function sortStrings({ job }: { job: { args: { strings: string[] } } }) { console.log("Sorted strings:", [...job.args.strings].sort());}Registering workers
Jobs are uniquely identified by their ākindā string. Workers are registered on start up so that River knows how to assign jobs to workers:
workers := river.NewWorkers()
// AddWorker panics if the worker is already registered or invalid:river.AddWorker(workers, &SortWorker{})AddWorker panics in case of invalid configuration. Given its succinct syntax and that bad configuration should prevent a worker process from booting, panicking is probably a reasonable compromise for most applications. However, for those who find it distastely, AddWorkerSafely is also provided:
workers := river.NewWorkers()if err := river.AddWorkerSafely(workers, &SortWorker{}); err != nil { panic("handle this error")}Wrap your applicationās JDBC DataSource, then register the job type and handler in a Workers registry and pass it to the client builder:
import com.riverqueue.Client;import com.riverqueue.Database;
var database = new Database(dataSource, Database.Dialect.POSTGRES);var workers = new Workers().add(sort, sortStrings);var builder = Client.builder(database).workers(workers);You can also register handlers directly with builder.worker(type, handler). Configure the builder before calling build().
Add the worker class to a River::Workers registry so the client knows which job kinds it can work:
workers = River::Workers.new.add(SortWorker)Register the async worker function with add_fn. Its job argument type identifies the kind it handles:
let mut workers = riverqueue::Workers::new();workers.add_fn(sort)?;Register the job definition and its handler together in Workers:
const workers = new Workers().add(sort, sortStrings);Starting a client
A client inserts jobs, runs workers, and participates in maintenance. This client works the default queue with up to 100 concurrent jobs.
A River Client provides an interface for job insertion and manages job processing and maintenance services. A client is created with a database pool, driver, and config struct containing a Workers bundle and other settings. Hereās a client Client working one queue ("default") with up to 100 worker goroutines at a time:
dbPool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))if err != nil { // handle error}
riverClient, err := river.NewClient(riverpgxv5.New(dbPool), &river.Config{ Queues: map[string]river.QueueConfig{ river.QueueDefault: {MaxWorkers: 100}, }, Workers: workers,})if err != nil { // handle error}
// Run the client inline. All executed jobs will inherit from ctx:if err := riverClient.Start(ctx); err != nil { // handle error}Configure queue concurrency, build the client, and call start. Each active job runs on a virtual thread:
var client = builder.queue("default", 100).build();var run = client.start();Keep the returned RunHandle for shutdown. River uses your applicationās connection pool and does not close it.
Connect through the Sequel driver and configure the worker registry and queue concurrency before calling start:
DB = Sequel.connect(ENV.fetch("DATABASE_URL"))
client = River::Client.new( River::Driver::Sequel.new(DB), config: River::Config.new(queues: {default: 100}, workers: workers))
client.startBuild a client with the Postgres pool, worker registry, and queue configuration. Keep the run handle returned by start for shutdown:
use riverqueue::{Client, QueueConfig, sqlx::PgPool};
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let client = Client::builder(pool.clone()) .workers(workers) .queue("default", QueueConfig::new(100)) .build()?;
let mut run = client.start()?;Construct a client with a Postgres driver, registered workers, and queue concurrency, then await start to begin working jobs:
import { PgDriver } from "@riverqueue/driver-pg";import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = new Client(new PgDriver(pool), { queues: { default: { maxWorkers: 100 } }, workers,});
const run = await client.start();Stopping
The client should also be stopped on program shutdown:
// Stop fetching new work and wait for active jobs to finish.if err := riverClient.Stop(ctx); err != nil { // handle error}There are some complexities around ensuring clients stop cleanly, but also in a timely manner. Read Graceful shutdown for more details on Riverās stop modes.
Insert-only clients will insert jobs, but not work them, and donāt need to be started or stopped.
Stop fetching and wait for active jobs to finish before closing your applicationās connection pool:
run.stop();RunHandle also implements AutoCloseable for use with try-with-resources.
Stop the client before disconnecting the database so workers can finish and persist their results:
client.stopDB.disconnectAwait stop on the run handle when the application is ready to shut down:
run.stop().await?;Wait for the run to stop before closing the database pool:
await run.stop();await pool.end();Read Graceful shutdown for signal handling and stop modes. Insert-only clients do not need to be started or stopped.
Inserting jobs
Transactional enqueueing keeps jobs consistent with the application data they depend on. A job inserted in a transaction becomes visible to workers only after the transaction commits.
Client.InsertTx is used in conjunction with an instance of job args to insert a job to work on a transaction:
_, err = riverClient.InsertTx(ctx, tx, SortArgs{ Strings: []string{ "whale", "tiger", "bear", },}, nil)if err != nil { // handle error}See the InsertAndWork example for complete code.
Client.Insert that doesnāt take a transaction is also available, although as described in Transactional enqueuing, inserting jobs in transactions is usually more appropriate to avoid bugs.
_, err = riverClient.Insert(ctx, SortArgs{ Strings: []string{ "whale", "tiger", "bear", },}, nil)if err != nil { // handle error}See also Inserting many jobs.
Pass a JDBC connection with an open transaction to insert alongside application changes:
try (var connection = dataSource.getConnection()) { connection.setAutoCommit(false); try { // Make related application changes through connection. client.insert(connection, sort, new SortArgs(List.of("whale", "tiger", "bear")));
connection.commit(); } catch (Exception error) { connection.rollback(); throw error; }}Omit the connection for a standalone insertion. River does not commit or close a caller-owned transaction. Alternatively, client.transaction(connection -> { ... }) manages a transaction for you.
Transactions on the same Sequel database connection automatically include job insertion:
DB.transaction do # Make related application changes here. client.insert SortArgs.new(strings: %w[whale tiger bear])endOutside an application transaction, the same insert call inserts immediately. Active Record transactions work the same way when the driver uses the same connection pool.
Attach an insertion to a SQLx transaction with .tx:
let mut tx = riverqueue::database::begin_postgres(&pool).await?;
// Make related application changes through &mut *tx.client.insert(SortArgs { strings: vec!["whale".into(), "tiger".into(), "bear".into()] }) .tx(&mut tx) .await?;
tx.commit().await?;Omit .tx(&mut tx) for a standalone insertion. Retain a pool handle with pool.clone() when constructing the client. Use Riverās transaction helper so beginning a transaction is safe if its future is cancelled.
Pass an open transaction connection as tx to include a job in application changes:
const tx = await pool.connect();
try { await tx.query("BEGIN");
// Make related application changes through tx. await client.insert(sort, { strings: ["whale", "tiger", "bear"] }, { tx });
await tx.query("COMMIT");} catch (error) { await tx.query("ROLLBACK"); throw error;} finally { tx.release();}Omit { tx } for a standalone insertion. River does not begin, commit, or roll back a caller-owned transaction.