← Blog2026-05-03Engineering

pg-boss in production: footguns we hit and how to avoid them

By Michael Cooper · Founder

Ten operational footguns we hit running pg-boss at AGLedger. Four are API-time, six are mostly configuration-time. All ten are live in current releases. Reproductions, citations, and the patterns we settled on.

The short version

We run pg-boss in production for about a year, across outbound delivery, webhook fanout, and scheduled maintenance. Ten operational footguns hit us repeatedly.

Footguns 1 to 4 are API-time: you call something and it does not do what you expected. Footguns 5 to 10 are mostly configuration-time, where a mechanism looks like it is enforcing something and is not, so the queue reads as correctly configured while the constraint applies to nothing.

Footguns 1 to 4 were tested 2026-05-03 against pg-boss 12.18.2, PostgreSQL 17, Node 24 LTS. Footguns 5 to 10 were read from src/plans.ts at each tag and confirmed against live behavior on 12.18.2, 12.26.4 and 12.27.0, PostgreSQL 18.4, Node 24.17.0. Two things change across that range and neither changes a finding: the notify column arrives in 12.21.0, and insert() drops group.id before 12.26.4.

Footgun 1 carries an amendment retested 2026-08-13 against pg-boss 12.27.0 on PostgreSQL 18.4: singletonSeconds deduplicates, but it is a wall-clock throttle rather than an in-flight lock, and policy: 'exclusive' is the primitive for one-at-a-time. If you are on pg-boss earlier than 11.0.8 or 10.4.0, upgrade first: issue #535 is fixed. The footguns below are still live in the latest releases.

API-time

1. singletonKey without singletonSeconds is a no-op on standard queues, and singletonSeconds is a throttle rather than a lock

2. boss.send() returns null on dedup, silently

3. boss.schedule() upserts on (name, key); schedules silently overwrite

4. Schema drift across pg-boss majors (snake_case rename in v10, archive removal in v11)

Configuration-time

5. groupConcurrency exempts ungrouped rows, and shrinks your batch: 5.3x more fetches for the same 30 jobs

6. createQueue never updates an existing queue, so tuning reaches only databases created after the change

7. updateQueue has three different null outcomes, and two options cannot be changed at all

8. A queue's policy is immutable; changing it means delete, recreate, and your own advisory lock

9. expired is not a job state; the enum is six values and the order is load-bearing

10. A running boss holds the event loop open, which decides whether a fatal boot error hangs or exits 0

1. Why doesn't singletonKey deduplicate on a standard queue?

Because it only deduplicates when paired with singletonSeconds. On the default standard policy the key is treated as a label only, and parallel enqueues all create rows.

pg-boss 12.x · default queue policy · silent failure mode

The pg-boss jobs API docs are explicit: singletonSeconds is the dedup window. The error mode is silent: callers see a returned id, no exception, and ship. Reproduction:

import { PgBoss } from 'pg-boss'

const boss = new PgBoss({ connectionString: process.env.DATABASE_URL! })
await boss.start()
try {
  await boss.createQueue('demo') // idempotent in v10+

  const ids = await Promise.all(
    Array.from({ length: 6 }, () =>
      boss.send('demo', { x: 1 }, { singletonKey: 'k' }),
    ),
  )
  console.log(ids.filter(Boolean).length) // -> 6, not 1
} finally {
  await boss.stop()
}

A second path: set a queue policy at createQueue time, so that singletonKeyis enforced by a partial unique index instead of a time window. They do not all mean “one at a time”. Each is a partial unique index on (name, COALESCE(singleton_key, '')) differing only in the predicate (visible in pg-boss plans.ts). The footgun is the default standard policy specifically.

PolicyIndex predicateWhat it actually enforces
shortstate = 'created'One queued job per key. A second send is refused while the first waits, and accepted once it starts running.
singletonstate = 'active'One running job per key. Sends land in created, which the index does not cover, so parallel sends all create rows.
statelystate <= 'active', state in keyOne job per key per state. A created, a retry and an active job can coexist.
exclusivestate <= 'active'One unfinished job per key, whatever state it is in. This is the one that means “only one of these at a time.”

