PostgreSQL backend
BullMQ ships with an optional PostgreSQL backend that runs the exact same Queue / Worker / QueueEvents / FlowProducer API on PostgreSQL instead of Redis. It is built on the datastore-agnostic backend abstraction: you select it by passing the createPostgresBackend factory.
INFO
The Redis backend remains the default and the most battle-tested option. Choose PostgreSQL when you would rather not operate a separate Redis instance, or when you want your jobs to live alongside (and in the same database as) your relational data.
Requirements
- PostgreSQL 13 or newer (14+ recommended). The backend checks the server version on first connect and throws
UnsupportedPostgresVersionErrorwhen the server is older than the minimum. You can bypass the check withskipVersionCheck: true(at your own risk). - The
pg(node-postgres) package installed in your project. It is an optional peer dependency — Redis-only users never need it. It is loaded lazily, only when you actually use the PostgreSQL backend:
npm install pgGetting started
Pass createPostgresBackend as the last constructor argument, with a connection that node-postgres understands (a connection string, a pool config, or a pg.Pool):
import {
Queue,
Worker,
PostgresQueueBackend,
createPostgresBackend,
} from 'bullmq';
const opts = {
connection: 'postgres://user:password@localhost:5432/mydb',
};
const queue = new Queue<any, any, string, PostgresQueueBackend>(
'my-queue',
opts,
createPostgresBackend,
);
const worker = new Worker(
'my-queue',
async job => {
// ... process the job exactly as you would with the Redis backend
},
opts,
createPostgresBackend,
);The argument positions mirror the Redis usage:
| Class | Constructor |
|---|---|
Queue | new Queue(name, opts, createPostgresBackend) |
Worker | new Worker(name, processor, opts, createPostgresBackend) |
QueueEvents | new QueueEvents(name, opts, createPostgresBackend) |
FlowProducer | new FlowProducer(opts, createPostgresBackend) |
Using it everywhere by default
If your whole application uses PostgreSQL, register it once as the process-wide default backend and drop the per-instance argument:
import { setDefaultBackendFactory, createPostgresBackend, Queue } from 'bullmq';
setDefaultBackendFactory(createPostgresBackend);
// Every class now defaults to the PostgreSQL backend:
const queue = new Queue('my-queue', {
connection: 'postgres://user:password@localhost:5432/mydb',
});Connection options
The connection option is forwarded to node-postgres and may be any of:
// 1) A connection string
{ connection: 'postgres://user:pass@localhost:5432/mydb' }
// 2) A node-postgres pool config (any pg.PoolConfig field), optionally carrying
// the BullMQ-specific `schema` / `skipVersionCheck`
{
connection: {
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'mydb',
max: 10, // pool size
schema: 'bullmq', // see "Schema" below
},
}
// 3) An already-constructed pg.Pool. BullMQ uses it as-is and does NOT close it
// on close() — you own its lifecycle.
import { Pool } from 'pg';
const pool = new Pool({ connectionString: 'postgres://…' });
{ connection: pool }When BullMQ builds the pool for you (from a string or config), it owns and closes that pool on close(). When you pass your own pg.Pool, its lifecycle is yours (you should close it yourself when your process shuts down).
Schema
All BullMQ objects live in a single PostgreSQL schema (namespace), which defaults to bullmq. This keeps BullMQ's tables cleanly separated from the rest of your database.
In general, prefer the default schema and use a dedicated database for BullMQ when possible. Custom schemas are supported, but add operational complexity. Set a custom schema via the config-object form:
const opts = {
connection: {
connectionString: 'postgres://user:pass@localhost:5432/mydb',
schema: 'jobs',
},
};WARNING
The schema option is only honored when BullMQ builds the pool for you (a config object, or a connection string wrapped as { connectionString, schema }). When you pass an already-constructed pg.Pool, BullMQ cannot change the search_path of connections it did not create. Configure your pool's search_path yourself so it includes BullMQ's schema (bullmq by default, or your custom schema), otherwise unqualified BullMQ queries will not resolve to BullMQ's tables.
Migrations
On the first waitUntilReady() of a connection, BullMQ automatically creates its schema and applies any pending migrations. This is:
- Idempotent — running it again is a no-op.
- Concurrency-safe — a transaction-scoped advisory lock (namespaced per schema) serializes concurrent starters, so many workers or instances booting at once still migrate exactly once.
No manual migration step is required. If a database was migrated by a newer BullMQ release and an older instance then connects, it refuses to operate (SchemaVersionMismatchError) rather than risk corruption — upgrade BullMQ to match.
DANGER
Schema downgrades are not supported.
How it works
The PostgreSQL backend maps BullMQ's primitives onto PostgreSQL features:
- Jobs and their state live in regular tables inside the schema. State transitions run as SQL functions within transactions, giving the same atomicity guarantees the Lua scripts provide on Redis.
- The blocking wait-for-job primitive (the Redis
BZPOPMIN) is implemented withLISTEN/NOTIFY: producersNOTIFYa channel and each worker blocks on a dedicatedLISTENconnection until it is woken (or a timeout elapses). - Each backend uses a
pg.Poolfor regular, short-lived queries plus one dedicated, long-livedLISTENconnection.
INFO
The pg.Pool size (max) bounds how many concurrent queries a single backend can run; the dedicated LISTEN connection is separate and does not consume a pool slot. Size your server's max_connections for the number of concurrent workers, queues and queue-events you run.
Performance
What to expect relative to the Redis backend:
- Per-operation latency is comparable when the database is on a low-latency network (same host / same datacenter). Individual operations such as adding a job or reading counts take on the order of a millisecond, dominated by the network round-trip rather than by the database itself.
- Job-processing throughput is usually lower than Redis (often around ~1.5–2× fewer jobs/s on similar setups), but this is workload-dependent. Redis is optimized for in-memory data structures and can also be configured with persistence trade-offs (RDB/AOF settings, or even no local persistence). PostgreSQL performs durable transactional writes by default (WAL + MVCC +
fsync). In practice, you are often trading some throughput for stronger relational guarantees and a single-database operational model. - Bulk enqueues (
addBulk, flows) come much closer to Redis, because many jobs are inserted per transaction.
The actual numbers depend heavily on your hardware, your PostgreSQL configuration and where the database lives relative to your workers, so benchmark with your own workload before committing to capacity figures.
Indicative numbers
The figures below are a rough illustration measured on an Apple Silicon laptop (M-series, ~12 cores) with PostgreSQL running locally, trivial no-op jobs and default (durable) settings. They exist only to give an order of magnitude — real workloads with meaningful payloads and processors will differ, and the Redis column is the same machine for context, not a formal benchmark.
| Operation | PostgreSQL | Redis (same machine) |
|---|---|---|
add(), one at a time | ~7,000 jobs/s | ~7,500 jobs/s |
add(), concurrently (Promise.all) | ~15,000 jobs/s | ~38,000 jobs/s |
addBulk(), batched + concurrent | ~45,000 jobs/s | ~52,000 jobs/s |
| Processing, 1 worker (concurrency 1) | ~2,300 jobs/s | ~6,000 jobs/s |
| Processing, concurrency 8–32 | ~11,000 jobs/s | ~18,000 jobs/s |
A few takeaways:
- Sequential
add()and batchedaddBulk()are close to Redis — the durable write is amortized (per round-trip, or across the batch). - Concurrent throughput — many parallel adds, or high-concurrency processing — is where Redis's in-memory model pulls ahead, leaving PostgreSQL in the ~1.5–2× range for processing.
Tuning tips
- Keep the database close. The round-trip latency between your workers and PostgreSQL is often the biggest factor — co-locate them (same host / AZ) where possible.
- Size the pool and
max_connections. Give each backend enough pool connections (max) for its concurrency, and make sure the server'smax_connectionscovers all your workers, queues and queue-events plus their dedicatedLISTENconnections. - Consider
synchronous_commit. If your durability requirements allow it, running PostgreSQL withsynchronous_commit = off(orlocal) removes the per-commitfsyncwait and can improve processing throughput substantially, at the cost of possibly losing the most recent commits on a crash. This is a database-level policy decision — weigh it against your durability needs. - Let retention do its work. Use
removeOnComplete/removeOnFailso finished jobs don't accumulate; large tables mean more index maintenance and vacuum work.
Feature parity and limitations
The PostgreSQL backend implements the full datastore-agnostic API — queues, workers, flows, job schedulers, rate limiting, prioritization, delayed jobs, deduplication, metrics and events — so your application code is identical across backends.
A few things are inherently Redis-specific and therefore do not apply to PostgreSQL:
getBackend()always returns the currently configured backend. Redis-only escape hatches (raw Redisclient,redisVersion, and similar) exist only when the backend is Redis.- Redis deployment concerns (Redis Cluster,
maxmemory-policy, etc.) don't apply; the relevant knob on PostgreSQL ismax_connections.