Queues and background jobs

Send a message from a function to a named queue and have another function consume it in the background.

Docs menu

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 with attempts one higher; the first run's late ack() 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, delayed and dead counts.
  • queue.purge <name> [scope]: pending (default), dead or all.
  • queue.dead_letters <name>: page the dead letters with limit and cursor.
  • queue.retry_dead <name> [seq]: send one or every dead letter back with its attempts reset.