Queues
A queue is a durable place messages wait until a consumer processes them. One message is processed by one consumer (work distribution). An app sends to a queue with env.queue; a queue delivers either to a consumer app (push) or to callers that pull it over the data API — never both.
Queues are created, configured and deleted in the console (or the management API); apps only send and consume.
Send from an app
Any app can send to any queue in its own project and environment, by slug — no binding to set up:
ts
await env.queue.send("orders", { id: 1 });
await env.queue.send("orders", { id: 2 }, { delaySeconds: 30 });
await env.queue.sendBatch("orders", [{ body: { id: 3 } }, { body: { id: 4 }, delaySeconds: 5 }]);The slug is resolved within the app's scope; a queue elsewhere is reported as not found. See env.queue for options (contentType, delaySeconds, attributes, dedupId) and encoding. There are no per-org/per-app send quotas — a send is bounded by the queue's maxDepth/maxBytes and refused with queue full at capacity.
Consume with an app
Set one app as the queue's consumer in the console. The host batches messages and calls that app's queue(batch, env, ctx) handler:
ts
export default {
async queue(batch, env) {
for (const msg of batch.messages) {
try {
await process(msg.body); // msg.id, msg.attempts, msg.attributes, msg.timestamp
msg.ack();
} catch {
msg.retry({ delaySeconds: 30 });
}
}
},
fetch: (req, env, ctx) => router.handle(req, env, ctx),
};Delivery is at-least-once. A message you neither ack() nor retry() is acked when the handler resolves and retried if it throws, so a handler that throws retries the whole batch. A redelivered message arrives with attempts + 1.
Batch delivery settings — maxBatchSize, maxBatchTimeoutMs, maxConcurrency, visibilityMs — are set with the consumer. A batch is delivered when it reaches maxBatchSize messages or its oldest message is maxBatchTimeoutMs old; at most maxConcurrency batches are in flight per queue.
The failed area (no dead-letter queue)
There is no -dlq queue. Every queue has a built-in failed area: after its last retry (maxRetries) a message moves there instead of being dropped, kept for the queue's retention (max 14 days). The failed area is internal — not listed, not bindable, not configurable, and deleted with the queue.
In the console the queue page has Messages and Failed tabs; Failed supports peek, Retry (move back into the queue with fresh attempts) and Discard. Over the API:
GET /v1/queues/{qId}/messages?failed=1— peek the failed area;POST /v1/queues/{qId}/retry-failed{ max? }— move failed messages back into the queue;POST /v1/queues/{qId}/purge{ confirm, failed? }— purge the queue (or the failed area).
Pull over the data API instead of a consumer app
Clear a queue's consumer (console: "Pull over the API"; API: PUT …/consumer { appId: null }) and it becomes a pull queue: external callers receive, ack, retry and extend leases over the data API with an API token. A queue that still has a consumer app refuses receive with 409.
Not in the MVP
Topics and fan-out, ordering keys and dedup surfaced in the console, cross-project sends, and per-app send allowlists. (The automatic dead-letter queue and the console Topics tab were removed; the engine keeps its topic support.)