better-push
Operate in production

Deployment

Which pieces to run where, per platform.

better-push works with nothing but a database and a request. Everything else - the queue, the worker, the cache - is a fold you open when your deployment can support it.

The question that decides the shape is simple: can this platform run a process that outlives a request?

The two shapes

Serverless

Vercel, Netlify, Lambda, Cloudflare Workers. No long-running process, so no worker.

Long-running

Railway, Fly, Docker, Kubernetes, a bare Node server. A worker is one more process.

better-push doctor detects which you are on from vercel.json, fly.toml, railway.json, a Dockerfile, or the platform's own build environment - and tells you the same thing this page does, in your terminal.


Serverless

Vercel, Netlify, AWS Lambda, and anything else that only runs during a request.

Sends

Two options, and inline is a real one:

// 1. Inline. notify() sends before it returns.
export const push = betterPush({ /* no queue */ });

The request that called notify() waits for the push service. For a handful of devices that is tens of milliseconds; for a fan-out to hundreds it is not. There is nothing to run and nothing to operate.

// 2. Queued, drained by cron.
import { dbQueue } from "@better-push/core/queue/db-queue";

export const push = betterPush({
  queue: dbQueue(),
  runPendingSecret: process.env.BETTER_PUSH_RUN_PENDING_SECRET,
});

notify() writes its rows, enqueues one job per provider, and returns in milliseconds. Then a cron job drains the queue:

POST https://your-app.com/api/push/_internal/run-pending
Authorization: Bearer $BETTER_PUSH_RUN_PENDING_SECRET
vercel.json
{ "crons": [{ "path": "/api/cron/push", "schedule": "* * * * *" }] }

Your cron route calls push.runPending() or POSTs to the internal endpoint. Either way, delivery latency becomes your cron interval.

`bullmq` needs a worker

BullMQ's delayed jobs are driven by a running worker process. On serverless, use dbQueue(): runPending() reads bp_job directly and needs nothing long-lived.

Scheduled sends and digests

Both need something to fire at a due time. runPending() flushes overdue digest windows before it drains jobs, so one cron entry covers both - at cron resolution rather than to the second.

Cache and realtime

A serverless deployment is many short-lived instances by definition, so the in-process signal bus reaches nothing. Configure cache: redis(...) and the SSE feed works across them; leave it out and the client polls, which still works.

Rate limits are per-instance without a shared cache, which on serverless means effectively unlimited. If rate limiting matters to you, Redis is not optional.

Retention

A prune pass is worker work. Without one, set retention and call push.runPending() from a daily cron - maintenance jobs are scheduled through the same queue.


Long-running

Railway, Fly, Docker, Kubernetes, a bare Node server.

The worker

One extra process, sharing the config:

worker.ts
import { push } from "./push";

const stop = push.startWorker();
process.on("SIGTERM", () => {
  // Stops claiming, waits for in-flight jobs, then releases connections.
  void stop().then(() => push.close());
});

It claims jobs with FOR UPDATE SKIP LOCKED, so any number of workers is safe. It also maintains rollups, prunes to your retention windows, sweeps overdue digest windows, and heartbeats into bp_worker - which is what the studio's "is anything running?" answer reads.

Give it the database and provider credentials. It needs no PORT, no session secret, and no HTTP surface at all.

Which queue

dbQueue()bullmq()
NeedsThe Postgres you already haveRedis
Delayed jobsPolled, at the worker's intervalNative, fire at their instant
Scheduled sendsWithin one pollTo the second
Digest flushesWithin one pollTo the second

Start with dbQueue(). Move to bullmq when you already have Redis, or when poll-interval latency on a scheduled send stops being acceptable.

Cache and realtime

More than one instance means cache: redis(...), or SSE signals and rate-limit counters stop crossing processes. memory() is honest about this - it reports itself as single-instance, and doctor repeats that back to you.


Per platform

TargetComponentsWorkerCron
VerceldbQueue() optional, redis() for realtimeNot possiblevercel.json crons -> run-pending
NetlifySameNot possibleScheduled functions -> run-pending
RailwaydbQueue() or bullmq(), redis()A second service, same image, startWorker()Not needed
FlySameA second process in fly.tomlNot needed
Docker / K8sSameA second container or deploymentNot needed
Bare NodeSameA second processNot needed

What doctor will say

npx @better-push/cli doctor

On a healthy serverless deployment: queue.none or queue.configured, cache.none if you have not added Redis, realtime.not-shared, and retention.unset. All warnings, not errors - they name a consequence, not a fault.

The errors are the things that are actually broken: a missing table, a schema version mismatch, a malformed credential, or the missing partial index a prisma migrate database always has.

Run it in CI. It exits 1 on any error.

On this page