singleton is the trap in that list: the name suggests uniqueness, the index only covers active, and six parallel sends produce six rows, exactly like the reproduction above.

Update, 2026-08-13: pairing with singletonSeconds is not the answer for one-at-a-time

Everything above holds: singletonKey alone is still a no-op on a standardqueue. But if what you actually want is “only one of these running at a time,” adding singletonSeconds is the wrong fix.

singletonSeconds buckets on wall-clock time, not on job liveness. It sets singleton_on to the epoch floor of now()/N, and the supporting index covers every state except cancelled:

-- the throttle index
CREATE UNIQUE INDEX job_i4
  ON job (name, singleton_on, COALESCE(singleton_key, ''))
  WHERE state <> 'cancelled' AND singleton_on IS NOT NULL

-- the one-at-a-time index
CREATE UNIQUE INDEX job_i6
  ON job (name, COALESCE(singleton_key, ''))
  WHERE state <= 'active' AND policy = 'exclusive'

Two consequences fall out of job_i4. A job that has already completed keeps refusing new sends for the remainder of its bucket, because completed and failed are both inside the predicate. And because the key includes singleton_on, two sends that straddle a bucket boundary have different keys, do not conflict at all, and leave two jobs in flight on the same key. It throttles. It does not lock.

job_i6 is the primitive for one-at-a-time. The job_state enum is ordered created < retry < active < completed < cancelled < failed, so state <= 'active' covers exactly created, retry and active: one job per key, and the slot frees the moment the job finishes.

BehaviorsingletonSecondspolicy: exclusive
Second send while first is runningRefusedRefused
Send after the first completedRefused for the rest of the bucketAccepted immediately
Send after the first failedRefused for the rest of the bucketAccepted immediately
Two sends straddling a bucket boundaryBoth accepted, two jobs in flightSecond refused
Two sends on different keysBoth acceptedBoth accepted

The symptom that surfaced it. An admin endpoint returning 409 already in progress with zero jobs running. The scan it guarded had finished minutes earlier and was still holding its bucket.

The wrinkle, and it is worse than it looks. Under exclusive, a job in retry holds the slot with nothing executing. retryDelay is the floor on that hold, not its duration. It sets start_after, which governs when the job becomes eligible to be fetched. It does not move the job out of retry, and retry is inside the state <= 'active' window that job_i6 covers. The job sits there until a worker actually picks it up.

With a worker polling at 2 seconds and an instant handler, the hold measured retryDelay plus about a second: 6.01s at a 5s delay, 16.04s at 15s. Budget roughly retryDelay + pollingInterval/2 + handler duration.

With nothing consuming the queue it never resolves at all. At retryDelay of 5s, 15s and 30s, a fresh send on the same key was still refused well past start_after, with the job sitting in retryand nothing active. So the ceiling on the hold is set by your consumer's liveness, not by any pg-boss setting: an exclusive queue whose worker has died holds every failed key's slot indefinitely. The symptom is the same 409 with nothing running that opens this section.

What we did. Moved the queue to policy: 'exclusive' and deleted the machinery the throttle had required: a lookup of the job owning the current bucket, a settled-versus-active branch, and a two-times-window heuristic that existed only to guess which job owned a bucket.

One thing to weigh before reaching for it everywhere: exclusive refuses the insert rather than queueing it behind the running job. That makes it the wrong choice for any work you cannot afford to drop.

Verified against pg-boss 12.18.2, 12.26.4 and 12.27.0 on PostgreSQL 18.4, Node 24.17.0, one isolated schema per version. Behavior identical on all three. Index definitions from plans.ts (createIndexJobThrottle, createIndexJobPolicyExclusive).

References: pg-boss issues #81 (historical singleton context, closed 2018) and #548 (replace-vs-discard semantics; closed 2026-07-02).

Our first fix was a facade over boss.send that filled both fields, which stops the no-op but buys a throttle rather than a lock. Pick the primitive first, then wrap it.

