---
title: 'pg-boss Tutorial: Postgres Job Queue in Node 24 (2026)'
description: Production pg-boss tutorial. Queue jobs in Postgres from Node.js 24 with TypeScript, retries, dead-letter queues, cron, and graceful shutdown.
url: https://nerdleveltech.com/pg-boss-postgres-job-queue-node-typescript-production-tutorial
language: en
date: '2026-05-15'
category: backend
tags:
  - pg-boss
  - postgres
  - postgresql
  - node.js
  - typescript
  - job-queue
  - background-jobs
  - backend
  - dead-letter-queue
  - cron
markdown_url: https://nerdleveltech.com/pg-boss-postgres-job-queue-node-typescript-production-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/pg-boss-postgres-job-queue-node-typescript-production-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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.[^1][^2]

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.[^3][^4][^5]

## 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`).

```bash
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`:

```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:

```yaml
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:

```bash
docker compose up -d
docker compose ps
```

Finally, create `.env`:

```bash
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:

```typescript
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 10.0, **queues must exist before** you call `send()` or `insert()` on them — the auto-create behavior was removed when the partitioned job table model was introduced.[^8] Define your queues centrally so the contract lives in one file.

Create `src/queues.ts`:

```typescript
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
    deleteAfterSeconds: 1_209_600, // Keep completed/failed jobs for 14 days (seconds, not days — see note below)
    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]
- **Retention and retry fields are always in seconds — never days, hours, or minutes.** Older pg-boss releases had unit-suffixed variants like `retentionDays`, `retentionHours`, and `deleteAfterDays`; all of them were removed in pg-boss 11 in favor of a single `*Seconds` field per concept (`retentionSeconds` for undelivered jobs, `deleteAfterSeconds` for completed/failed ones).[^15] A field like `retentionDays` is not a valid `QueueOptions` property today — it will fail type-checking under this tutorial's `strict: true` tsconfig.

## Step 4: Send and process jobs with type-safe handlers

Create `src/workers.ts` for the consumer side:

```typescript
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`:

```typescript
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:

```bash
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()`:

```typescript
// 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.

```bash
npm install @pg-boss/dashboard@1.1.3
```

Run it in a second terminal, picking a different port than your app:

```bash
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`:

```typescript
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
      close: true,      // Release the pg connection pool once draining finishes (also the default)
    });
    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:

```bash
docker compose up -d
npx tsx src/index.ts
```

In terminal two:

```bash
DATABASE_URL=postgresql://pgboss:pgboss@localhost:5432/pgboss \
PORT=3001 \
npx pg-boss-dashboard
```

In terminal three, peek directly at the partitioned job table:

```bash
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 called `boss.send()` before `boss.createQueue()`. Auto-creation was removed in pg-boss 10.[^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

- The companion post [Production Postgres pooling with PgBouncer and Supavisor](https://nerdleveltech.com/production-postgres-pooling-pgbouncer-supabase-supavisor-tutorial) 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](https://nerdleveltech.com/postgres-listen-notify-realtime-presence-tutorial).
- If you are still on Postgres 17 and worried about the upgrade, [Zero-downtime Postgres 18 upgrade with pg_createsubscriber](https://nerdleveltech.com/postgres-18-zero-downtime-upgrade-pg-createsubscriber-tutorial) covers the migration.
- The official pg-boss docs ship a complete API reference[^14] and a Configuration page that lists every constructor option not used here (custom schema names, BYO database adapters, `max` pool sizing, password functions).

## Related reads

- [Postgres MERGE RETURNING: Old and New Values (2026)](https://nerdleveltech.com/postgres-merge-returning-old-new-values)

[^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]: PostgreSQL documentation — `FOR UPDATE SKIP LOCKED` (introduced in PostgreSQL 9.5). https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
[^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
[^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
[^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
[^6]: Node.js release schedule — Node 24 entered Active LTS in October 2025; EOL April 30, 2028. https://endoflife.date/nodejs
[^7]: pg-boss Configuration Reference — default `monitorIntervalSeconds=60`, `maintenanceIntervalSeconds=86400`, `superviseIntervalSeconds=60`, `warningQueueSize`, `warningSlowQuerySeconds`. https://deepwiki.com/timgit/pg-boss/15.5-configuration-reference
[^8]: pg-boss v10 release notes — queues must be created before send/insert; partitioned job table introduced. https://github.com/timgit/pg-boss/releases/tag/10.0.0
[^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
[^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
[^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/
[^15]: pg-boss v11 release notes — removed day/hour/minute unit variants of retention and expiration settings (e.g. `retentionDays`, `deleteAfterDays`) in favor of single `*Seconds` fields. https://github.com/timgit/pg-boss/releases/tag/11.0.0
