When your HTTP handler needs to step aside
A user signs up. Your API returns a 201 Created in 40 milliseconds. Behind the scenes, you still need to send a welcome email, generate a PDF invoice, and sync the account to a third-party CRM. If you run that work inside the HTTP request, the connection hangs until the slowest service replies. Users click away. Servers run out of goroutines. You hand the work off to a background process instead. That is a job queue.
PostgreSQL as your queue
Most developers reach for Redis or RabbitMQ when they need a queue. River takes a different path. It treats your existing PostgreSQL database as the storage backend. You get a river_job table that tracks every task: when it was created, how many times it failed, when it should run next, and whether it is currently being processed. The tradeoff is clear. You add read and write load to your primary database, but you eliminate an entire infrastructure dependency. You get exactly-once delivery guarantees, automatic retries, and scheduling without managing a separate message broker.
River works by polling the database. A worker process runs a loop that queries for pending jobs, claims them, executes your code, and updates the row status. If your code panics or returns an error, River catches it, increments a failure counter, and schedules a retry with exponential backoff. When the job succeeds, the row is marked complete and archived.
Queue state lives in your database. Keep your schema migrations in version control.
The smallest working queue
Here is the baseline setup. You define a job struct, implement the worker interface, register it, and start the client.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"github.com/riverqueue/river"
)
// SendEmailJob carries the payload for a single email task.
type SendEmailJob struct {
river.Job // embed to get ID, Attempt, and Queue metadata
To string `json:"to"` // json tags map fields to the payload column
Subject string `json:"subject"` // payload is stored as JSONB in PostgreSQL
}
// SendEmailWorker executes the job when River hands it over.
type SendEmailWorker struct {
*river.WorkerBase[SendEmailJob] // embed for default retry and logging helpers
}
// Work runs the actual business logic.
func (w *SendEmailWorker) Work(ctx context.Context, job *SendEmailJob) error {
// check context before network calls to respect cancellation
if err := ctx.Err(); err != nil {
return err // abort early if the parent context expired
}
fmt.Printf("Sending to %s: %s\n", job.To, job.Subject)
return nil // nil signals success to River
}
func main() {
db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/mydb")
if err != nil {
log.Fatal(err) // fail fast if the database connection is invalid
}
client, err := river.NewClient(db, &river.Config{
Queues: map[string]river.QueueConfig{
river.QueueDefault: {MaxWorkers: 5}, // cap concurrent goroutines per queue
},
})
if err != nil {
log.Fatal(err)
}
workerSet := river.NewWorkers()
river.AddWorker(workerSet, &SendEmailWorker{}) // register the handler
client.WorkerSet(workerSet) // attach handlers to the client
// start the polling loop in the background
if err := client.Start(context.Background()); err != nil {
log.Fatal(err)
}
// insert a job into the river_job table
_, err = client.Insert(context.Background(), &SendEmailJob{
To: "alice@example.com",
Subject: "Welcome",
}, nil)
if err != nil {
log.Fatal(err)
}
}
Run this after executing river migrate up against your database. The migration creates the river_job table and the necessary indexes. The Start call launches a background goroutine that immediately begins polling for work. The Insert call writes a JSON payload into the table and returns control to your main function.
Keep your job structs small. Serialize only what the worker needs.
What happens under the hood
When client.Insert runs, River serializes your struct into JSON and writes a row to river_job. The row starts with a status of available and a max_attempts limit. The worker loop runs a SELECT ... FOR UPDATE SKIP LOCKED query against the table. This is the critical piece. PostgreSQL row-level locking ensures that only one worker claims a specific job. If two instances of your application are running, they will never execute the same job twice.
Once a worker claims a row, the status flips to running. River deserializes the JSON payload back into your SendEmailJob struct and calls the Work method. If Work returns nil, the row becomes completed. If it returns an error, River increments the attempt counter, calculates the next retry time using a built-in backoff strategy, and sets the status back to available. When the retry time arrives, the polling loop picks it up again.
Context propagation flows through the entire chain. The context you pass to Insert or Start determines how long the worker will wait for cancellation. If you call client.Stop, River finishes any jobs currently in the Work method and stops polling for new ones.
Context is plumbing. Run it through every long-lived call site.
Production-ready job execution
Real applications need retry policies, unique constraints, and graceful shutdowns. Here is how you configure them without cluttering your business logic.
// EnqueueWithPolicy shows how to attach retry and uniqueness rules.
func EnqueueWithPolicy(ctx context.Context, client *river.Client, to string) error {
_, err := client.Insert(ctx, &SendEmailJob{
To: to,
}, &river.InsertOpts{
// retry up to 5 times with exponential backoff
MaxAttempts: 5,
// prevent duplicate jobs for the same recipient within 10 minutes
UniqueOpts: &river.UniqueOpts{
ByQueue: true,
Expiry: 10 * time.Minute,
},
})
return err
}
The InsertOpts struct lets you override defaults per job. The UniqueOpts configuration tells River to check the river_job table before inserting. If a job with the same payload and queue already exists within the expiry window, River skips the insert and returns the existing job ID. This prevents thundering herds when multiple API requests trigger the same background task.
You should also handle graceful shutdowns in your main loop. River exposes a Stop method that waits for in-flight jobs to finish.
// GracefulShutdown demonstrates stopping the worker loop cleanly.
func GracefulShutdown(ctx context.Context, client *river.Client) {
// signal the worker to stop polling for new jobs
client.Stop()
// wait until all currently running jobs finish
<-client.Wait()
fmt.Println("River client stopped cleanly")
}
The Wait channel blocks until every active Work call returns. Combine this with a signal handler for SIGINT or SIGTERM, and your background workers will drain their queues instead of dropping mid-execution.
Don't leave goroutines hanging. Drain the queue before you exit.
Error handling and Go conventions
River follows standard Go patterns, which means you will see familiar idioms everywhere. The Work method signature always takes context.Context as the first parameter, conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. If the context expires, return ctx.Err() immediately.
Error handling in Go is explicit by design. You will write if err != nil { return err } repeatedly. The community accepts the boilerplate because it makes the unhappy path visible. River expects you to return errors from Work. If you return a wrapped error using fmt.Errorf("failed to send: %w", err), River preserves the error chain for logging and retry decisions.
Receiver naming follows a simple rule. The receiver is usually one or two letters matching the type. You will see (w *SendEmailWorker) Work(...) rather than (this *SendEmailWorker) or (self *SendEmailWorker). Keep it short. It reduces noise in the method body.
Public names start with a capital letter. Private names start lowercase. There are no public or private keywords. If you want a field to be serialized into the job payload, capitalize it or add a json tag. If you want to keep it internal, leave it lowercase. River only serializes exported fields.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If you spawn background work inside Work, derive a new context and cancel it when the function returns. The worst goroutine bug is the one that never logs.
Common pitfalls and compiler traps
River hides a lot of complexity, but a few patterns trip up developers quickly.
Forgetting to run migrations is the most common startup error. If you try to insert a job before the river_job table exists, PostgreSQL rejects the query with relation "river_job" does not exist. Run river migrate up during your container initialization or CI pipeline.
Blocking inside Work breaks the polling loop. Each queue runs a fixed number of goroutines defined by MaxWorkers. If your Work function hangs on a network call or waits on an unbuffered channel, those worker slots stay occupied. The queue backs up, and new jobs sit in available status forever. Always pass a context with a deadline to external HTTP or database calls.
Ignoring context cancellation causes goroutine leaks. If you spawn a goroutine inside Work and forget to cancel it when the parent context expires, that goroutine outlives the job. River will mark the job as complete or failed, but the leaked goroutine keeps running. Attach a derived context to every child goroutine and cancel it when Work returns.
The compiler catches type mismatches early. If you forget to embed river.Job in your struct, you get cannot use &SendEmailJob{} (value of type *SendEmailJob) as river.JobArgs value in argument. The embedded struct provides the metadata fields River needs to track state.
If you return a non-nil error from Work, River logs it and schedules a retry. If you panic, River catches the panic, logs the stack trace, and treats it as a fatal error for that attempt. Never rely on panics for control flow. Return errors explicitly.
The compiler complains with undefined: time if you forget to import the package. It rejects unused imports with imported and not used. Trust gofmt. Argue logic, not formatting.
When to reach for River
Background processing is not one-size-fits-all. Pick the tool that matches your workload.
Use River when you need reliable job execution with automatic retries and you already run PostgreSQL. Use River when you want exactly-once delivery guarantees without managing a separate message broker. Use a simple goroutine and channel when you need lightweight concurrency within a single request lifecycle. Use a cron job when you need to run a fixed schedule of independent tasks that don't require per-job state tracking. Use an external queue like RabbitMQ or Kafka when you need cross-language interoperability or massive throughput that exceeds your database's write capacity. Use plain sequential code when the task finishes in under 100 milliseconds: the simplest thing that works is usually the right thing.