2. Why does boss.send() return null instead of a job id?

A null return means the enqueue was deduplicated. It is documented behavior rather than an error, and nothing throws, so callers that do not check the return value drop the dedup signal.

pg-boss 12.x · documented, easy to discard

When pg-boss successfully dedups an enqueue, boss.send() returns null, not a job id, and not an error. Callers that destructure or chain on the return without checking const id = await boss.send(...); track(id) silently drop the dedup signal.

import { PgBoss } from 'pg-boss'

const boss = new PgBoss({ connectionString: process.env.DATABASE_URL! })
await boss.start()
try {
  await boss.createQueue('demo')

  const a = await boss.send('demo', { x: 1 },
    { singletonKey: 'k', singletonSeconds: 60 })
  console.log(a) // -> a uuid string

  const b = await boss.send('demo', { x: 1 },
    { singletonKey: 'k', singletonSeconds: 60 })
  console.log(b) // -> null
} finally {
  await boss.stop()
}

This is documented behavior, not a bug. We landed an ESLint rule that flags discarded return values from queue-send calls after multiple callsites in our own codebase silently discarded the null. One of them was a metric counting enqueues. Until we caught it, the count was off by the dedup rate.

Reference: pg-boss issue #548 discusses replace-vs-discard semantics and was closed on 2026-07-02.

What we did. Treat every boss.send / boss.insert return value as required-to-handle. The simplest version is the lint rule. The runtime version is a wrapper that returns a discriminated union ({ deduped: true } | { id: string }) so the type system forces the branch.

3. Why did boss.schedule() overwrite my existing schedule?

Because pgboss.schedule is keyed on (name, key) and key defaults to an empty string, so a second call for the same queue name upserts over the first.

pg-boss 12.x · documented, easy to misuse

The pgboss.schedule table's primary key is (name, key). The key parameter to boss.schedule() defaults to ''. If you call boss.schedule('maintenance', cron, data) once per task, all calls share the empty default key. Only the last one survives. Every prior schedule row is upserted away.

import { PgBoss } from 'pg-boss'

const boss = new PgBoss({ connectionString: process.env.DATABASE_URL! })
await boss.start()
try {
  await boss.createQueue('maintenance')

  await boss.schedule('maintenance', '*/2 * * * *', { task: 'expiry-sweep' })
  await boss.schedule('maintenance', '*/5 * * * *', { task: 'audit-checkpoint' })
  await boss.schedule('maintenance', '0 * * * *',   { task: 'reputation-roll' })

  // Inspect with: SELECT name, key, cron, data FROM pgboss.schedule;
  //
  // Expected: three rows.
  // Actual:   one row, name='maintenance', key='', cron='0 * * * *',
  //           data={task:'reputation-roll'}.
} finally {
  await boss.stop()
}

We shipped this bug and did not notice for a while. The schedule that survived was the one we expected to fire most often, so the system looked healthy from the outside. We caught it when an unrelated recovery sweep started failing and we went looking. The schedules that ran were the ones registered last on each boot. The schedules that did not run were the ones we thought were running.

Reference: pg-boss schedule SQL is in plans.ts (search for the schedule function); the upsert on (name, key) is visible in the SQL.

What we did. Always pass an explicit, unique key argument to boss.schedule. Wrap it: a helper that takes (boss, queue, cron, data, taskKey) and sets both key: taskKey and singletonKey: taskKey in one call. Never call boss.schedule directly.

To detect existing collisions in your own database:

SELECT name, key, count(*)
FROM pgboss.schedule
GROUP BY 1, 2
HAVING count(*) > 1;

4. What breaks when you upgrade across pg-boss majors?

Raw SQL against the pg-boss schema. v10 renamed columns to snake_case, v11 removed the pgboss.archive table, and v12 went ESM-only, so ops scripts and dashboards that hardcode the schema break silently on the matching upgrade.

pg-boss v10, v11, v12 · silent breakage in raw SQL

