Skip to content

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.

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, 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.

// server.mjs — where the message lands
app.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.

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.

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.

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 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.