A queue stores messages in the app's database until a consumer function has
handled them. Messages survive a restart and are delivered at least once, so a
consumer must be safe to run twice for the same message. Use the send's id
to prevent a sender from queuing the same job twice.
Declare, send, consume
A version declares its queues in the queues array of the version manifest,
like cron. Sending to a queue that the live version does not declare throws
an error naming the queue.
"queues": [
{ "name": "emails", "consumer": "mailer", "batch_size": 10, "max_retries": 5 },
{ "name": "images", "consumer": "resizer", "dead_letter_queue": "images-failed" },
{ "name": "images-failed" }
]
substrio push and substrio deploy build that array from substrio.toml.
Use one [queues.<name>] table per queue, with the same fields. A queue name
containing . cannot be used as a table header and cannot be declared there.
[queues.emails]
consumer = "mailer"
batch_size = 10
max_retries = 5
[queues.images]
consumer = "resizer"
dead_letter_queue = "images-failed"
[queues.images-failed]
Send from any function:
substrio.queue("emails").send({ to: "[email protected]" });
substrio.queue("emails").send({ to: "[email protected]" }, { delay: 60_000, id: "welcome-bo" });
substrio.queue("emails").sendBatch([{ to: "a" }, { body: { to: "b" }, delay: 5000 }]);
send returns { seq, duplicate }. A send whose id is already waiting in the
queue (or being delivered) sends nothing and returns duplicate: true with the
first message's seq. The id is forgotten once its message is acked or dead.
delay is in milliseconds. A send is refused inside db.transaction.
sendBatch accepts bodies or { body, delay, id } objects. An item whose body
has a body key must use the object form.
The consumer is the module export queue(batch, env, ctx) of the function the
declaration names:
export default {
async queue(batch, env, ctx) {
for (const msg of batch.messages) { // msg.body, msg.attempts, msg.id, msg.timestamp
try {
await deliver(msg.body);
msg.ack();
} catch (e) {
msg.retry({ delay: 30_000 }); // or msg.retry(): exponential backoff
}
}
},
};
A message the handler does not settle is acked when the handler returns and
retried when it throws or rejects (the throw is recorded in function.errors).
batch.ackAll() and batch.retryAll({ delay }) settle every message.
A consumer that returns a Response with a status of 400 or more fails the whole
batch regardless of how it settled individual messages. Every message is redelivered
under the queue's max_retries, then dead-lettered. A Rust consumer that returns
400 or more fails its batch in the same way, and its verdicts are not read.
Settings
Each queue takes these members (all optional, milliseconds where named _ms);
a member left out takes the default below.
| Member | Default | Meaning |
|---|---|---|
consumer |
none | the function to call; without one, messages wait |
batch_size |
10 | messages per consumer call |
max_wait_ms |
100 | how long the oldest ready message waits for a full batch |
max_retries |
3 | redeliveries after the first; one more failure makes it a dead letter |
retry_delay_ms |
1000 | first retry delay; each next one is multiplied by backoff_factor |
backoff_factor |
2 | multiplier per attempt |
max_retry_delay_ms |
3600000 | the longest retry delay backoff reaches |
visibility_timeout_ms |
30000 | how long a delivered message is hidden before it is delivered again |
concurrency |
4 | consumer calls of this queue in flight at once |
delivery_delay_ms |
0 | delay of a send that names none |
dead_letter_queue |
none | another declared queue that receives a message after max_retries |
A message past max_retries goes to the dead_letter_queue (as a new message
there), or, without one, stays in the queue's dead letters.
How it behaves
- A delivery hides the message for
visibility_timeout_ms. If the handler has not settled it by then (it is still running, or the server stopped), the message is delivered again withattemptsone higher; the first run's lateack()is ignored. - Messages in a burst remain in the database until a consumer takes them; none is refused or dropped. Each consumer call is a normal function call with the same limits and logs.
- A version's queues replace the app's declared queues when the version goes live. A queue removed by a later version retains its messages, but sends to it are refused until it is declared again.
- A sandbox has separate queues over its copy of the data.
Operations
queue.list, queue.stats, queue.purge, queue.dead_letters and
queue.retry_dead are on the HTTP API (/v1/_queues), the CLI and MCP:
queue.stats <name>:ready,in_flight,delayedanddeadcounts.queue.purge <name> [scope]:pending(default),deadorall.queue.dead_letters <name>: page the dead letters withlimitandcursor.queue.retry_dead <name> [seq]: send one or every dead letter back with its attempts reset.