pg-boss has had two breaking schema changes and one breaking packaging change since v10. v10 introduced partitioned tables, queue policies, and the snake_case column rename (singletonkey singleton_key, etc.). v11 removed the pgboss.archive table entirely and changed retention semantics; completed jobs now live in pgboss.job with state = 'completed' until deleteAfterSeconds elapses. v12 went ESM-only with named exports.

Anything that referenced old column names or the removed archive table in raw SQL (recovery sweeps, ops scripts, monitoring dashboards) broke silently on the matching upgrade. The v11 shape we hit:

import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL! })

// A "find old completed jobs" query that worked on pg-boss 10.x:
const completed = await pool.query(`
  SELECT id, completed_on FROM pgboss.archive
  WHERE name = $1 AND completed_on > now() - interval '7 days'
`, ['demo'])

// On pg-boss 10.x:  returns rows.
// On pg-boss 11.x+:   throws 'relation "pgboss.archive" does not exist'
// - but only when this query actually runs, which for a weekly sweep
// can be days after the upgrade if the sweep is in a try/catch that
// logs-and-continues.

await pool.end()

What we did. A startup sentinel. On boss.start() completion, run one query against information_schema.columns for pgboss.job and assert every column you reference by string literal in raw SQL is present. Fail boot if a required column is missing. Throwing on boot is strictly cheaper than a silent loop.

const REQUIRED = ['name', 'state', 'singleton_key'] as const
const { rows } = await pool.query<{ column_name: string }>(`
  SELECT column_name FROM information_schema.columns
  WHERE table_schema = 'pgboss' AND table_name = 'job'
`)
const present = new Set(rows.map(r => r.column_name))
const missing = REQUIRED.filter(c => !present.has(c))
if (missing.length > 0) {
  throw new Error(`pgboss.job missing columns: ${missing.join(', ')}`)
}

The same sentinel pattern works in your migrations gate if you maintain ops dashboards.

Fixed upstream: issue #535

pg-boss 11.0.8 (2025-10-10) · backport in 10.4.0 (2025-11-19)

pg-boss issue #535 (closed 2025-10-03): on a stately or singleton queue with batch_size > 1, when two jobs sharing a singleton key sat in created and retry states at the same time, the fetch query would grab both and try to activate them. The partial unique index on the singleton key fails the activation with a Postgres 23505 unique_violation, and the worker stops fetching from then on. It affects singleton queues as well as stately ones.

If you are on pg-boss earlier than 11.0.8 (or earlier than 10.4.0 on the v10 line), upgrade. To confirm it is not silently affecting you, look for completed jobs sharing a singleton key with completion timestamps within milliseconds of each other:

-- pg-boss v11+ - completed jobs live in pgboss.job until
-- deleteAfterSeconds elapses (the archive table was removed in v11).
SELECT singleton_key, COUNT(*), MIN(completed_on), MAX(completed_on)
FROM pgboss.job
WHERE state = 'completed'
  AND singleton_key IS NOT NULL
  AND completed_on > now() - interval '7 days'
GROUP BY singleton_key
HAVING COUNT(*) > 1
   AND MAX(completed_on) - MIN(completed_on) < interval '5 seconds'
ORDER BY 2 DESC LIMIT 20;

-- pg-boss v10.x - query pgboss.archive instead (or in addition).

Credit goes upstream. The fix landed in 11.0.8 via the maintainer (timgit) and was backported to the v10 line in PR #640 by @nrempel, released as 10.4.0. We just had to upgrade.

5. Why did groupConcurrency make our throughput fall off a cliff?

Because it does not only cap concurrency, it shrinks the batch. The fetch applies LIMIT batchSize first and discards over-cap rows second, with no refill. Concentrating the same 30 jobs into fewer groups took the drain from 3 fetches to 16. These are counts of fetch calls rather than timings.

pg-boss 12.x · two separate traps · both silent

Trap one: rows with no group are exempt from the cap

