A client's document summariser charged them twice for the same 40-page PDF. Not a billing bug — the job ran twice. The worker was mid-way through a chain of model calls when a deploy sent it SIGTERM, the queue redelivered the job forty seconds later, and the whole chain re-ran from the top. Two invoices, one upload, and a very reasonable email asking what happened.
Nobody picks a job queue for that scenario. Teams pick one for throughput numbers they will never approach, then meet the redelivery semantics eight months later in production. We've run four of these in client work over the past year — pg-boss, BullMQ, Inngest and Trigger.dev — and the useful comparison isn't jobs per second. It's what each one does when something dies halfway.
The decision arrives as an outage, not a design doc
Almost every queue we've added to an existing codebase was added late. The pattern is consistent: a feature that used to take 400ms starts taking 40 seconds, because it now calls a model, or a PDF renderer, or a third-party API that has its own bad days. The request handler was never the right place for it, but it was the place it started.
AI features accelerate this hard. A summarise-then-extract-then-classify chain is three network calls with retries, and any one of them can stall for a minute. Put that in a request and you're at the mercy of whatever timeout sits in front of your app — the platform's, the load balancer's, or the browser's. So the work moves to a queue, and the queue arrives without a design phase.
- The symptom is a timeout— a gateway 504 on an endpoint that worked fine when the feature was a stub with a canned response.
- The first fix is a fire-and-forget promise — no await, return 202, hope the process stays alive. This works until the first deploy, then loses every in-flight job silently.
- The second fix is a queue — and it gets chosen in an afternoon, from a benchmark table, under pressure.
That afternoon is the one worth slowing down. Switching queues later isn't a library swap: retry semantics, idempotency assumptions and observability all leak into the job code itself. We've done two of those migrations and both cost more than the original integration.

