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.
Sending a message
Section titled “Sending a message”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 endpoint
Section titled “The endpoint”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.
The far side
Section titled “The far side”// server.mjs — where the message landsapp.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.
A job with no route is dropped in silence
Section titled “A job with no route is dropped in silence”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.
Nobody is signed in on the far side
Section titled “Nobody is signed in on the far side”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.
Limits
Section titled “Limits”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.
What it runs on
Section titled “What it runs on”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.