groupConcurrency constrains only jobs that carry a group. A producer that does not stamp one emits rows that are exempt, so the queue looks correctly configured from the worker side while the limit applies to nothing. The fetch predicate for groupConcurrency: 1 is (j.group_id IS NULL OR NOT EXISTS (... active job with the same group_id ...)), and the IS NULL disjunct is unconditional. The supporting index is likewise WHERE state = 'active' AND group_id IS NOT NULL.

A fetch against a queue with groupConcurrency: 1 returned 1 of 3 grouped jobs and 3 of 3 ungrouped ones. One misconfigured producer is enough to disable the cap for everything it enqueues.

Version note. On 12.18.2, insert() silently discards group.id even when you pass it, so every job enqueued that way is exempt and the cap constrains nothing at all. Fixed in 12.26.4 by pg-boss issue #861. send() was never affected.

Trap two: the cap eats your batch size

fetchNextJob builds a next CTE that applies LIMIT batchSize, then hands the result to a group_ranking / group_filtered pair that discards rows over the cap. Nothing refills afterward. The candidate window is chosen first and thinned second, so a fetch of batchSize: 10 against a deep per-group backlog returns far fewer than 10, and how many depends entirely on how the backlog is spread across groups.

All rows below were enqueued group-sequentially with send() (every job for group 1, then every job for group 2, and so on) at batchSize: 10, groupConcurrency: 1.

Backlog shapeEnqueuedReturnedBatch fill (of 10)
10 groups x 1 job1010100%
5 groups x 2 jobs10550%
3 groups x 5 jobs15220%
2 groups x 10 jobs20110%
3 groups x 5, then 10 ungrouped25220%
3 groups x 5 interleaved with 10 ungrouped25660%

The last two rows are the same population and differ only in enqueue order. Ungrouped jobs are exempt from the cap, but they only help if they land inside the first batchSize rows by (priority DESC, created_on, id). Append them after a grouped backlog and they fall outside the window, so they do not fill the batch and they wait behind it.

The drain cost follows directly. Same 30 jobs, same batchSize: 10 and groupConcurrency: 1, completing each batch before the next fetch:

ShapeFetches to drainBatch sizes
30 groups x 1 job310, 10, 10
10 groups x 3 jobs74, 5, 7, 5, 5, 3, 1
3 groups x 10 jobs161, 2, 2, 2, 2, 2, 3, 3, 3, 3, 2, 1, 1, 1, 1, 1

5.3x more fetches for the same 30 jobs, purely from concentrating the backlog into fewer groups. The cap is doing what it says; the surprise is that batchSizestops being a throughput knob once a group backlog forms. The effective batch is closer to “number of distinct groups with queued work, capped at batchSize” than to batchSize.

One measurement caveat. Batches enqueued via insert() are non-deterministic under groupConcurrency. The job table declares created_on ... DEFAULT now(), and now() is transaction-scoped, so every row from one insert() call shares a created_on and the ordering falls through to a gen_random_uuid() id. Eight identical runs returned 2 every time via send() and anywhere from 4 to 9 via insert(). If you are benchmarking this, use send() or you will read run-to-run variance as a version difference.

What we did. Every producer on a grouped queue stamps a group, enforced by a test rather than by review.

6. Why didn't changing a queue option in code change anything?

Because createQueue on a queue that already exists is a no-op for every option. Queue tuning reaches databases created after the change; anywhere the queue row already exists, it silently keeps the values it was first created with.

pg-boss 12.x · splits behavior by install age

pgboss.create_queue() inserts the queue row with a bare ON CONFLICT DO NOTHING and no update branch, so a queue row that already exists is left untouched. The function returns void, so nothing distinguishes the created case from the ignored one. Re-creating a queue with retryLimit moved from 2 to 9 and expireInSeconds from 60 to 999 left the pgboss.queue row unchanged.

It bit us on an outbound delivery queue, where expireInSeconds has to stay above the HTTP client timeout. Set it below, and pg-boss re-fires the job while the first attempt is still in flight, so the receiver handles the same delivery twice. The one time we moved that value, the change reached fresh installs only.

