Async delivery
Move sending off the request path with dbQueue() or bullmq() - the Postgres you already have, or the Redis you already have.
By default notify() sends inline: it writes its rows, then talks to every
push service before the call returns. That is the right default - it works on
serverless, needs no extra process, and is the reason better-push has no
mandatory infrastructure. But it puts a network round trip per provider on your
request, and one slow push service slows down whatever called you.
Adding a queue moves the sending into a worker. notify() writes its rows,
enqueues one job per provider, and returns in milliseconds.
Turning it on
import { betterPush } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { dbQueue } from "@better-push/core/queue/db-queue";
export const push = betterPush({
database: drizzleAdapter(db),
providers: [webPush({ vapid })],
session: getSession,
queue: dbQueue(),
});That is the whole configuration change. dbQueue() uses the Postgres you
already gave the adapter - no Redis, no broker, no second datastore. Work lives
in a bp_job table that ships with the schema whether or not you configure a
queue, so turning it on later is a config line, not a migration.
Then run a worker in its own process - see Running a Worker:
import { push } from "./src/push";
const stop = push.startWorker();
process.on("SIGTERM", () => void stop().then(() => process.exit(0)));Nothing sends without a worker
With a queue configured and no worker running, jobs accumulate in bp_job and
no push is delivered. That is not a failure mode to be surprised by - it is
the point, and it is recoverable: start a worker and the backlog drains. If
you cannot run a long-lived process, use
runPending() or the HTTP entrypoint.
What changes in NotifyResult
Push deliveries come back queued instead of sent or failed. Nothing else
moves: in-app deliveries are a row in your database, so they are already done by
the time notify() returns, and a preference-suppressed channel never had
anything to send.
const result = await push.notify({ userId, title: "Your order shipped" });
if (result.outcome !== "delivered") return; // scheduled, or collected into a digest
result.deliveries;
// [
// { id: "...", channel: "inApp", status: "delivered" },
// { id: "...", channel: "push", deviceId: "...", status: "queued" },
// ]The real outcome lands on bp_delivery when the worker runs. If your UI reports
"sent", read the delivery row rather than the notify() result.
Inline versus queued
| Inline (default) | queue: dbQueue() | |
|---|---|---|
notify() returns | after every provider replies | after the rows are written |
| Push delivery status | sent / failed | queued, then sent / failed |
| Retries | none - one attempt | exponential backoff, then dead-letter |
| Needs a process | no | yes, or a cron caller |
| Runs on serverless | yes | yes, via runPending() |
| A slow push service | slows the request | slows the worker |
| Extra infrastructure | none | none - the same Postgres |
What is still failed immediately
A device whose provider key is not in your providers array is failed at
notify() time with provider_not_configured, queue or no queue. Enqueueing
work that is guaranteed to dead-letter would only delay an answer you already
have.
The job
One job per provider batch, not one per device. A user with three browsers and
an iPhone produces two jobs: one web-push batch of three, one apns batch of
one. The job carries ids only:
{ "notificationId": "...", "provider": "web-push", "deliveryIds": ["...", "..."] }The worker reloads the notification and the delivery rows when it runs, so a job
can never be stale against the database it is about to act on. It also means a
retry naturally narrows itself: only deliveries still at queued are reloaded,
so a device that already succeeded is never sent to twice. See
Retries and Failures.
Inspecting the queue
-- work in flight, with when it is next due
SELECT id, attempts, run_at, last_error FROM bp_job WHERE status = 'pending';
-- jobs that gave up
SELECT id, attempts, last_error FROM bp_job WHERE status = 'dead';A successful job leaves no row: it is deleted, not marked done.
bp_delivery already records what happened to every delivery - status,
attempts, error, sent_at - so keeping completed jobs would be a second audit
trail that grows without bound.
Options
dbQueue({
maxAttempts: 5, // claims before a job dead-letters
visibilityTimeoutMs: 300_000, // when another worker may steal a stuck claim
batchSize: 10, // jobs claimed per poll
pollIntervalMs: 1_000, // sleep between polls that found nothing
backoff: (attempt) => 30_000 * 4 ** (attempt - 1), // ms until the next try
});Every field has a working default; the defaults above are the actual ones.
backoff defaults to 30s, 2m, 8m, 32m, capped at an hour, each with ±20%
jitter.
The BullMQ backend
bullmq() is the second backend. It uses the Redis you may already have for the
cache, and swaps a poll loop for BullMQ's own worker and native
delayed jobs.
npm install bullmq ioredisimport { bullmq } from "@better-push/core/queue/bullmq";
export const push = betterPush({
database: drizzleAdapter(db),
providers: [webPush({ vapid })],
session: getSession,
queue: bullmq({ connection: process.env.REDIS_URL! }),
});bullmq and ioredis are optional peer dependencies, imported lazily on
first use, so an app on dbQueue never needs them installed.
Options
bullmq({
connection: process.env.REDIS_URL!, // or { client }
queueName: "better-push", // two apps sharing a Redis need different names
prefix: "bp", // BullMQ key prefix
concurrency: 10, // jobs processed at once per worker
maxAttempts: 5, // the same default as dbQueue
completedTtlSeconds: 3_600, // how long a completed job id stays reserved
backoff: (attempt) => 30_000 * 4 ** (attempt - 1),
});backoff defaults to the same 30s / 2m / 8m / 32m schedule dbQueue uses, from
the same function - the retry behaviour is one definition, not two that drift.
completedTtlSeconds matters more than it looks. Maintenance work schedules
itself with a deterministic id (rollup-2026-07-30T15) and relies on the queue
rejecting a duplicate. A completed id is only unique while BullMQ still
remembers it, so freeing it inside its own period would let the next worker tick
re-add it. An hour is the shortest period any maintenance job uses.
It never shares the cache's connection
Do not hand it `redis()`'s client
BullMQ requires maxRetriesPerRequest: null and a dedicated blocking socket
per worker. The cache's client is deliberately the opposite - it is tuned to
fail fast on a request path so an unreachable Redis falls back to Postgres
instead of hanging. Pointing both at the same server is fine and expected;
handing them the same client is not.
Pass a URL and this backend dials its own connections and closes them in
push.close(). Pass { client } and it borrows yours for the producer side and
duplicates it per worker - whoever created a connection is responsible for
ending it.
Which one to use
dbQueue() | bullmq() | |
|---|---|---|
| Infrastructure | the Postgres you already have | a Redis |
| Delayed jobs | a run_at column, found by polling | native, fired at the instant |
| Scheduled sends | within a poll interval (~1s) | at the due time |
| Digest flushes | within a poll interval | at the due time |
| Throughput | fine to a few hundred jobs/second | higher, and cheaper per job |
Serverless runPending() | a single claim statement - the right shape | works, but a blocking worker in a function that must return is not |
| Studio queue screen | reads bp_job | reads Redis through the same inspector |
| Operational surface | one datastore | two |
Neither is a downgrade. dbQueue is the right answer for most apps and the only
one that adds no infrastructure; bullmq is the right answer when you already
run Redis, want exact timing, or are pushing enough volume that a Postgres table
is the wrong shape for a queue.
Swapping the backend
queue takes any QueueAdapter factory, and the worker loop belongs to the
backend rather than to core - dbQueue polls and claims rows, while bullmq
brings its own Worker class and native delayed jobs. Nothing above the
queue: line changes when the backend does, including the studio: the queue
screen reads through an optional QueueInspector that both backends implement,
so switching backends never costs observability.
The worker and the cache
If you configure a cache, give the worker process the same
cache: line the web process has. Two things depend on it:
- The worker is the process that disables a dead push token. With a shared
cache that invalidation reaches the web instances at once; with
memory()- or with no cache in the worker - their cached device lists keep a dead token for up to the 300 second TTL and keep targeting it. - The worker reads preferences and device lists on every send, which is exactly what the cache is there to accelerate.
The worker publishes no realtime signals, and that is deliberate: the feed
row is written by notify() in both inline and queued mode, so the signal is
published there. Push send outcomes do not change the feed, so there is nothing
for the worker to announce - which is why an instant in-app feed works
identically with and without a queue.
Give the worker a clean shutdown too:
const stop = push.startWorker();
process.on("SIGTERM", () => {
void stop()
.then(() => push.close())
.then(() => process.exit(0));
});Queue interfaces
Prop
Type
Prop
Type
Prop
Type