Queue
Where a background job waits and who hands it to a worker. Nothing is built in, pg-boss on the Postgres you already run is what the starter writes, and moving to Redis or Cloudflare Queues is one import your job code never sees.
A job says what to run. The adapter decides everything else, which process picks
it up, what queued work survives that process dying, and whether cron and dedup
exist at all. It is the only piece that moves when you change backends, so
job(), publish() and your handler never name a broker.
The default
There is not one. No queue adapter is built in, and an app with no queue block
is still a valid app. The service hands back an empty client.
What most readers actually run is whatever create-questpie wrote for them, and
that is pg-boss unless they asked otherwise.
import { pgBossAdapter } from "questpie/adapters/pg-boss";
import { runtimeConfig } from "questpie/app";
import { env } from "@/lib/env";
export default runtimeConfig({
app: { url: env.APP_URL },
db: { url: env.DATABASE_URL },
queue: {
adapter: pgBossAdapter({ connectionString: env.DATABASE_URL }),
},
});--queue bullmq writes bullMQAdapter({ connection: { url: env.REDIS_URL } })
instead and adds the bullmq and redis packages. --queue none leaves the
block out.
No block, no queue
Codegen still generates the typed methods from your jobs/ files, but with no
queue block the service returns {}. app.queue.sendEmail is then
undefined and dispatching throws on the missing property. Nothing falls back.
Available adapters
| Adapter | Factory and import | Needs | Pick it when |
|---|---|---|---|
| pg-boss | pgBossAdapter() from questpie/adapters/pg-boss | the Postgres you already have | Always, until you have a reason not to. This is the starter default. |
| BullMQ | bullMQAdapter() from questpie/adapters/bullmq | Redis, plus the bullmq package | You already run Redis and want a dedicated high-throughput queue. |
| Cloudflare Queues | cloudflareQueuesAdapter() from questpie/adapters/cloudflare-queues | a Queues producer binding | You deploy to Workers. It is the only one that runs there. |
| yours | anything implementing QueueAdapter | whatever your broker needs | The three above do not cover your infrastructure. |
None are re-exported from questpie/queue. pg-boss and bullmq are optional
peer dependencies, pulled in only by their own subpath. Cloudflare needs neither.
What each one can do
An adapter declares capability flags. The consumer flags gate methods that throw
when false, and executionTerminalState makes a secretPayload publish throw.
scheduling throws from .schedule() but skips options.cron quietly, and
singleton only advertises.
| Capability | pg-boss | BullMQ | Cloudflare Queues |
|---|---|---|---|
longRunningConsumer, queue.listen() | yes | yes | no |
runOnceConsumer, queue.runOnce() | yes | yes | no |
pushConsumer, queue.createPushConsumer() | no | no | yes |
scheduling, options.cron and .schedule() | yes | yes | no |
singleton, singletonKey dedup | yes | yes | no |
executionTerminalState, secretPayload | yes | no | no |
The resolved flags are on app.queue.capabilities. If one codebase targets more
than one runtime, read them and branch instead of catching the throw.
Swapping
queue takes an adapter instance and nothing else, so moving to Redis is one
import and one expression. The generated app.queue.<name> methods, your
handlers and every dispatch site are untouched.
import { bullMQAdapter } from "questpie/adapters/bullmq";
import { runtimeConfig } from "questpie/app";
import { env } from "@/lib/env";
export default runtimeConfig({
app: { url: env.APP_URL },
db: { url: env.DATABASE_URL },
queue: {
adapter: bullMQAdapter({ connection: { url: env.REDIS_URL } }),
},
});Two things move under you. Anything capability-gated in the table above stops working the moment you pick an adapter without that flag, which is what makes the jump to Cloudflare Queues the expensive one. And dispatch inside a transaction changes route, covered in Transactional dispatch.
pg-boss options
PgBossAdapterOptions is pg-boss's own ConstructorOptions plus one QUESTPIE
switch. Everything else, connectionString, schema, max and the rest, is
spread straight into new PgBoss(...) and stays pg-boss's to document.
| Option | Default | What it does |
|---|---|---|
useApplicationTransaction | true | Insert the job through the app's current Drizzle transaction. Set false for a separate database. |
The adapter creates each queue on demand and caches the name, so you never declare queues yourself. It is also the only adapter that can insert the job in your transaction, which is why it is the default.
BullMQ options
| Option | Required | What it does |
|---|---|---|
connection | yes | ioredis connection options, handed to every Queue and Worker. |
queuePrefix | no | Redis key prefix for this app's queues and workers. |
workerOptions | no | Extra BullMQ WorkerOptions, minus connection and prefix. |
start() is a no-op and connections open lazily. listen() builds one Worker
per job name and maps teamSize to its concurrency. schedule() adds a
repeatable under questpie:<jobName>, and unschedule() removes it by name.
Cloudflare Queues options
Pass queue or enqueue. The constructor throws if you pass neither.
| Option | What it does |
|---|---|
queue | A producer binding, or a function returning one. The function is resolved on every publish. |
enqueue | Your own producer function, in place of a binding. |
decode | Turns a raw pushed body into an envelope. Defaults to requiring a jobName string. |
The adapter carries a runtime: "cloudflare" marker, and the handler from
questpie/adapters/cloudflare refuses to start unless the queue adapter has
both that marker and createPushConsumer(). Delays cap at 24 hours, and a
startAfter or retryDelay past that throws.
`retryDelay` is seconds on every adapter
You always pass it in seconds. BullMQ multiplies by 1000 for its millisecond
backoff.delay, and Cloudflare passes it through as delaySeconds. Do not
pre-multiply for either one.
The interface
Every adapter, built-in or yours, implements this. It is the only seam the queue client talks to.
interface QueueAdapter {
capabilities?: Partial<QueueAdapterCapabilities>;
// Required.
start(): Promise<void>;
stop(): Promise<void>;
publish(jobName, payload, options?, dispatchId?): Promise<string | null>;
schedule(jobName, cron, payload, options?): Promise<void>;
unschedule(jobName: string): Promise<void>;
on(event: "error", handler: (error: Error) => void): void;
// Optional. What you implement decides what the adapter can do.
publishInTransaction?(
tx,
jobName,
payload,
options,
dispatchId,
): Promise<string | null>;
transactionalPublishing?: boolean;
inspectExecutionState?(jobName, adapterJobId): Promise<QueueExecutionState>;
ensureQueue?(jobName, opts?): Promise<void>;
listen?(handlers, options?): Promise<void>;
runOnce?(handlers, options?): Promise<QueueRunOnceResult>;
createPushConsumer?(args): QueuePushConsumerHandler;
}Those six required members are the whole obligation. Leave capabilities off
and the runtime infers it: the three consumer flags from whether the matching
method exists, scheduling from schedule and unschedule both being
functions, and singleton from false. executionTerminalState is the one
exception, true only when you set it true and implement inspectExecutionState.
questpie/queue carries the whole contract surface, QueueAdapter plus the
Queue* record, batch and capability types. Each adapter's option type ships
from its own entry point, as PgBossAdapterOptions from
questpie/adapters/pg-boss does.
Where each topic lives
| Topic | Page |
|---|---|
| Publishing inside a transaction, crash recovery | Transactional dispatch |
| The obligations behind each optional method | Writing an adapter |
job(), publish(), PublishOptions, handlers | Jobs |
| Starting a worker in each consumer model | Running a worker |
| Encrypted payloads and safe receipts | Secret payloads |
The runtimeConfig file and its other slots | Configuration |