Skip to content
runlot

Queues

Declare `queues` in runlot.json; your worker sends with `env.<BINDING>.send` and drains with a `queue()` handler. Messages live in your project's database.

Add queues to runlot.json. A producer gives your worker a binding to send with; a consumer names a queue your worker's queue handler drains.

runlot.json
{
  "name": "my-app",
  "main": "src/index.ts",
  "queues": {
    "producers": [{ "binding": "JOBS", "queue": "jobs" }],
    "consumers": [
      { "queue": "jobs", "maxBatchSize": 10, "maxBatchTimeout": 5, "maxRetries": 3, "deadLetterQueue": "jobs-dead" }
    ]
  }
}
src/index.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    await env.JOBS.send({ url: new URL(request.url).searchParams.get("u") });
    return new Response("queued");
  },
  async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {
    for (const message of batch.messages) {
      try {
        await crawl(message.body, env);
        message.ack();
      } catch {
        message.retry({ delaySeconds: 60 });
      }
    }
  },
};

main and database are both required: the messages are rows in your project's database, in the runlot_queue schema, where you can see them with runlot pg shell. They are inside your database's size cap and they come back with a restore.

runlot deploy prints the queues it is about to register.

Queue producers: env.JOBS → jobs
Queue consumers: jobs

Sending

env.<BINDING>.send(body, options?) enqueues one message; sendBatch([{ body, ...options }]) enqueues several in one transaction.

Option
contentTypejson (default), text, or bytes (an ArrayBuffer or typed array). A json body must be JSON.stringify-able.
delaySecondsHow long before the message becomes deliverable. A delayed message does not jump the queue when its delay ends.

A message is at most 128 KiB serialized. Past the daily operation cap (below) send throws a QueueLimitError with code: "queue_ops_cap" — tell it apart from a transport failure by the name: one is worth retrying, the other is not.

Receiving

queue(batch, env, ctx) receives up to maxBatchSize messages of one queue. Each message has id, timestamp (when it was enqueued), body, and attempts (this delivery counted).

message.ack()Done with it.
message.retry({ delaySeconds? })Deliver it again, after the delay you name or the backoff below.
batch.ackAll() / batch.retryAll({ delaySeconds? })The same for every message in the batch.

A handler that returns without throwing acks every message it did not explicitly retry. That is the whole reason the simplest correct consumer is a loop with no ack() call in it. A handler that throws retries the entire batch.

Batching

A batch is delivered when it reaches maxBatchSize messages or when maxBatchTimeout seconds have passed with a partial batch waiting, whichever comes first. Defaults are 10 messages and 5 seconds. The handler has maxBatchTimeout plus a 30 second grace to finish one batch; past that the batch is recorded as a timeout and its messages go back to the queue.

Retries and backoff

A retried message waits before it is delivered again: 10 seconds after the first failure, doubling each time, capped at one hour. Name a delaySeconds on retry() and that wins. After maxRetries redeliveries (default 3, at most 10) the message is out of retries.

The dead-letter queue

What happens to a message out of retries depends on one line in the consumer:

  • With deadLetterQueue, the message moves there — same id, same body, same enqueue time, attempts reset to 0. A dead-letter queue is an ordinary queue: it counts against your limit, it shows on the Queues tab, and you can consume it or redrive it.
  • Without one, the message is deleted.

To deliver exactly once and keep the failures, write maxRetries: 0 and a deadLetterQueue. maxRetries: 0 on its own is read as the default.

Delivery

At least once. A message is leased to your handler and only removed once the handler acks it. If the process dies mid-batch — a redeploy, a parked project, a crash — the lease expires and the messages are delivered again, without spending a retry. Write handlers that tolerate a second delivery.

No ordering guarantee. Messages are handed out oldest-visible first, but a retried or delayed message comes back later than its neighbours. Do not build on the order.

No deduplication. Two sends are two messages.

A project holding messages is not parked, and a parked project is woken to drain a message that becomes visible while it sleeps — a delayed message, or one left behind by a crash. The parked check runs every five minutes, so that is the most such a message waits.

Limits

FreePro
Queues per organization525
Queues per project (distinct names, dead-letter queues included)55
Operations per day, per project (a send is one, a delivery is one)10,0001,000,000
Message size128 KiB128 KiB
maxBatchSize1–1001–100
maxBatchTimeout1–60 seconds1–60 seconds
maxRetries0–100–10
Batch history30 days365 days

The daily cap resets at 00:00 UTC. It is per day rather than per month on purpose: the way a queue goes wrong is a loop — a consumer that sends into its own queue — and a daily bucket bounds that to one day's worth.

Watching it

The Queues tab of your project, next to Scheduled runs, shows each declared queue with how many messages are visible, delayed and in flight, how many sit in its dead-letter queue, and the last batch's outcome; below it, a table of recent batches: when each started, which queue, how it ended, how many messages were delivered, acked, retried and dead, and why if it did not run.

A failed or error batch three times in a row emails your organization's admins (queue_failed), once per queue per day. One failure is a retry working and is not worth a mail. At 90 % of the day's operations you get queue_ops_cap.

How a batch can end

SucceededYour handler ran and returned. Messages it retried are retries by its own request, not failures.
FailedYour handler threw, or the worker exports no queue. The reason shown is the runtime's own, such as exception.
ErrorThe batch never reached your handler — the worker could not be reached (no_socket, drive_failed), a parked project would not wake (wake_failed), it ran past the limit (timeout), or the database did not answer (db_failed). Ours, not yours.
SkippedThe project woke carrying a different consumer list than the one about to be driven (consumers_changed).

From the command line

runlot queue                    # every declared queue: depth, dead letters, last batch
runlot queue peek jobs          # the next messages, without taking them (--limit 10)
runlot queue redrive jobs-dead  # move dead letters back to jobs (--to when ambiguous)
runlot queue purge jobs         # delete every message in jobs

purge and redrive ask for confirmation, and refuse outside a terminal unless you pass --yes. Purge cannot be undone. Redrive resets each message's retry count, so a message that still fails goes through the full retry cycle again before it comes back.

Changing or removing a queue

The declaration travels with the code. Edit runlot.json and deploy; rolling back restores that version's queues. Removing a consumer stops delivery; the rows stay in your database until you purge them or deploy a consumer again.

What is not here

There is no cross-project queue: a name is scoped to the project's database. There is no env.queue in runlot dev yet — a consumer runs only after a deploy. A project in cold state is not polled; a delayed message under one waits until something else brings the project back.

On this page