Tutorials Production Web Apps Series Chapter 4

Cron Triggers + Workers Queues — Scheduled Work and Async Fan-Out

Production WebChapter 4 of the Production Web Apps Series26 minMay 30, 2026Intermediate

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:

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.

PatternCron expression
Every minute* * * * *
Every 5 minutes*/5 * * * *
Hourly on the hour0 * * * *
Daily at 03:00 UTC0 3 * * *
Mondays at 09:00 UTC0 9 * * MON
First of the month0 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.

Loading diagram…

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:

Pricing — The Headline Numbers

(Verify on the Workers Queues pricing page; these are accurate as of mid‑2026.)

KnobValue
Free tier (operations / month)1,000,000
After free tier$0.40 per million ops
Operations per delivered message~3 (write + read + delete)
Throughput5,000 messages / second per queue
Max concurrent consumers250
Default batch size10
Message retention — Free plan24 hours (fixed)
Message retention — Paid plan60 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

NeedUse
Runs on a schedule, work fits in one Worker invocationCron only (e.g. "delete expired sessions nightly")
Runs on a schedule, work fans out to many itemsCron + Queue (the example above)
Triggered by a user action, work too slow for the requestQueue from fetch (e.g. signup → enqueue welcome email + provisioning)
Triggered by a webhook, work too slow for the 2xxQueue from webhook receiver (Ch 3)
Synchronous, must finish before respondingNeither — 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

  1. Cron Triggers let a Worker run on a schedule (scheduled() handler, UTC cron expressions in wrangler.toml) and cost essentially nothing — they're the right answer for "every night," "every hour," "every Monday."
  2. 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.
  3. 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)

  1. 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.
  2. Create a queue (wrangler queues create my-q) plus a dead-letter queue (my-q-dlq). Add producer + consumer bindings.
  3. From your fetch handler, await env.MY_Q.send({ test: true }) on every request. From the consumer, console.log(msg.body) then msg.ack(). Hit the URL a few times; watch the consumer logs.
  4. Throw inside the consumer on a specific message body. Confirm msg.retry() re-enqueues with backoff, and after max_retries the message lands in the DLQ.
  5. 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:

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.

Ch 3: Webhooks That Don't Lose DataComing Soon →
WebUltimate Web Development SeriesWeb development tutorials for HTML, CSS, JavaScript, Next.js, Workers, databases, and production shipping.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.

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
5 free articles remainingSubscribe for unlimited access