What we did. One helper that creates, then converges the existing row via updateQueue. Options that cannot be changed after creation are a separate argument, so the distinction is visible at every call site, and a lint rule forbids bare createQueue so the next option added cannot be inert everywhere the queue already exists.

The corollary that still catches us. Converging handles a changed option but not a removedone. Delete a line from a queue declaration and databases with an existing row keep the old value while new ones take pg-boss's default, which is the same split by age in a new costume.

7. Which queue options can you actually change after creation?

All but two. policy and partition throw. The other eleven update, but through three different SQL paths, and passing null does three different things.

pg-boss 12.26.4 and 12.27.0 · statement byte-identical between them

OptionColumnSQL semanticChangeable?
retryLimitretry_limitCOALESCEyes
retryDelayretry_delayCOALESCEyes
retryBackoffretry_backoffCOALESCEyes
retryDelayMaxretry_delay_maxjsonb_existsyes, with a catch
expireInSecondsexpire_secondsCOALESCEyes
retentionSecondsretention_secondsCOALESCEyes
deleteAfterSecondsdeletion_secondsCOALESCEyes
warningQueueSizewarning_queuedCOALESCEyes
heartbeatSecondsheartbeat_secondsjsonb_existsyes
notifynotifyCOALESCE (absent before 12.21.0)yes
deadLetterdead_letterconditional assignmentyes
policypolicyabsent from the statementno, throws
partitionpartitionabsent from the statementno, throws

deadLetter is the odd one out and belongs to neither semantic class. It arrives as a separate function argument rather than through the options JSONB, and the whole assignment clause is omitted from the generated SQL when the caller passes nothing.

Passing null has three different outcomes. The SQL story and the API story diverge: for most options the JS validation layer rejects null before it ever reaches the statement.

  • Throws: retryLimit, retryDelay, retryBackoff, expireInSeconds, retentionSeconds, notify
  • Silently ignored: deleteAfterSeconds, warningQueueSize
  • Actually set to null: retryDelayMax, heartbeatSeconds

Two options ignore null silently: deleteAfterSeconds and warningQueueSize. The rule is the same either way: always pass the value you want, including when that value is the default.

retryDelayMax cannot be updated on its own. On a queue already created with retryBackoff: true:

updateQueue(q, { retryDelayMax: 200 })
  -> throws: retryDelayMax can only be set if retryBackoff is true

updateQueue(q, { retryBackoff: true, retryDelayMax: 200 })
  -> stored: 200

The guard reads the options object it was handed rather than the queue row, so it cannot see the retryBackoff: true already stored. You have to resend it alongside.

One good piece of news: policy and partition refuse loudly. Being absent from the UPDATE could reasonably be read as “silently ignored.” It is not. Both throw: queue policy cannot be changed after creation and queue partitioning cannot be changed after creation. That is the friendlier failure, and it is what makes the next footgun tractable.

8. How do you change a queue's policy on an existing install?

You delete and recreate the queue, which drops its jobs, and you hold your own advisory lock while you do it. There is no in-place path.

pg-boss 12.x · destructive · unprotected by default

policy is absent from updateQueue and createQueue is ON CONFLICT DO NOTHING, so changing a policy in code gives new databases the new semantics and leaves every existing one on the old. Given footgun 1, that means an install can be silently running without the dedup you think you configured.

The race is the part worth planning for. pg-boss takes advisory locks inside createQueue and deleteQueue under different keys, and takes no lock at all for reading pgboss.queue. A read-then-delete-then-create sequence is therefore protected by nothing pg-boss does. If your API and worker processes boot simultaneously, as ours do, that race is routine rather than theoretical. pgboss.delete_queue, the SQL function, on an already-deleted queue raises SQLSTATE 22004, which is what losing the race looks like. Note the JS boss.deleteQueue() swallows the cold-cache case and returns quietly, so you will only see 22004 on the warm-cache race this describes, not by calling it twice from a fresh process.

