pg-boss Tutorial: Postgres Job Queue in Node 24 (2026)
May 15, 2026
To build a production-grade job queue in Postgres with Node.js, install pg-boss@12.18.2 against a Postgres 18 database, call boss.createQueue() for each queue you need with retryLimit, retryBackoff, and deadLetter set, then register typed handlers with boss.work(queue, ([job]) => ...). pg-boss uses Postgres SELECT ... FOR UPDATE SKIP LOCKED for atomic dequeue, so workers across multiple Node processes pick up jobs without contention — no Redis required.12
The "should I add Redis just for jobs?" question shows up in every Node.js backend over a few thousand RPS. pg-boss answers it by piggybacking on the Postgres you already operate: same backups, same monitoring, same DBAs.1 It is not a drop-in BullMQ replacement — the SKIP LOCKED ceiling sits well below what Redis-backed queues can sustain — but for the 90% of teams running tens or hundreds of jobs per second with rich workflow semantics (retries with jitter, cron, dead-letter queues, pub/sub), it deletes an entire moving part from production.
This tutorial walks through a complete TypeScript setup against the current pg-boss@12.18.2 (npm latest on 2026-05-11)3 and Node 24 LTS. Every dependency version is pinned. Every code block is runnable as written.
TL;DR
You will scaffold a TypeScript project against Node 24 LTS, point it at a Postgres 18 container, create two typed queues (send-email and send-email-dlq) with exponential-backoff retries, drop failed jobs into the dead-letter queue, schedule a daily cron job in Europe/Dublin time, install the official @pg-boss/dashboard@1.1.3 for monitoring, and shut down cleanly on SIGTERM. End-to-end runtime: about 25 minutes. Verified May 15, 2026 against pg-boss 12.18.2, dashboard 1.1.3, and Postgres 18.3.345
What you'll learn
- How pg-boss uses
SKIP LOCKEDto deliver exactly-once jobs without Redis - How to set up pg-boss 12 against Postgres 18 in Docker with TypeScript
- How to define type-safe queue payloads with generics on
send()andwork() - How to configure retries with jittered exponential backoff and a dead-letter queue
- How to schedule cron jobs with a specific IANA timezone
- How to install the
@pg-boss/dashboardpackage for monitoring - How to handle graceful shutdown on
SIGTERMso in-flight jobs are not lost - When to choose pg-boss vs Redis-backed alternatives like BullMQ
Prerequisites
- Node.js 24.15.0 or newer (pg-boss 12 requires
node >=22.12.0)3 - Docker Engine 20.10+ with Compose v2 (Docker Desktop 3.4+ ships it automatically)
- PostgreSQL knowledge at the level of
psqland connection strings - A free TCP port on
localhost:5432for the Postgres container
Pin Node 24 specifically — it is the Active LTS line until October 2026, with security support through April 2028.6 Node 22 still works (pg-boss only needs
>=22.12.0) but ships in Maintenance LTS now.6
Step 1: Scaffold the project
Create the directory and pin every dependency. None of the versions below are "latest" — they are the exact tags published to npm as of the day this tutorial was written (verified with npm view).
mkdir pg-boss-tutorial && cd pg-boss-tutorial
npm init -y
npm pkg set type=module
npm install pg-boss@12.18.2 pg@8.20.0 dotenv@17.4.2
npm install -D typescript@6.0.3 tsx@4.22.0 \
@types/node@22.19.17 @types/pg@8.20.0
The type=module line is non-negotiable: pg-boss 12 ships as a pure ESM package ("type": "module" in its own package.json).3 Loading it from a CommonJS project requires Node's require(esm) mechanism, which is why pg-boss bumped its engines requirement to >=22.12.0 (when require(esm) became unflagged).3
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}
Create docker-compose.yaml for Postgres 18:
services:
postgres:
image: postgres:18-alpine
container_name: pgboss-db
environment:
POSTGRES_USER: pgboss
POSTGRES_PASSWORD: pgboss
POSTGRES_DB: pgboss
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pgboss -d pgboss"]
interval: 2s
timeout: 3s
retries: 10
The postgres:18-alpine tag currently resolves to Postgres 18.3 on Alpine 3 (verified on Docker Hub on May 15, 2026).5 pg-boss officially supports any Postgres 13 or newer, so 18 is well within range.1
Bring it up and confirm the database is healthy:
docker compose up -d
docker compose ps
Finally, create .env:
DATABASE_URL=postgresql://pgboss:pgboss@localhost:5432/pgboss
Step 2: Initialize pg-boss with typed payloads
Create src/boss.ts. This is the single place that constructs the PgBoss instance for the entire app:
import 'dotenv/config';
import PgBoss from 'pg-boss';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is required');
export const boss = new PgBoss({
connectionString,
schema: 'pgboss',
application_name: 'pg-boss-tutorial',
// Sane production defaults — see pg-boss configuration reference
monitorIntervalSeconds: 60,
maintenanceIntervalSeconds: 86_400,
superviseIntervalSeconds: 60,
warningQueueSize: 1_000,
warningSlowQuerySeconds: 5,
});
// MANDATORY in production: pg-boss is an EventEmitter and will crash the
// Node process with an unhandled 'error' event if you forget this.
boss.on('error', (err) => {
console.error('[pg-boss] internal error', err);
});
The defaults here are intentional. The monitorIntervalSeconds=60 and maintenanceIntervalSeconds=86400 values match the documented defaults, and warningQueueSize=1000 makes pg-boss surface a large-queue warning when any queue's backlog exceeds 1,000 jobs — a much earlier signal of a stuck worker than waiting for the dashboard to turn red.7
Calling boss.start() will, on first run, create the pgboss schema, the partitioned pgboss.job table, and every supporting table.1 By default migrate: true, so subsequent upgrades from one minor pg-boss version to the next will run their own migrations transparently. In production you may want migrate: false and apply migrations as a deliberate deploy step; for this tutorial we leave the default on.
Step 3: Define queues with retries and a dead-letter queue
Since pg-boss 11.0, queues must exist before you call send() or insert() on them — the auto-create behavior was removed when the partitioning model was introduced.8 Define your queues centrally so the contract lives in one file.
Create src/queues.ts:
import { boss } from './boss.js';
// Typed payload contracts — exported so producers and workers share them
export type SendEmailJob = {
to: string;
subject: string;
bodyHtml: string;
};
export type DailyDigestJob = {
date: string; // YYYY-MM-DD
};
export const QUEUES = {
sendEmail: 'send-email',
sendEmailDlq: 'send-email-dlq',
dailyDigest: 'daily-digest',
} as const;
export async function ensureQueues(): Promise<void> {
// Dead-letter queue MUST be created before the queue that references it
await boss.createQueue(QUEUES.sendEmailDlq, { policy: 'standard' });
await boss.createQueue(QUEUES.sendEmail, {
policy: 'standard',
retryLimit: 5, // Try up to 5 times before DLQ
retryDelay: 30, // 30 second base delay
retryBackoff: true, // Jittered exponential backoff
expireInSeconds: 600, // Mark stuck jobs failed after 10 minutes
retentionDays: 14, // Keep completed/failed jobs for 14 days
deadLetter: QUEUES.sendEmailDlq,
});
await boss.createQueue(QUEUES.dailyDigest, {
policy: 'standard',
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
});
}
Three details here are worth pausing on:
- Dead-letter queue is just another queue. pg-boss does not create a hidden DLQ table; you create a regular queue and point the primary queue at it by name with the
deadLetteroption.9 WhenretryLimitis exhausted, the failed job is moved into the DLQ as a new job whose data is the original payload. That means you write the DLQ handler exactly like any other handler. retryBackoff: trueenables jittered exponential backoff. If you setretryBackoffwithout settingretryDelay, pg-boss defaults the base delay to 1 second.10 The exact backoff curve is "exponential with jitter," so a permanently-failing job lands in the DLQ on a roughly minute-to-hour scale rather than seconds — long enough that transient downstream outages clear themselves, short enough that you'll see the DLQ entry inside a working shift.expireInSecondsis your runaway-worker safety net. If a job is fetched but the worker process dies before the handler resolves, the supervision loop will mark the job failed after this timeout and (if retries remain) requeue it.7
Step 4: Send and process jobs with type-safe handlers
Create src/workers.ts for the consumer side:
import type { Job } from 'pg-boss';
import { boss } from './boss.js';
import {
QUEUES,
type SendEmailJob,
type DailyDigestJob,
} from './queues.js';
// Stand-in for your real email provider
async function deliverEmail(payload: SendEmailJob): Promise<void> {
console.log(`[email] -> ${payload.to}: ${payload.subject}`);
if (payload.to.endsWith('@bounce.example')) {
throw new Error('mailbox does not exist');
}
}
export async function registerWorkers(): Promise<void> {
// Single-job handler (batchSize defaults to 1). The handler ALWAYS
// receives an array since pg-boss v10, so destructure it.
await boss.work<SendEmailJob>(
QUEUES.sendEmail,
{ batchSize: 1, pollingIntervalSeconds: 2 },
async ([job]: Job<SendEmailJob>[]) => {
await deliverEmail(job.data);
},
);
// Dead-letter handler: log, page on-call, or move to a Slack channel
await boss.work<SendEmailJob>(
QUEUES.sendEmailDlq,
async ([job]: Job<SendEmailJob>[]) => {
console.error(
`[dlq] giving up on email to ${job.data.to} (job ${job.id})`,
);
// In production: emit metric, page on-call, or stash in a "needs human"
// table. The job stays here until you explicitly resolve it.
},
);
// Batched handler — process 50 digest jobs per fetch
await boss.work<DailyDigestJob>(
QUEUES.dailyDigest,
{ batchSize: 50, pollingIntervalSeconds: 5 },
async (jobs: Job<DailyDigestJob>[]) => {
console.log(`[digest] processing batch of ${jobs.length}`);
for (const job of jobs) {
// Real work goes here
await new Promise((r) => setTimeout(r, 10));
}
},
);
}
The Job<T> generic flows the payload type through to your handler, so job.data.to is typed as string. If a producer ever tries to send() with a malformed object, TypeScript will reject it at compile time. The same generic also works on boss.send<SendEmailJob>(...).
Two API points often missed:
teamSize,teamConcurrency, andteamRefillwere removed in v10. Tutorials older than 2024 still cite them; they no longer exist.11 Backpressure is controlled by combiningbatchSizewith how manyboss.work()calls you register and how many processes you run.- The handler always gets an array. Single-job destructuring (
([job]) => ...) is the v10+ migration pattern from the old(job) => ...signature.11 If you forget to destructure withbatchSize: 1you will silently work on the array as a whole.
Now wire it together in src/index.ts:
import { boss } from './boss.js';
import { ensureQueues, QUEUES } from './queues.js';
import { registerWorkers } from './workers.js';
async function main(): Promise<void> {
await boss.start();
console.log('[pg-boss] started');
await ensureQueues();
await registerWorkers();
// Producer: queue a few jobs
const id1 = await boss.send(QUEUES.sendEmail, {
to: 'alice@example.com',
subject: 'Hello from pg-boss',
bodyHtml: '<p>It works.</p>',
});
console.log(`[send] queued job ${id1}`);
const id2 = await boss.send(QUEUES.sendEmail, {
to: 'broken@bounce.example', // will fail and eventually hit DLQ
subject: 'This one is going to fail',
bodyHtml: '<p>Bounce me.</p>',
});
console.log(`[send] queued job ${id2}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Run it:
npx tsx src/index.ts
You should see one job succeed immediately, the bouncing job retry with growing delays, and eventually a [dlq] line after retries are exhausted. Leave the process running for the next steps.
Step 5: Schedule a cron job with a timezone
Use boss.schedule(name, cron, data, options) to register a recurring job. The name parameter is the queue name the job lands in — not a separate "schedule name." pg-boss runs a Timekeeper that evaluates cron expressions every 30 seconds and creates jobs in the matching queue.12
Add this inside main(), after registerWorkers():
// 09:00 Europe/Dublin every day — uses IANA timezone via the tz option
await boss.schedule(
QUEUES.dailyDigest,
'0 9 * * *',
{ date: new Date().toISOString().slice(0, 10) },
{ tz: 'Europe/Dublin' },
);
console.log('[schedule] daily-digest registered for 09:00 Europe/Dublin');
A few cron-specific facts:
- pg-boss uses a 5-field cron (
minute hour day month dayOfWeek).12 Six-field cron (with seconds) is not supported. - Across multiple Node processes, only one acquires the cron lock and fires the schedule at any given tick — pg-boss uses a 60-second singleton plus a distributed lock to guarantee this.12
- The schedule entry lives in the
pgboss.scheduletable, not in your code, so restarting the process does not double-fire the cron. If you ever rename a queue, delete the old row frompgboss.scheduleexplicitly — pg-boss will otherwise keep emitting jobs onto the abandoned queue name with no handler.
Step 6: Install the dashboard
The official monitoring UI ships as a separate package, @pg-boss/dashboard@1.1.3.4 It is distributed as a standalone CLI (the pg-boss-dashboard bin), not a library — there is no createServer() to import. Configuration is entirely via environment variables.
npm install @pg-boss/dashboard@1.1.3
Run it in a second terminal, picking a different port than your app:
DATABASE_URL=postgresql://pgboss:pgboss@localhost:5432/pgboss \
PGBOSS_SCHEMA=pgboss \
PORT=3001 \
npx pg-boss-dashboard
The supported environment variables are:
DATABASE_URL— Postgres connection string. Defaults topostgres://localhost/pgboss.PGBOSS_SCHEMA— the schema pg-boss was migrated into. Defaults topgboss.PORT— HTTP port to listen on. Defaults to3000.PGBOSS_DASHBOARD_AUTH_USERNAME/PGBOSS_DASHBOARD_AUTH_PASSWORD— enable HTTP basic auth (strongly recommended outside localhost).
Open http://localhost:3001. You will see your queues, recent job states, and per-job payload viewers. The dashboard also lets you cancel, retry, resume, or delete jobs directly from the UI — useful for triaging entries in the dead-letter queue without writing a one-off script.4
Step 7: Graceful shutdown on SIGTERM
In production, the orchestrator (Kubernetes, ECS, fly.io, systemd) sends SIGTERM and waits a grace period before forcing SIGKILL. If pg-boss is mid-job when SIGKILL lands, that job stays in active state until expireInSeconds elapses — exactly the kind of "stuck for ten minutes" failure a job queue is supposed to prevent.
Append to src/index.ts:
async function shutdown(signal: string): Promise<void> {
console.log(`[pg-boss] received ${signal}, draining…`);
try {
await boss.stop({
timeout: 25_000, // Match this to your platform's grace period
graceful: true, // Wait for in-flight handlers to resolve
destroy: false, // Let pg release connections cleanly
});
console.log('[pg-boss] stopped cleanly');
process.exit(0);
} catch (err) {
console.error('[pg-boss] forced shutdown', err);
process.exit(1);
}
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
The timeout value should be slightly less than your platform's terminationGracePeriodSeconds (Kubernetes defaults to 30). Setting it lower than the grace period guarantees pg-boss has time to finish before the orchestrator force-kills.
Verification
Open three terminals and run the smoke test. In terminal one:
docker compose up -d
npx tsx src/index.ts
In terminal two:
DATABASE_URL=postgresql://pgboss:pgboss@localhost:5432/pgboss \
PORT=3001 \
npx pg-boss-dashboard
In terminal three, peek directly at the partitioned job table:
docker exec -it pgboss-db \
psql -U pgboss -d pgboss -c \
"SELECT name, state, retry_count, created_on
FROM pgboss.job
ORDER BY created_on DESC LIMIT 10;"
Expected: at least one send-email job in completed, the bouncing job moving through retry → failed → finally a send-email-dlq job in created or completed. The dashboard's queue view should show the same.
Now press Ctrl-C in terminal one. You should see received SIGINT, draining… followed by stopped cleanly within a few seconds — and the active job (if any) should be completed in the database, not stuck in active.
Troubleshooting
Error: queue "send-email" does not exist— You calledboss.send()beforeboss.createQueue(). Auto-creation was removed in pg-boss 11.8 Always callensureQueues()afterboss.start().- Handler runs once but the variable is
undefined— You forgot to destructure the job array. Since v10,boss.work()handlers always receiveJob<T>[], notJob<T>.11 Useasync ([job]) => { ... }for single-job handlers. - Cron jobs never fire — Confirm
schedule: true(the default) is set on thePgBossconstructor and thatsupervise: true(also the default) has not been disabled. The Timekeeper component runs internal jobs on the__pgboss__send-itqueue; you can inspect that queue in the dashboard to confirm the schedule is firing.12 - Dashboard says "schema not found" — The connectionString's database must be the one pg-boss migrated. If you point the dashboard at a different database, it will not find any tables. Confirm with
\dninpsqlthat thepgbossschema exists. - Jobs stuck in
active— Almost always means a worker crashed mid-handler.expireInSeconds(set on the queue) tells pg-boss to mark such jobs failed after the timeout; retry logic then kicks in. If you see this often, lowerexpireInSecondsand audit handler code for unhandled promise rejections.
When to pick something else
pg-boss leans on Postgres SELECT ... FOR UPDATE SKIP LOCKED for atomic dequeue.2 That mechanism scales horizontally up to the point where commit-time contention on the job partitions dominates — meaningfully high for most apps, but lower than a Redis-backed queue. If you genuinely need tens of thousands of jobs per second, with millisecond-precision delays and complex parent/child dependencies, BullMQ on Redis remains a sharper tool — npm-trends shows it pulls roughly an order of magnitude more weekly downloads than pg-boss or Graphile Worker for that reason.13
For everyone else — the team running Postgres for everything else, the small SaaS that does not want to add a Redis SPOF, the side project that wants reliable cron and DLQ semantics out of the box — pg-boss is a fine default.
Next steps and further reading
- The companion post Production Postgres pooling with PgBouncer and Supavisor explains how to put pg-boss behind a pooler safely (hint: session mode, not transaction mode).
- For a complementary realtime channel pattern that is not a job queue, see Postgres LISTEN/NOTIFY for realtime presence.
- If you are still on Postgres 17 and worried about the upgrade, Zero-downtime Postgres 18 upgrade with pg_createsubscriber covers the migration.
- The official pg-boss docs ship a complete API reference14 and a Configuration page that lists every constructor option not used here (custom schema names, BYO database adapters,
maxpool sizing, password functions).
Footnotes
-
timgit/pg-boss README — "pg-boss relies on Postgres's SKIP LOCKED, a feature built specifically for message queues… exactly-once delivery and the safety of guaranteed atomic commits to asynchronous job processing." https://github.com/timgit/pg-boss ↩ ↩2 ↩3 ↩4
-
PostgreSQL documentation —
FOR UPDATE SKIP LOCKED(introduced in PostgreSQL 9.5). https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE ↩ ↩2 -
pg-boss npm registry — version 12.18.2,
"type": "module","engines": {"node": ">=22.12.0"}, dependencies pg ^8.20.0 and cron-parser ^5.5.0. https://www.npmjs.com/package/pg-boss ↩ ↩2 ↩3 ↩4 ↩5 -
@pg-boss/dashboard npm registry — version 1.1.3, requires Node 22.12+ and pg-boss 12.11+. https://www.npmjs.com/package/@pg-boss/dashboard ↩ ↩2 ↩3
-
PostgreSQL 18 release announcement (Sept 25, 2025) plus current
postgres:18-alpinetag on Docker Hub (Postgres 18.3). https://www.postgresql.org/about/news/postgresql-18-released-3142/ and https://hub.docker.com/_/postgres ↩ ↩2 -
Node.js release schedule — Node 24 entered Active LTS in October 2025; EOL April 30, 2028. https://endoflife.date/nodejs ↩ ↩2
-
pg-boss Configuration Reference — default
monitorIntervalSeconds=60,maintenanceIntervalSeconds=86400,superviseIntervalSeconds=60,warningQueueSize,warningSlowQuerySeconds. https://deepwiki.com/timgit/pg-boss/15.5-configuration-reference ↩ ↩2 -
pg-boss v11 release notes — queues must be created before send/insert; job table partitioning introduced. https://github.com/timgit/pg-boss/releases/tag/11.0.0 ↩ ↩2
-
pg-boss v10 release notes —
deadLetteroption added tosend(),insert(), andcreateQueue(). https://github.com/timgit/pg-boss/releases/tag/10.0.0 ↩ -
pg-boss retry configuration —
retryBackoff: trueenables jittered exponential backoff and defaultsretryDelayto 1 if unset. https://deepwiki.com/timgit/pg-boss/11.1-retry-configuration ↩ -
pg-boss v10 release notes —
boss.work()handler standardized to always receiveJob[];teamSize,teamConcurrency, andteamRefillremoved. https://github.com/timgit/pg-boss/releases/tag/10.0.0 ↩ ↩2 ↩3 -
pg-boss Cron-based Scheduling docs —
Timekeeperevaluates 5-field cron expressions;tzoption accepts IANA timezones; 60-second singleton plus distributed lock prevents duplicate cron firing. https://deepwiki.com/timgit/pg-boss/10.1-cron-based-scheduling ↩ ↩2 ↩3 ↩4 -
npm-trends comparison of BullMQ vs pg-boss vs graphile-worker (BullMQ leads weekly downloads by roughly an order of magnitude). https://npmtrends.com/bullmq-vs-graphile-worker-vs-pg-boss ↩
-
pg-boss API documentation portal. https://timgit.github.io/pg-boss/ ↩