better-push
Operate in production

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

src/push.ts
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:

worker.ts
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() returnsafter every provider repliesafter the rows are written
Push delivery statussent / failedqueued, then sent / failed
Retriesnone - one attemptexponential backoff, then dead-letter
Needs a processnoyes, or a cron caller
Runs on serverlessyesyes, via runPending()
A slow push serviceslows the requestslows the worker
Extra infrastructurenonenone - 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 ioredis
src/push.ts
import { 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()
Infrastructurethe Postgres you already havea Redis
Delayed jobsa run_at column, found by pollingnative, fired at the instant
Scheduled sendswithin a poll interval (~1s)at the due time
Digest flusheswithin a poll intervalat the due time
Throughputfine to a few hundred jobs/secondhigher, and cheaper per job
Serverless runPending()a single claim statement - the right shapeworks, but a blocking worker in a function that must return is not
Studio queue screenreads bp_jobreads Redis through the same inspector
Operational surfaceone datastoretwo

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:

worker.ts
const stop = push.startWorker();
process.on("SIGTERM", () => {
  void stop()
    .then(() => push.close())
    .then(() => process.exit(0));
});

Queue interfaces

Prop

Type

Prop

Type

Prop

Type

On this page