Background jobs
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 jobs are for when. Background jobs are for how much.
Section titled “Scheduled jobs are for when. Background jobs are 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, handed over as the envelope's "data" |
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 { data: 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 });});Every /tasks/ route — queued, scheduled or a flow step — is handed the same envelope:
{ "from": { "kind": "queue", "name": "import-row" }, "at": 1787012590380, "user": "u_42", "data": {} }data is what was queued. from names what triggered the run, at is when it fired, and user
is who it belongs to — null for a job that belongs to the app rather than a person.
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.
Background jobs are 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.
The work runs as whoever asked for it
Section titled “The work runs as whoever asked for it”There is no cookie on the far side, but the run is not a stranger. The message carries the
partition of whoever called queue(), and env.PLATFORM.fetch acts as that person — so a
user-scoped collection is writable from a queued route without opening it to the world.
That is user in the envelope. Read it when the route needs to name the person; you do not need
to pass an id in data just to write their own records.
A job declared by the app rather than a person — a schedule.json cron — has user: null and
acts as the app itself. It can write shared collections, and a user-scoped write from one
lands nowhere useful, which is why a declared put must name a shared collection.
A /tasks/ route can queue more work, and that is how you fan out: one message reads a file
and queues one message per row. Never queue the job that is running — a job that queues itself
spins until the rate limit stops it.
/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 is created when you turn Background jobs on. Queues is included on the Workers Free plan, with 10,000 operations a day and a 24-hour retention ceiling; Workers Paid raises both. Cloudflare bills your account per message.