pg-boss Tutorial: Postgres Job Queue in Node 24 (2026)

May 15, 2026

pg-boss Tutorial: Postgres Job Queue in Node 24 (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 LOCKED to 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() and work()
  • 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/dashboard package for monitoring
  • How to handle graceful shutdown on SIGTERM so 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 psql and connection strings
  • A free TCP port on localhost:5432 for 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 deadLetter option.9 When retryLimit is 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: true enables jittered exponential backoff. If you set retryBackoff without setting retryDelay, 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.
  • expireInSeconds is 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, and teamRefill were removed in v10. Tutorials older than 2024 still cite them; they no longer exist.11 Backpressure is controlled by combining batchSize with how many boss.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 with batchSize: 1 you 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.schedule table, not in your code, so restarting the process does not double-fire the cron. If you ever rename a queue, delete the old row from pgboss.schedule explicitly — 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 to postgres://localhost/pgboss.
  • PGBOSS_SCHEMA — the schema pg-boss was migrated into. Defaults to pgboss.
  • PORT — HTTP port to listen on. Defaults to 3000.
  • 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 retryfailed → 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 called boss.send() before boss.createQueue(). Auto-creation was removed in pg-boss 11.8 Always call ensureQueues() after boss.start().
  • Handler runs once but the variable is undefined — You forgot to destructure the job array. Since v10, boss.work() handlers always receive Job<T>[], not Job<T>.11 Use async ([job]) => { ... } for single-job handlers.
  • Cron jobs never fire — Confirm schedule: true (the default) is set on the PgBoss constructor and that supervise: true (also the default) has not been disabled. The Timekeeper component runs internal jobs on the __pgboss__send-it queue; 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 \dn in psql that the pgboss schema 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, lower expireInSeconds and 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

Footnotes

  1. 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

  2. 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

  3. 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

  4. @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

  5. PostgreSQL 18 release announcement (Sept 25, 2025) plus current postgres:18-alpine tag on Docker Hub (Postgres 18.3). https://www.postgresql.org/about/news/postgresql-18-released-3142/ and https://hub.docker.com/_/postgres 2

  6. Node.js release schedule — Node 24 entered Active LTS in October 2025; EOL April 30, 2028. https://endoflife.date/nodejs 2

  7. pg-boss Configuration Reference — default monitorIntervalSeconds=60, maintenanceIntervalSeconds=86400, superviseIntervalSeconds=60, warningQueueSize, warningSlowQuerySeconds. https://deepwiki.com/timgit/pg-boss/15.5-configuration-reference 2

  8. 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

  9. pg-boss v10 release notes — deadLetter option added to send(), insert(), and createQueue(). https://github.com/timgit/pg-boss/releases/tag/10.0.0

  10. pg-boss retry configuration — retryBackoff: true enables jittered exponential backoff and defaults retryDelay to 1 if unset. https://deepwiki.com/timgit/pg-boss/11.1-retry-configuration

  11. pg-boss v10 release notes — boss.work() handler standardized to always receive Job[]; teamSize, teamConcurrency, and teamRefill removed. https://github.com/timgit/pg-boss/releases/tag/10.0.0 2 3

  12. pg-boss Cron-based Scheduling docs — Timekeeper evaluates 5-field cron expressions; tz option 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

  13. 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

  14. pg-boss API documentation portal. https://timgit.github.io/pg-boss/


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.