Skip to content

Queue

Five hundred rows to run through AI. A hundred emails to send. An upload to process. None of that fits in the time a person will wait, and a Worker is cut off long before it finishes anyway.

A queue is the answer. Your app hands the work over and answers immediately; Cloudflare gives each message back to your app’s own /tasks/ route, a few at a time, and retries the ones that fail.

Scheduled is for when. Queue is for how much.

Section titled “Scheduled is for when. Queue is for how much.”

A nightly digest is a scheduled job. A thousand things to get through right now is a queue. Both arrive at a /tasks/ route, so one route can serve both.

The app doesn’t write that fetch. It imports queue, and the module calls the endpoint:

import { queue, QueueError } from "queue";
await queue("resize", { id: photo.id });

queue(job, data) hands one message over and resolves true the moment it is taken. The job name is the route name, so queue("resize", …) runs /tasks/resize in your server.mjs, and data — any JSON value — is the whole of what the far side receives. A refusal throws a QueueError carrying .status and the server’s own message; the job name and the message size are checked before the request is even sent.

Queue one item per message. A hundred small runs share the work out, where one big run would be cut off:

for (const row of rows) {
await queue("import-row", row);
}

The import calls exactly one endpoint, and it is the only way to queue from a page:

Method & path Body Returns
POST /api/_queue/:job {...anything} — the payload for one run 202 {"queued":true} — :job runs at /tasks/:job afterwards

That table is the truth for server.mjs, which is a lone module with no module graph and so cannot import queue. An ordinary route in it that wants to send a message calls the endpoint through env.PLATFORM.fetch instead.

// server.mjs — where the message lands
app.post("/tasks/import-row", async (c) => {
const row = await c.req.json();
await c.env.PLATFORM.fetch("/api/_data/rows", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(row),
});
return c.json({ ok: true });
});

Server code reaches a capability through env.PLATFORM.fetch, never a bare fetch — the server runs inside the app’s own worker, where a relative URL has no origin to resolve against. It takes the same /api/_… paths the browser uses and returns a normal Response.

Queue is the one capability that needs a server.mjs. Without a route for the message to reach, the app refuses to build.

The name in queue("import-row", …) and the name in app.post("/tasks/import-row", …) are the same string, and nothing checks that they match. A message whose job has no route reaches server.mjs, matches nothing, and 404s — and a 404 counts as done. Nothing retries, nothing is logged, and the work simply never happens.

From the browser a misspelt job looks exactly like success, so write the route first.

A queued run has no session and no cookie. Anything the work needs to know about a person must travel in the message — their id, the record id, the address to write to. /api/me is empty there.

That also means a /tasks/ route cannot queue more work: env.PLATFORM.fetch carries the caller’s session, a queued run has none, and /api/_queue answers 401 to it. Fan out from the browser, or from an ordinary request — never from the far side of one.

/tasks/ routes are not reachable from a browser at all. They answer 403 to anything that is not the platform handing over a queued or scheduled run.

A failure is retried, so write the route to survive running twice

Section titled “A failure is retried, so write the route to survive running twice”

Answer 2xx and the message is done. Throw, or answer 5xx, and it comes back — up to 3 times. Anything else — a 400, a 404 — is taken as done and dropped.

That makes a PUT with an id you derive from the message the right write, and a blind POST the wrong one, because a retry would leave two records. After the last attempt the message is dropped and the failure lands in your app’s errors, so make the route say what it was working on when it threw.

Fire and forget, and the person must see that

Section titled “Fire and forget, and the person must see that”

queue() resolving means taken, not done. There is no way to ask whether a message finished.

If the person needs to know, have the queued route write the result to a Data collection and show them that — a row that goes working then ready is the pattern. Never claim the work is finished on the strength of the call having resolved.

A message is at most 100,000 bytes, and a caller may queue 120 a minute. Put the id of a big thing in the message, never the thing itself. queue() throws a QueueError for either — .status is 413 and 429 — so catch it around a loop and say what stopped, rather than firing a thousand messages at a wall.

Queueing needs a signed-in caller: an app with Sign-in off gets a 401.

A real Cloudflare Queue in your own account, created when you turn the capability on. Queues needs the Workers Paid plan — without it, turning Queue on tells you so rather than half-working. Cloudflare bills your account per message.