The previous three chapters were reactive — make reads cheap (Ch 1), bound writes (Ch 2), receive events safely (Ch 3). This chapter is the proactive side: work you initiate. Two cases cover almost all of it:
- Time-based work. "Every night at 03:00 UTC, delete sessions older than 30 days." "Every hour, recompute the leaderboard." Solved by Workers Cron Triggers.
- Async work after a request. "User just signed up → send welcome email + provision sandbox + notify Slack." You don't want to do all that inside the signup request and risk a 30‑second response. Solved by Workers Queues.
By the end you'll have wired both, with retries, batching, and dead-letter handling — and you'll understand the bill (spoiler: under $1/month for any normal indie app).
Cron Triggers — Schedule a Worker
A Cron Trigger fires a special handler on your Worker on a cron schedule. Zero infrastructure to manage; you just declare the schedule and the function.
Configuration
# wrangler.toml
name = "my-worker"
main = "src/index.js"
compatibility_date = "2026-05-30"
[triggers]
crons = [
"0 3 * * *", # daily at 03:00 UTC
"*/15 * * * *", # every 15 minutes
"0 9 * * MON", # 09:00 UTC every Monday
]The scheduled() handler
// src/index.js
export default {
// Normal HTTP handler.
async fetch(req, env) {
return new Response("hello");
},
// Cron handler. `controller.cron` tells you which schedule triggered.
async scheduled(controller, env, ctx) {
switch (controller.cron) {
case "0 3 * * *":
ctx.waitUntil(runNightlyCleanup(env));
break;
case "*/15 * * * *":
ctx.waitUntil(refreshLeaderboard(env));
break;
case "0 9 * * MON":
ctx.waitUntil(sendWeeklyDigest(env));
break;
}
},
};The same Worker can have both an HTTP handler and a scheduled handler. Each cron invocation gets its own ctx so waitUntil works exactly like in a fetch handler.
| Pattern | Cron expression |
|---|---|
| Every minute | * * * * * |
| Every 5 minutes | */5 * * * * |
| Hourly on the hour | 0 * * * * |
| Daily at 03:00 UTC | 0 3 * * * |
| Mondays at 09:00 UTC | 0 9 * * MON |
| First of the month | 0 0 1 * * |
Cost
Cron invocations count as regular Worker requests (Workers Free: 100k/day; Workers Paid: $5/month + included quota). For typical schedules — a handful of crons running a few times a day — this is rounding error: 24 hourly fires × 30 days = 720 requests/month. You'll never notice it on the bill.
Workers Queues — Async Fan-Out With Retries
A queue is a buffered channel between a producer (your fetch handler) and a consumer (another Worker that processes messages). The producer enqueues fast; the consumer takes its time.
Figure 1 — A queue decouples "this work needs to happen" from "this request must finish." The HTTP request returns the moment the producer's send completes; everything else runs in the background, with retries, batching, and dead-letter handling built in.
Configuration
# wrangler.toml
# 1) Producer binding — any Worker that sends messages.
[[queues.producers]]
binding = "EMAIL_Q"
queue = "email-jobs"
# 2) Consumer — a Worker that processes them. Often the same Worker.
[[queues.consumers]]
queue = "email-jobs"
max_batch_size = 10 # default
max_batch_timeout = 5 # seconds — flush partial batch
max_retries = 5
dead_letter_queue = "email-jobs-dlq"Producer (anywhere in your Worker)
// One message at a time.
await env.EMAIL_Q.send({ to: "u@example.com", kind: "welcome" });
// Or batched (cheaper, lower latency at producer).
await env.EMAIL_Q.sendBatch([
{ body: { to: "a@x.com", kind: "welcome" } },
{ body: { to: "b@x.com", kind: "welcome" } },
]);The producer can be your fetch handler, your scheduled handler, or a webhook receiver (Ch 3). All look the same.
Consumer
// src/index.js
export default {
async queue(batch, env, ctx) {
for (const msg of batch.messages) {
try {
await sendEmail(msg.body, env);
msg.ack(); // success — remove from queue
} catch (err) {
// Don't crash on a single bad message; retry just that one with backoff.
msg.retry({ delaySeconds: 60 });
}
}
},
};Two important behaviours:
- Batching: the consumer receives up to
max_batch_sizemessages at once. Inside the handler you decide per-message whether toack,retry, or let it default to retry. - Retries:
msg.retry()re-enqueues the message; aftermax_retriestotal attempts, the message lands in the dead-letter queue. You attach another consumer to the DLQ for alerting / manual replay.
Pricing — The Headline Numbers
(Verify on the Workers Queues pricing page; these are accurate as of mid‑2026.)
| Knob | Value |
|---|---|
| Free tier (operations / month) | 1,000,000 |
| After free tier | $0.40 per million ops |
| Operations per delivered message | ~3 (write + read + delete) |
| Throughput | 5,000 messages / second per queue |
| Max concurrent consumers | 250 |
| Default batch size | 10 |
| Message retention — Free plan | 24 hours (fixed) |
| Message retention — Paid plan | 60 s – 14 days (configurable) |
| Data egress charges | $0 — always |
A worked example: an app that enqueues 100,000 messages per month. 100,000 × 3 ops = 300,000 ops — comfortably under the 1M free tier. Cost: $0. Even at 1 million delivered messages per month you'd hit 3M ops, pay for 2M past the freebie, and the bill would be $0.80.
End-to-End: Nightly Cron → Queue → Consumer
A complete pattern that uses both primitives — exactly the shape a real SaaS uses to send "your subscription expires in 7 days" reminders:
# wrangler.toml
[triggers]
crons = ["0 9 * * *"] # daily at 09:00 UTC
[[queues.producers]]
binding = "REMINDERS"
queue = "subscription-reminders"
[[queues.consumers]]
queue = "subscription-reminders"
max_batch_size = 10
max_retries = 5
dead_letter_queue = "subscription-reminders-dlq"// src/index.js
export default {
// 1) Daily cron — finds users whose subs expire in 7 days, fans out one
// message per user.
async scheduled(controller, env, ctx) {
ctx.waitUntil((async () => {
const targetDate = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7;
const rows = await env.DB.prepare(`
SELECT s.id, s.user_id, u.email
FROM subscriptions s JOIN users u ON s.user_id = u.id
WHERE s.status = 'active'
AND s.current_period_end BETWEEN ? AND ?
`).bind(targetDate - 3600, targetDate + 3600).all();
// Batch into chunks of 100 so sendBatch stays small.
for (let i = 0; i < rows.results.length; i += 100) {
await env.REMINDERS.sendBatch(
rows.results.slice(i, i + 100).map(r => ({
body: { userId: r.user_id, email: r.email, kind: "expires_in_7d" },
}))
);
}
})());
},
// 2) Consumer — sends the actual emails, retries transient failures.
async queue(batch, env, ctx) {
for (const msg of batch.messages) {
try {
await sendReminderEmail(msg.body, env);
msg.ack();
} catch (err) {
// Transient — retry with backoff.
if (msg.attempts < 3) {
msg.retry({ delaySeconds: 60 * msg.attempts });
} else {
// Final fail → falls into the DLQ automatically after max_retries.
msg.retry();
}
}
}
},
};That's a real "expiring subscription reminder" pipeline in 30 lines of Worker code, no servers to run, and the monthly bill is under a dollar at any small-product scale.
Cron vs Queue vs Both — Decision Table
| Need | Use |
|---|---|
| Runs on a schedule, work fits in one Worker invocation | Cron only (e.g. "delete expired sessions nightly") |
| Runs on a schedule, work fans out to many items | Cron + Queue (the example above) |
| Triggered by a user action, work too slow for the request | Queue from fetch (e.g. signup → enqueue welcome email + provisioning) |
| Triggered by a webhook, work too slow for the 2xx | Queue from webhook receiver (Ch 3) |
| Synchronous, must finish before responding | Neither — keep it in the request, possibly with ctx.waitUntil for the tail. |
Pattern: cron is the scheduler, queues are the executor. They compose perfectly.
Mental Model — Three Sentences
- Cron Triggers let a Worker run on a schedule (
scheduled()handler, UTC cron expressions inwrangler.toml) and cost essentially nothing — they're the right answer for "every night," "every hour," "every Monday." - Workers Queues decouple "this work needs to happen" from "this HTTP request must finish" — the producer enqueues in milliseconds; the consumer processes in batches with built-in retries, exponential backoff, and a dead-letter queue.
- The bill stays small — 1M ops/month free, ~3 ops per delivered message, $0.40 per additional million; an indie app moving 100k jobs/month pays $0 and inherits a production-grade retry + DLQ system.
Try It Yourself (15 Minutes)
- Add a Cron Trigger to a test Worker firing every minute. Implement
scheduled()to log to a D1 table. Watch the rows pile up — that's your scheduler working with zero servers. - Create a queue (
wrangler queues create my-q) plus a dead-letter queue (my-q-dlq). Add producer + consumer bindings. - From your
fetchhandler,await env.MY_Q.send({ test: true })on every request. From the consumer,console.log(msg.body)thenmsg.ack(). Hit the URL a few times; watch the consumer logs. - Throw inside the consumer on a specific message body. Confirm
msg.retry()re-enqueues with backoff, and aftermax_retriesthe message lands in the DLQ. - Build the nightly-reminder pipeline from this chapter against fake D1 data. Confirm a single cron fire enqueues N messages and the consumer processes them in batches of 10.
Where This Lands in the Series
That closes the Production Web Apps series. You can now:
- Make reads cheap (Ch 1 — the four caches).
- Bound writes (Ch 2 — rate limiting + abuse defence).
- Receive events safely (Ch 3 — HMAC + idempotency + dead-letter).
- Run scheduled and async work (Ch 4 — Cron Triggers + Queues — this chapter).
Combined with the foundational chapters on JWT sessions, KV, D1, R2, and the CI cost chapter, you've got the complete production-on-Cloudflare playbook: build, ship, defend, scale — and keep the monthly bill under what you'd pay for a single weeknight dinner.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper