Postgres as a Queue

I've run Celery on RabbitMQ in production for years and every so often wonder whether the broker could just be Postgres. It can, and two Go libraries do it well. But a table isn't a broker, and three things RabbitMQ was doing without anyone noticing have to be rebuilt in the client. That's where DBOS and River differ from each other, and from Celery.
Correction, 16 Sep 2026. The first version measured DBOS at
fe1d839(21 Aug), before #454 batched the dequeue claim and rewrote dispatch. Max Demoulin from DBOS caught it; everything below is re-measured against5387dd3(15 Sep), and the notify, fence and cost-unit fixes are his too.
Why bother
If you already have Postgres, a queue in Postgres is a table and a library instead of another cluster to run.
You can enqueue inside your own transaction. The oldest bug in every broker-backed system is committing the order and losing the "send confirmation" message somewhere between the commit and the publish, or publishing and then rolling back. Insert the job in the same transaction as the order and that whole class of bug is gone.
And the history is free, since a broker deletes on ack and a table keeps rows until you delete them.
The catch is that the broker was doing three jobs you never saw.
What the broker was doing
The worker sets prefetch_count on its channel (in Celery, concurrency × prefetch_multiplier, multiplier defaulting to 4) and the broker pushes until that many messages are unacked, then waits. Round-robin over whichever consumers still have credit.
So the broker counts what each connection holds, and the bound is enforced on its side, not the client's. It pushes, so nobody polls. And when a worker dies its connection drops, the channel closes, and everything unacked on it goes back on the queue without anyone configuring anything.
A table has a state column. That's it. No connection to count against, nothing to push down, no channel to close.
What a table does instead
available -> running -> completed
-> retryable
-> discarded
DBOS calls it workflow_status, River calls it river_job, and every broker operation becomes a row transition that the client itself performs. Deliver is a poll: SELECT ... FOR UPDATE SKIP LOCKED LIMIT n. Claim, ack and requeue are UPDATEs on the state column. Requeueing a dead worker's jobs is nothing at all unless somebody builds it.
SKIP LOCKED is what keeps concurrent pollers off each other's rows. River does the claim in one CTE, select-with-skip-locked then update-returning, one round trip per batch. DBOS does it in two, a SELECT ... FOR UPDATE SKIP LOCKED and then one UPDATE ... WHERE workflow_uuid = ANY($ids) for the batch, inside the same transaction. Either way the claim is constant in batch size.
The bound
Both libraries compute the fetch limit client-side with the same arithmetic, concurrency minus currently running:
// DBOS
maxTasks = workerConcurrency - localRunningCount
// River, producer.go:958
func (p *producer) maxJobsToFetch() int {
return p.config.MaxWorkers - int(p.numJobsActive.Load())
}
Neither has Celery's multiplier. You fetch what you can start right now and no more. That turns out to be fine, for a reason that shows up under wakeup.
The difference is what happens when concurrency isn't set. River refuses to start (producerConfig.MaxWorkers is required, producer.go:147). DBOS treats unset as unbounded; maxTasks starts at -1 and the SELECT goes out with no LIMIT.
I ran the unbounded case. Two DBOS workers, 2000 enqueued workflows, nothing configured. Whichever polled first took all 2000 in a single pass, and the other polled thirty times over fifteen seconds and got nothing. Swap the seeds and the winner swaps. The claim itself is cheap now, five statements and 30 to 60 ms for the whole batch, so this isn't about the cost of the grab. It's that after the grab there's nothing left.
The DBOS maintainers have been on both sides of this one. Their Python SDK had max_tasks = 100 # To minimize contention with large queues until March 2026, when #616 removed it as "unnecessary and confusing" after scale testing. I'd agree a fixed count is a bad knob, and single-worker throughput really is fine without it. The second worker finding an empty table is what a single-worker test can't show you.
A bound doesn't distribute work, to be clear. Nobody allocates anything; each worker takes up to its limit, and the one that polls more often takes more. All the bound does is leave something in the table for the next poller. It also says nothing about the cluster. Twenty River clients at MaxWorkers 100 will happily run 2000 jobs, and River has no cluster-wide cap, whereas DBOS has WithGlobalConcurrency, enforced by counting PENDING rows inside the claim transaction.
The wakeup
A table can't push, so the client has to find out there's work somehow.
DBOS uses a timer. One goroutine per queue, 1 second by default, and after every pass it sleeps the full interval whether the batch came back empty or full:
// dbos/queue.go, tail of runQueue
select {
case <-ctx.Done():
return
case <-time.After(sleepDuration):
}
That caps sustained throughput at concurrency / (interval + work) once there's always work waiting. At concurrency 8 on a 100 ms poll I measured 74 to 76 jobs a second against the 80 you'd get from concurrency over interval, and the model held to within 1.1%. The gap is small now because the in-pass work is small, about 7 ms, since the claim was batched; at fe1d839 it was 16%. For long jobs none of this matters because concurrency saturates first. For short ones, under a backlog, the poll interval sets the ceiling. The qualifier is Max Demoulin's: that formula describes a closed system, one with work always waiting. Below saturation, throughput is your arrival rate and the interval shows up as latency instead. He has a short post on exactly this, why doubling worker speed did nothing for throughput.
River is more elaborate here, and it's the part of the codebase I'd point people at. The inserting client sends a NOTIFY from inside its own transaction, rate-limited to one per queue per 100 ms, and producers on that queue wake on commit. When a fetch comes back at the limit, the producer sets a flag so that the next job completion triggers another fetch, which means refill under a backlog is paced by work finishing and needs no cap. Under both of those there's still a 1-second poll, as the fallback for lost notifications and for drivers without LISTEN; under a real backlog it almost never finds anything.
The completion trigger is also why the missing multiplier doesn't hurt. Celery's prefetch exists to hide broker latency so a freed slot doesn't wait a round trip for its next message. River makes that round trip one Postgres query at the moment the slot frees.
DBOS hit the same wall from a different side. Migrations 43 and 44 moved pg_notify out of the write transaction on two of its channels, because a notifying commit takes a global lock on the async notification queue and serializes writers; notifies now batch in-process and flush as one statement. Peter Kraft wrote it up in Postgres LISTEN/NOTIFY Can Actually Scale: 2,900 writes a second with notify in the transaction, 60,000 with it batched. River's answer to the same lock is the rate limiter. Queue enqueue in DBOS still isn't on notify at all, which is why the wakeup is a timer.
Liveness
A running row stays running until something changes it, and this is where the two libraries diverge most.
DBOS recovers on restart. A starting process flips its own dead executor ID's PENDING rows back to ENQUEUED and re-dispatches them, and completed steps replay from a ledger. That's the right shape for a process that crashed and came back. It watches nobody else, though. The only triggers are Launch for the process's own ID, an admin endpoint, and DBOS's hosted control plane, so a pod that dies and doesn't return has stranded work until something calls recovery by hand.
River runs a rescuer on whichever client currently holds a leadership row in river_leader. Every 30 seconds it finds running rows whose attempted_at is older than a horizon (1 hour by default, plus the job's own timeout) and moves them to retryable or discards them, appending an error saying why. There's no expiry column and no lease extension; the lease is the claim timestamp plus the horizon, same trade as RabbitMQ's consumer_timeout, and the horizon has to exceed your longest legitimate job.
Once a rescuer exists, two actors can change a job's state, and something has to decide who wins. River fences completion on state = 'running', so a late completion from a job that's already been rescued is a no-op and the job runs again. At-least-once, with idempotency left to you. DBOS has the same fence, an execution can only finish a row still in PENDING, and on top of it each step's output goes into a ledger keyed on (workflow_uuid, function_id), and a conflict on that key is classified. Identical bytes mean our own retried write; a different function name is a determinism error; anything else means another execution owns this workflow, and this one parks. So a double run in DBOS gets caught at the first step that collides.
What you get, and what it costs
Enqueue-in-transaction is real. River's InsertTx takes your pgx.Tx, the job row commits with your business rows, and the NOTIFY fires on that commit. History is real too; queue depth is a COUNT and failure rate over the last hour is a GROUP BY.
The bigger thing is that DBOS and River are different product categories that happen to share a storage choice. River is a job queue. It runs a function, retries it on failure, done. DBOS is durable execution, Temporal's category. A workflow is a sequence of steps; each step's output is checkpointed to operation_outputs as it completes, and if the process dies mid-workflow the next executor replays from the ledger, skipping steps that already ran and handing their recorded outputs back to the code as if they'd just returned. Step three, "charge the card," runs once even if the process crashes twice around it. Temporal gives you that with a separate server cluster that owns the event history. DBOS gives it to you as a library, with the history in the Postgres you already run.
You pay for it per step, and the right unit is writes, not round trips. Max's point, and he's right: reads are cheap and writes are WAL. A zero-step DBOS workflow is two writes (the claim, the finish); each step adds one. Round trips are four for the zero-step case, almost all of them the finish transaction. River's success path is close to one write, because completion is batched by a goroutine that flushes every 50 ms and the worker never waits on it. For many short jobs that's the throughput gap. For a workflow with a step that must not run twice, the extra write per step is the product.
Retention is the part of "history is free" that isn't. An unpruned table grows forever and every claim has to find the few available rows among millions of completed ones. DBOS keeps the hot path fast with a partial index on active states and, as of its September releases, prunes by completed_at with payloads split into their own tables so the status row stays narrow. River deletes finished jobs after a retention window and reindexes on a schedule.
Choosing
River if a job is a function that runs once and retries on failure. DBOS if a job is a sequence of steps where each must run exactly once across crashes, and you're willing to pay a round trip per step for Temporal's guarantee without Temporal's cluster.
Either way, three things to check before trusting it under load: whether the fetch is bounded when nothing is configured, what wakes an idle worker, and what happens to a running row when its process dies.
Eight axes
| Celery (RabbitMQ) | DBOS | River | |
|---|---|---|---|
| Category | Job queue | Durable execution | Job queue |
| Push or pull | Push | Pull, 1 s timer | Pull, notify + completion trigger, timer fallback |
| Fetch bound | prefetch_count, broker-enforced |
concurrency - running, optional; unbounded when unset |
MaxWorkers - active, mandatory |
| Hold N, run M < N | Yes, multiplier default 4 | No | No |
| Claim | Broker-side | SELECT FOR UPDATE SKIP LOCKED + one batched UPDATE ... ANY($ids) |
One CTE, SKIP LOCKED LIMIT n + UPDATE RETURNING |
| Dead worker's work | Requeued on connection close | Stranded until recovery is invoked | Leader-run rescuer, attempted_at + 1 h |
| Durability unit | Message | Step, ledger with replay | Job, no replay |
| Cluster-wide cap | No | WithGlobalConcurrency |
No |
Hatchet is the third Postgres queue I have open and isn't here because I haven't read its dequeue path yet.
DBOS at 5387dd3 (re-measured 16 Sep; first version was fe1d839), River at a4cf56f. Benchmark harness and raw results in a separate module against the DBOS repo; tracer cross-checked against pg_stat_statements to a net gap of 0.0%.