Four queues, four different owners
The clearest way to separate these is to ask what each one takes ownership of. Two of them hand you primitives and leave the operating to you. The other two take your control flow and run it for you. That difference matters more than any feature list.
pg-boss — the queue already inside your database
pg-boss puts jobs in tables in the Postgres you're already running, and hands them out with SELECT … FOR UPDATE SKIP LOCKED. No new service, no new thing to back up, no new thing to page someone about at 2am. For most projects we start here, and roughly two-thirds of them never need anything else.
Its real advantage is one most comparisons skip: because the job lives in your database, enqueueing can ride the same transaction as the row it's about. Insert the document and queue its summarisation together, and there is no window where one exists without the other. Every queue that lives outside your database has that window, and every one of them eventually falls into it.
BullMQ — throughput you probably don't need, on infra you now own
BullMQ is the mature Redis option and it is genuinely good: rate limiting, concurrency groups, repeatable jobs, flows with parent-child dependencies, and throughput an order of magnitude past what Postgres polling will give you. If you're moving tens of thousands of jobs an hour, this is the one on the list built for it.
The cost is that you now operate Redis as a database rather than a cache, and the two configurations are different. A Redis tuned as a cache with an eviction policy will happily discard your job state under memory pressure, and it will not tell you. We inherited exactly that on a takeover project: jobs vanished at a low single-digit rate for weeks, blamed on the third-party API, actually evicted keys. Set persistence and no-eviction deliberately, or don't use it.
Inngest — the orchestrator is theirs, the code is yours
Inngest inverts the model. Your functions sit behind an HTTP endpoint in your own app; their service decides when to call them, how often to retry, and what to do when your function reports a step failed. You give up owning the scheduler. In exchange you get the one feature that changed how we write AI jobs: retries are per step, and completed steps are memoized.
Read the last two steps again. If saveSummary throws because the database was failing over, the retry starts at persist — not at the model call. On a job whose expensive step costs real money, that boundary is the difference between a retry that's free and a retry that shows up on an invoice. With a whole-job retry, four attempts is four model calls.
Trigger.dev — for work that outlives a request by design
Trigger.dev runs your task code on their infrastructure instead of calling back into your app, which removes the ceiling entirely. Jobs that run for minutes or hours are the normal case, not the thing you engineer around: video processing, long agent loops, bulk imports where a single run walks 50,000 rows. It also checkpoints, so a deploy mid-run doesn't restart the run from zero.
Two things to price in honestly. Your job code now lives in a separate deploy target with its own build, which is a real change to how a small team ships. And while it is open source and self-hostable, self-hosting it is a project with an owner — not an afternoon. On the managed plan we've found the billing intuitive because it's compute-time based; on a fan-out of thousands of tiny tasks, check the numbers before you commit.
| pg-boss | BullMQ | Inngest | Trigger.dev | |
|---|---|---|---|---|
| Runs on | Your Postgres | Your Redis | Their orchestrator, your endpoint | Their containers |
| New infra to operate | None | Redis, tuned as a datastore | None | None managed; real work self-hosted |
| Enqueue in your DB transaction | Yes | No — dual write | No — dual write | No — dual write |
| Unit of retry | Whole job | Whole job | Per step, memoized | Task, with checkpoints |
| Long-running work | You own the process, so fine | You own the process, so fine | Split across steps | The point of it |
| Observability out of the box | SQL you write yourself | Bull Board / hosted dashboard | Hosted UI with replay | Hosted UI, live logs, replay |
| Marginal cost | Postgres you already pay for | Redis you already pay for | Per run and per step | Per compute-second |
| Where it bites | Throughput ceiling under polling | Silent loss when misconfigured | Vendor owns your control flow | Second deploy target |
Retries are the product
Every queue on this list will retry your job. None of them can make your job safe to retry — that part is yours, and it is the part that gets skipped. The double-charged PDF at the top of this post was not a queue bug. The queue did exactly what it promised: at-least-once delivery. The job was simply not written to be run twice.
At-least-once is the guarantee all four give you, and it's the correct one — exactly-once delivery across a network is a thing vendors imply and physics doesn't allow. What you can build is exactly-once effect, by making the second run of a job find its own previous work and stop. That's a few lines per job, and it belongs there from the first commit.
The questions that actually decide the choice
- If the job runs twice, what does the user see — nothing, or a duplicate charge, a duplicate email, a duplicate row?
- When a deploy kills a worker mid-job, does the work restart from the beginning or from where it stopped?
- Does the enqueue commit with the database write it depends on, or can you end up with one and not the other?
- How does someone find out that a job has been failing for six hours — a dashboard, an alert, or a customer?
- What does one retry cost, in model tokens or third-party API calls, and how many retries are configured?
- Can you replay a single failed job after fixing the bug, without hand-writing a script to do it?
That third question is the quiet one. Enqueue after commit and a crash in between loses the job; enqueue before commit and a rollback leaves a job pointing at a row that never existed. pg-boss dissolves the problem because the job is a row in the same transaction. With the other three, you either accept the window or write an outbox — a table you insert into transactionally and drain into the queue separately. The outbox is not hard, but it is not free either, and it should be a decision rather than an omission.
No job that spends money — model calls, payment APIs, outbound messages — ships without an idempotency key derived from its input, checked before the side effect and not after. We added this rule after the duplicate-invoice incident, and it has caught three near-misses since, all of them retries nobody knew were happening.
How we choose now
The honest heuristic is that the correct answer is usually the boring one, and it stays correct much longer than teams expect. We reach past Postgres when there's a specific reason to, and the reason is almost never volume.
- Start on pg-bosswhen jobs are short, the app already has Postgres, and correctness matters more than throughput — emails, webhooks, thumbnails, nightly rollups. Transactional enqueue is worth more here than anything on the other three.
- Go BullMQ when you genuinely have volume, or need rate limiting and job dependencies as first-class features, and someone on the team is willing to own a Redis instance properly.
- Go Inngestwhen a job is a chain of expensive, flaky steps — which describes almost every AI feature we build. Per-step retries and replay from the dashboard pay for the vendor dependency within the first incident.
- Go Trigger.dev when the work is long by nature rather than by accident, and a request-shaped runtime is the thing fighting you.

Mixing is allowed, and on larger codebases it's what we end up with. One client runs pg-boss for the twenty small operational jobs that keep the app tidy, and Inngest for the four AI pipelines where a retry has a price tag. Nobody has ever complained about the split. What they complained about, before we did it, was a Redis outage taking out password reset emails alongside the video encoder.
If you only change one thing
Take your most expensive background job — the one that calls a model, or charges a card, or posts to somebody else's API — and run it twice by hand against staging with the same input. Not a load test. Two runs, same payload, then look at the database and the third-party dashboard. Most teams we do this with find something they didn't expect within ten minutes.
If that job is safe run twice, your queue choice is a preference and you can pick on ergonomics. If it isn't, no queue on this list will save you, and the fix is a day of work in your own code rather than a migration to somebody else's platform. That's the whole decision, and it has almost nothing to do with jobs per second.








