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{ "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:
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() | |
|---|---|---|
| Needs | The Postgres you already have | Redis |
| Delayed jobs | Polled, at the worker's interval | Native, fire at their instant |
| Scheduled sends | Within one poll | To the second |
| Digest flushes | Within one poll | To 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
| Target | Components | Worker | Cron |
|---|---|---|---|
| Vercel | dbQueue() optional, redis() for realtime | Not possible | vercel.json crons -> run-pending |
| Netlify | Same | Not possible | Scheduled functions -> run-pending |
| Railway | dbQueue() or bullmq(), redis() | A second service, same image, startWorker() | Not needed |
| Fly | Same | A second process in fly.toml | Not needed |
| Docker / K8s | Same | A second container or deployment | Not needed |
| Bare Node | Same | A second process | Not needed |
What doctor will say
npx @better-push/cli doctorOn 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.