Background jobs on Ploy: queues, durable workflows, and cron

How to run serverless background jobs on Ploy: queues with retries for work that can wait, durable workflows whose steps survive restarts for work that must finish, and cron triggers for work on a schedule, all declared in ploy.yaml.

Abstract indigo glow on a dark background representing queues and durable workflows on Ploy

Serverless is great until something takes longer than a request. Sending a receipt, resizing an upload, charging a card and then shipping an order, or cleaning up every night: none of that belongs in the HTTP handler, and "just run it in the background" has no obvious meaning when there is no server.

Ploy gives a serverless worker three ways to run background jobs, each declared in ploy.yaml and handled in the same codebase: queues for work that can wait and may be retried, durable workflows for multi-step work that must finish, and cron triggers for work on a schedule. This post shows when to use which, with the real APIs.

The three tools

Need Use Declared as
Do this soon, retry if it fails, order does not matter Queue queue:
Do these steps in order and never lose progress Workflow workflow:
Do this every hour / day / weekday morning Cron cron:
kind: worker
build: pnpm build
out: dist
db:
DB: default
queue:
TASKS: tasks
workflow:
ORDER_FLOW:
name: order_processing
retries: 5
cron:
HOURLY_CLEANUP: "0 * * * *"

ploy types generates the bindings, and ploy dev runs all three locally with a dashboard at http://localhost:4000 to inspect messages, step results, and cron history.

Queues: work that can wait

Send from any handler; messages are stored durably and delivered at least once:

const { messageId } = await env.TASKS.send(
{ task: "send-receipt", orderId },
{ delaySeconds: 60 },
);
await env.TASKS.sendBatch([
{ payload: { task: "resize", uploadId: "a" } },
{ payload: { task: "resize", uploadId: "b" } },
]);

Handle with a message export. A handler that throws is retried with exponential backoff:

export default {
async fetch() {
return new Response("ok");
},
async message(event, env) {
const payload = event.payload as { task: string; orderId: string };
if (payload.task === "send-receipt") {
await sendReceipt(env, payload.orderId);
}
console.log("done", event.id, "attempt", event.attempt);
},
} satisfies Ploy;

With @meetploy/start, .queue("TASKS", { payload: zodSchema }, handler) validates every payload and gives you ctx.ack(), ctx.retry(delayMs), and ctx.deadLetter(reason) for explicit control. Because delivery is at least once, make handlers idempotent: key side effects on orderId, not on the message.

Durable workflows: work that must finish

A workflow is a function whose steps are persisted. If the worker restarts after step 2, steps 1 and 2 are replayed from storage and execution resumes at step 3. Failed steps are retried (3 times by default; the retries: 5 above raises it for this workflow, and each step can override it).

export default {
async fetch(request, env) {
const { executionId } = await env.ORDER_FLOW.trigger({
orderId: "ord_123",
amount: 4900,
});
return Response.json({ executionId });
},
workflows: {
order_processing: {
handler: async ({
input,
env,
step,
}: WorkflowContext<PloyEnv, { orderId: string; amount: number }>) => {
const payment = await step.run("charge", async () => {
return await chargeCard(input.orderId, input.amount);
});
await step.run("ship", async () => {
await createShipment(input.orderId);
});
await step.run(
"record",
async () => {
await env.DB.prepare(
"UPDATE orders SET status = 'shipped', payment_id = ? WHERE id = ?",
)
.bind(payment.id, input.orderId)
.run();
},
{ retries: 10 },
);
return { paymentId: payment.id };
},
onError: async (error, { env }) => {
console.error("order_processing failed:", error.message);
},
},
},
} satisfies Ploy;

env.ORDER_FLOW.getExecution(executionId) returns status (running, completed, failed) and the output; cancel(executionId) stops a run. The onError hook runs before the execution is marked failed, which is the place to release a lock or alert someone.

step.sleep() is durable too. With @meetploy/start you can write ctx.step.sleep("24h") and send a follow-up the next day without anything running in between, and ctx.step.parallel() fans out independent steps.

Step names must be unique within a run, because the name is what lets a replay skip completed work.

Cron: work on a schedule

Each key under cron: is a trigger name and each value a cron expression. Five fields is standard; a sixth leading field adds seconds for sub-minute schedules (*/10 * * * * *).

export default {
async scheduled(event, env) {
if (event.cron === "0 * * * *") {
const stale = await env.DB.prepare(
"SELECT id FROM uploads WHERE processed = 0 AND created_at < ?",
)
.bind(Date.now() - 3_600_000)
.all<{ id: string }>();
for (const row of stale.results) {
await env.TASKS.send({ task: "resize", uploadId: row.id });
}
}
},
} satisfies Ploy;

The scheduled handler gets the same env as fetch, so a cron can query the database and fan work out to a queue, which is the usual pattern for "find everything that needs doing, then do each one with retries".

Picking the right one

  • Queue when each job is independent and a retry is harmless: emails, thumbnails, webhooks, indexing.
  • Workflow when the order matters and partial completion is dangerous: charge then ship, provision then notify, anything with a human-visible side effect per step.
  • Cron when the trigger is time, not an event. Cron plus queue is the standard combination for batch jobs.

All three are included on every plan. Each invocation has a CPU-time budget that depends on the plan: on Free, 5 s per queue message, 10 s per cron run, and 15 s per workflow execution across all its steps; Pro and Enterprise raise these, and self-hosted instances set their own. Time spent waiting on fetch() or the database does not count.

Frequently Asked Questions

Do queues and workflows work in Next.js?

Yes. Declare them in ploy.yaml, send from route handlers with getPloyContext().env, and export message, scheduled, and workflows from app/ploy.ts. See Deploy Next.js on Ploy.

How do I test a workflow locally?

ploy dev runs the workflow engine in the emulator. Trigger it from a route, then open the dashboard to see each step's input, output, retries, and timing.

What is the difference between a queue retry and a workflow retry?

A queue retries the whole message. A workflow retries a single step while keeping the results of earlier steps, so a flaky third step does not re-run a successful charge.

Can a cron run longer than one request?

Cron has its own CPU budget (10 s on Free, 60 s on Pro, 120 s on Enterprise). For long batches, have the cron enqueue items and let the queue handler do the work.

Get started