What we did.Boot recreates the queue when the live policy differs from the one in code, reads the policy back, and refuses to start rather than run with dedup silently disabled. The whole check, recreate and verify sequence is held under one advisory lock we own rather than one of pg-boss's.

The cost. Recreating drops that queue's jobs. We accepted it for exactly one queue because its work is re-runnable integrity checks with a 24-hour retention, so the worst case is losing up to a day of that history plus whatever was in flight at restart. If the queue carries work you cannot re-derive, drain it first or accept that the policy is fixed for the life of the database.

9. What are the pg-boss job states?

Six, and expired is not one of them. An expired job is failed through the same path as any other failure, so it lands in retry while retries remain and reaches failed only once they are exhausted. The default retryLimit is 2, so on a default queue it never goes straight to failed.

pg-boss 12.x · identical on 12.18.2, 12.26.4, 12.27.0

SELECT enum_range(NULL::pgboss.job_state);

                    enum_range
---------------------------------------------------
 {created,retry,active,completed,cancelled,failed}
(1 row)

The declaration order is load-bearing rather than cosmetic. Postgres enums compare in declaration order, which is what lets job_i6express “not yet finished” as state <= 'active'. Confirmed behaviorally on all three versions: on an exclusive queue a send is refused while the holder is created and while it is active, and accepted immediately once it is completed or failed.

We published an API response enum that listed expired, a state that never arrives, and omitted retry and cancelled, two that genuinely do. A client coding against that spec would poll forever for one and never handle the others.

What we did.Prose about job states is generated from constants rather than retyped, and a test pins our API's documented state enum against enum_range on the live database, so a rename upstream fails the build instead of shipping.

10. Why did a fatal boot error exit 0 and report success?

Because pg-boss's intervals are not unref'd, so a running boss holds the Node event loop open. That decides which way a swallowed boot error fails: hang, or exit clean.

pg-boss 12.x · ESM · measured in the container we run in production

A top-level await rejection in an ESM entrypoint is delivered as uncaughtException. If you have a keep-alive handler that swallows those, what happens next is decided entirely by pg-boss:

  • pg-boss running: its intervals hold the event loop open. The process survives, the container reports Up, and the HTTP port is never bound. A healthy-looking container serving nothing.
  • pg-boss failed to start: nothing holds the loop, Node drains it and exits 0. A fatal boot error reporting clean success to every orchestrator watching.

The second is the dangerous one. A readiness probe catches the hang. Nothing catches exit 0.

A related ordering bug in the same area. Starting job consumers before the boot phase finishes means a job can execute before the process has finished starting. If a later registration then throws, the process exits 1 without stopping pg-boss, stranding that job in active on an exclusive queue with expireInSeconds: 3600. That reproduces the exact 409 from footgun 1, with nothing running. An hour is the floor, not the ceiling: when the job finally expires it is failed through the normal path, so it moves to retry, which job_i6 also covers, and keeps the slot until a worker drains it.

What we did. A two-phase boot guard: until the HTTP server is listening, an unhandled error logs fatal and exits 1; after that, the prior keep-alive posture is unchanged. Consumers start only once boot has finished.

These are the ten that bit us repeatedly across outbound delivery, webhook fanout and maintenance schedules. Three have a corresponding GitHub issue; the rest are behaviors read out of the source rather than bugs anyone has filed. pg-boss is the Postgres-native queue we picked and would pick again, and the maintainer is responsive. If you found a cleaner pattern for any of these, open an issue on pg-boss or send us a note.

Everything measured here is behavioral, taken on a single local instance. That is the right instrument for “does this row exist, in this state, after this call” and the wrong one for throughput, so there is no benchmark for what policy: 'exclusive' costs against a standard queue. We have written separately about benchmarking this stack.

Sources & further reading

Related

AGLedger is a self-hosted cryptographic notary for automated work; every record is hash-chained and Ed25519-signed. pg-boss is part of how we get there, which is why queue behavior this far down gets this much attention: a record is only worth something if it still holds up once someone disputes it. That property has a name, tamper-evident logging, and it is what an AI agent's audit trail has to be built from. Learn more.