Transactions and background jobs: a better fit than ever
Database-backed job queues have a reputation for sharp edges, and years ago the operational pain was real enough to warrant a detailed postmortem. Table bloat from long-running queries made it expensive for workers to hunt for lockable jobs across millions of dead tuples. Yet the alternative—a non-transactional queue sitting next to a transactional store—creates a more fundamental class of problems:
- A job emitted to a queue inside an uncommitted transaction can be picked up before its data is visible, guaranteeing a failure on the first attempt.
- A job emitted from a transaction that later rolls back will fail forever, burning resources on each retry until it dead-letters.
- Emitting after commit closes the visibility gap but opens a crash window between commit and enqueue, silently losing jobs.
- With neither store nor queue transactional, jobs can observe data in a partially written state, with unpredictable results.
Postgres's NOTIFY also respects transactions, which means a committed job can wake a worker immediately—bringing the delay between enqueue and execution down to sub-millisecond levels. Despite the operational friction experienced at Heroku, the queue stayed in Postgres because the transactional guarantees were worth the cost. What was missing was a well-designed, modern implementation.
River: a Go-native queue on Postgres
River is a new job queue built for Go and Postgres, using the pgx driver. It is now in beta. The project leans heavily on Go generics (available since Go 1.18) to give workers strongly typed access to job arguments:
type SortWorker struct {
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
}
No raw JSON blobs, no per-job unmarshalling boilerplate, and no reflection. Job arguments are plain Go structs; the only required method is Kind, which returns a stable string identifier used when the job round-trips through the database:
type SortArgs struct {
// Strings is a slice of strings to sort.
Strings []string `json:"strings"`
}
func (SortArgs) Kind() string { return "sort" }
Beyond the basics, River includes batch insertion, error and panic handlers, periodic jobs, subscription hooks for telemetry, and support for unique jobs.
Performance choices
River is designed around fast primitives:
- It uses
pgx's binary protocol support, reducing string marshaling and parsing. - Batch selects and updates minimize round trips to the database.
- Bulk inserts can use
COPY FROMfor efficiency.
The project is not yet optimized, and no benchmarks are published, but on a commodity laptop it processes roughly 10,000 trivial jobs per second.
Why the calculus changed
The original queue problems date to the Postgres 9.4 era, with a Ruby–process model that made contention worse. Several things have changed since.
Single-dependency stacks
Running fewer moving parts is increasingly attractive. A single Postgres dependency means teams only need operational expertise in one system—no separate Redis or bespoke queueing components to babysit. The same logic has appeared elsewhere: Rails 7.1 added Solid Cache, which 37signals uses to serve cache data from the same MySQL database as its application data, trading a memory-only cache for a disk-backed one and seeing cache hit rates improve by an order of magnitude.
Go's concurrency model
Ruby, lacking true parallelism, is often deployed with a process-forking model where each worker is an independent process. In the Heroku setup, every worker contended with every other for each new job, scanning millions of dead rows on every lock attempt. River clusters can run many processes, but each one runs jobs on goroutines with an internal producer that locks jobs on behalf of all its executors. Far fewer processes are needed, which means far less cross-process contention.
Postgres improvements
Nine major Postgres releases have landed since 9.4, several with features that matter specifically for queues:
SKIP LOCKED(9.5) lets a transaction skip rows already locked by others, making job locking far cheaper.REINDEX CONCURRENTLY(12) allows queue indexes to be rebuilt without blocking, removing accumulated bloat.- B-tree deduplication (13) shrinks low-cardinality indexes—exactly the kind a job queue has.
- B-tree deletion optimization (14) removes expired index entries during inserts, helping indexes with heavy churn.
There is also movement toward a transaction timeout setting. Postgres currently has statement timeouts and idle-in-transaction timeouts, but nothing bounding total transaction duration. For OLTP workloads and job queues alike, long-lived transactions are hazardous, and such a limit would be a welcome addition.
Try it
River is available on GitHub, with documentation and a getting started guide. The API is being treated as stable—the maintainers are aiming to avoid a future /v2—while internals are still being refactored and optimized. It is in beta and open for feedback.



