tutorials
Send scheduled email digests with Cloudflare Cron Triggers
July 10, 2026

You want a daily "here's what happened" email to go out at 8am without standing up a cron box or a worker queue on some other cloud. Cloudflare Workers can do this, but the scheduler has sharp edges: UTC-only timing, no exactly-once guarantee, and a handler that will quietly time out if you send inline.
A digest email is deceptively simple to describe and annoying to run. The logic ("gather the last day's activity, format it, mail it") is trivial. The hard part is the recurring, reliable, time-zone-correct trigger, and a Worker has no built-in scheduler the way a Linux box has crontab. Cloudflare's answer is Cron Triggers, and they are good, but they behave differently from a unix cron in ways that will bite you if you assume otherwise.
Let's build a real daily digest from scratch on Cron Triggers, including the parts the quickstart skips: time zones, overlap, idempotency, and not blowing your CPU budget. Then, at the end, the shortcut if you'd rather not own the scheduling glue.
How do I run a scheduled job on Cloudflare Workers?
Add a triggers.crons entry to wrangler.jsonc and export a scheduled() handler. Cloudflare invokes that handler on the schedule you declare. There is no separate cron daemon, no always-on process, and nothing to keep alive: the Worker is dormant until the trigger fires, then runs once and stops.
Here is the smallest possible scheduled Worker:
{
"name": "digest-mailer",
"main": "src/index.ts",
"compatibility_date": "2026-06-01",
"triggers": {
// Standard 5-field cron. THIS IS UTC. Always.
"crons": ["0 8 * * *"], // 08:00 UTC every day
},
}
// src/index.ts
interface Env {}
export default {
async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
console.log("cron fired at", new Date(event.scheduledTime).toISOString());
// ...do the work
},
} satisfies ExportedHandler<Env>;
The event.scheduledTime is a millisecond timestamp of the scheduled fire time, not the actual execution time. Use it, not Date.now(), as the anchor for "what period does this digest cover." If a run is delayed by a few seconds (it can be), anchoring on scheduledTime keeps your windows aligned and non-overlapping.
Deploy and the trigger registers automatically:
npx wrangler deploy
Uploaded digest-mailer (1.2 sec)
Deployed digest-mailer triggers (0.4 sec)
schedule: 0 8 * * * (UTC)
Current Version ID: 8f2c...-...
That (UTC) in the output is the whole ballgame, and the first thing people get wrong.
What time zone do Cloudflare Cron Triggers use?
UTC, always, with no setting to change it. A cron of 0 8 * * * fires at 08:00 UTC, which is 8am in London during winter, 9am in London during summer, midnight in California, and various other hours everywhere else. Cloudflare does not localize, and it does not observe daylight saving time.
This matters for digests because "8am" is a product decision, not 08:00 UTC. You have two honest options:
Option A: pick a fixed UTC time and accept it drifts with DST for your readers. Fine for an internal tool, sloppy for a customer-facing newsletter. If most of your readers are in America/New_York and you want roughly 8am Eastern, schedule 0 12 * * * (noon UTC ≈ 8am EDT in summer) and shrug at the winter hour.
Option B: run the cron hourly and decide per-recipient. This is the correct approach for a real audience across time zones. Fire every hour, and inside the handler send only to recipients whose local hour is currently your target hour.
{
"triggers": {
"crons": ["0 * * * *"], // top of every hour, UTC
},
}
// Send a digest at the recipient's local 8am, using their stored IANA zone.
function localHour(timeZone: string, atUtc: number): number {
// Intl is available in the Workers runtime.
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "numeric",
hour12: false,
});
// formatToParts avoids "24" vs "0" ambiguity across runtimes.
const hourPart = fmt.formatToParts(new Date(atUtc)).find((p) => p.type === "hour");
return Number(hourPart?.value ?? "-1") % 24;
}
// In the handler:
const TARGET_LOCAL_HOUR = 8;
const dueNow = recipients.filter(
(r) => localHour(r.timeZone, event.scheduledTime) === TARGET_LOCAL_HOUR,
);
Store an IANA time zone string ("America/New_York", "Europe/Berlin") per recipient, not a UTC offset, because offsets change twice a year and IANA names encode the DST rules. The Intl.DateTimeFormat API in the Workers runtime handles the conversion, so you do not need a time-zone library.
How do I build the digest itself?
Query the period's activity from your database, grouped by recipient, then render one email per recipient. The trigger is the hard part; the content is a normal query and a template. Assume you have a D1 table of events you want to digest.
interface Env {
DB: D1Database;
EMAIL_SEND: Queue<DigestMessage>;
}
interface DigestMessage {
to: string;
subject: string;
html: string;
periodKey: string; // e.g. "2026-08-01", the day this digest covers
}
// Pull events from the last 24h, grouped by the user they belong to.
async function gatherDigests(env: Env, scheduledTime: number) {
const windowStart = scheduledTime - 24 * 60 * 60 * 1000;
const { results } = await env.DB.prepare(
`SELECT u.email AS email,
u.id AS user_id,
e.title AS title,
e.url AS url,
e.created_at AS created_at
FROM events e
JOIN users u ON u.id = e.user_id
WHERE e.created_at >= ?1
AND e.created_at < ?2
AND u.digest_enabled = 1
ORDER BY u.id, e.created_at DESC`,
)
.bind(windowStart, scheduledTime)
.all<{ email: string; user_id: string; title: string; url: string; created_at: number }>();
// Group rows into one digest per user.
const byUser = new Map<string, { email: string; items: typeof results }>();
for (const row of results) {
const bucket = byUser.get(row.user_id) ?? { email: row.email, items: [] as typeof results };
bucket.items.push(row);
byUser.set(row.user_id, bucket);
}
return byUser;
}
function renderDigest(items: { title: string; url: string }[]): string {
const rows = items
.map((i) => `<li><a href="${i.url}">${escapeHtml(i.title)}</a></li>`)
.join("");
return `<h1>Your daily digest</h1><ul>${rows}</ul>
<p><a href="https://yourdomain.com/settings/email">Manage your digest</a></p>`;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
);
}
Two things people skip and regret: escape user-supplied content (titles, names) before it goes into HTML email, and only include recipients who actually have activity. Mailing an empty "here's what happened (nothing)" digest trains people to unsubscribe.
Why should I not send the emails inside the scheduled handler?
Because a scheduled handler has a wall-clock limit and the sends are slow and failure-prone, so doing them inline turns one flaky recipient into a failed batch. The handler should decide what to send and hand the actual delivery to a queue. The cron run then finishes in well under a second regardless of list size.
Here is the wrong version first, because it works in dev and dies in production:
// DON'T: send inline in the cron handler.
async scheduled(event, env) {
const digests = await gatherDigests(env, event.scheduledTime);
for (const { email, items } of digests.values()) {
// 50-200ms each, sequentially, with no retry. 500 recipients = minutes,
// and one timeout kills the rest of the loop.
await fetch("https://api.delivery.example/send", {
method: "POST",
body: JSON.stringify({ to: email, html: renderDigest(items) }),
});
}
}
At a few hundred recipients this either blows the handler's time budget or loses the tail of the list when one send hangs. The fix is to enqueue:
export default {
async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
const periodKey = new Date(event.scheduledTime).toISOString().slice(0, 10); // YYYY-MM-DD
const digests = await gatherDigests(env, event.scheduledTime);
const messages: DigestMessage[] = [];
for (const { email, items } of digests.values()) {
if (items.length === 0) continue; // skip empty digests
messages.push({
to: email,
subject: `Your digest for ${periodKey}`,
html: renderDigest(items),
periodKey,
});
}
// Enqueue in batches of up to 100; the consumer does the slow sends.
for (let i = 0; i < messages.length; i += 100) {
await env.EMAIL_SEND.sendBatch(
messages.slice(i, i + 100).map((body) => ({ body })),
);
}
console.log(`enqueued ${messages.length} digests for ${periodKey}`);
},
} satisfies ExportedHandler<Env>;
The consumer that drains EMAIL_SEND, with retries, backoff, and a dead-letter queue, is the same pipeline covered in the Queues guide, and the queue patterns roundup catalogs the batching and retry shapes worth reusing. The scheduled handler's only job is to be fast and correct about what to send. A digest is just one job a scheduler can drive; the other ways Cloudflare Cron Triggers can run your email are worth a look once this one is working.
How do I stop a double-fire from double-sending?
Record which period each recipient was sent, and skip them if that period is already marked. Cron Triggers are at-least-once, not exactly-once: an invocation can occasionally overlap a still-running previous run, or fire twice around a deploy. If your handler is not idempotent, that becomes a duplicate digest in someone's inbox.
The cheapest guard is a small "sent" table keyed by recipient and period:
async function notYetSent(env: Env, userId: string, periodKey: string): Promise<boolean> {
// INSERT OR IGNORE returns meta.changes = 1 only if the row was new.
const res = await env.DB.prepare(
`INSERT OR IGNORE INTO digest_sends (user_id, period_key, sent_at)
VALUES (?1, ?2, ?3)`,
)
.bind(userId, periodKey, Date.now())
.run();
return res.meta.changes === 1; // true = we claimed it, go ahead and send
}
Call notYetSent before enqueuing each recipient. Because the (user_id, period_key) pair is a unique key, a second concurrent run inserts nothing and gets changes === 0, so it enqueues nobody. This is a claim-then-send pattern: claim the slot in D1 first, and only enqueue if the claim was yours.
-- migration
CREATE TABLE digest_sends (
user_id TEXT NOT NULL,
period_key TEXT NOT NULL,
sent_at INTEGER NOT NULL,
PRIMARY KEY (user_id, period_key)
);
One subtlety: if you claim the slot but the enqueue fails, you have marked it sent without sending. For a daily digest that is usually acceptable (a missed digest is low-stakes), but if it is not, claim in the queue consumer after a successful send instead, and pass periodKey through the message so the consumer can dedupe there.
How do I test a Cron Trigger without waiting for 8am?
Run wrangler dev and curl the magic scheduled endpoint, which invokes your handler immediately. You do not have to wait for the real schedule, change your clock, or deploy.
npx wrangler dev
# In another terminal: fire the scheduled handler now.
curl "http://localhost:8787/cdn-cgi/handler/scheduled"
[wrangler] Running scheduled event...
enqueued 12 digests for 2026-08-01
If you have multiple cron expressions and want to simulate a specific one, pass it through:
curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=0+*+*+*+*"
For a real deployed Worker, you can also trigger a run on demand from the dashboard, or just check the cron invocation logs under the Worker's Triggers tab to confirm it fired. If a deployed cron silently never runs, the usual cause is that triggers.crons was added but the Worker was never re-deployed, so Cloudflare never registered the schedule. Re-run wrangler deploy and confirm the schedule: line in the output.
Common failure modes
The digest went out at the wrong hour after the clocks changed
You scheduled a fixed UTC cron and assumed it tracked your local time. UTC does not observe DST. Either accept the seasonal drift or move to the hourly-cron-plus-per-recipient-local-hour approach above.
Some recipients got two digests
Your handler is not idempotent and the trigger fired twice (overlap or a deploy-time double-fire). Add the digest_sends claim table and check it before enqueuing.
The cron handler timed out and only half the list went out
You were sending inline. Move the sends to a queue; the scheduled handler should only gather and enqueue.
Cron is configured but never fires
The trigger was added to wrangler.jsonc but the Worker was not redeployed, so the schedule was never registered. Run wrangler deploy and look for the schedule line in the output. Also confirm the expression is a valid 5-field cron in UTC.
The shortcut: scheduled campaigns in Tinbox
Everything above is the right architecture, and it is also a fair amount to own: a UTC-aware schedule, a query, a renderer, a queue and consumer, idempotency, and per-recipient time-zone logic. If you would rather not maintain the scheduling glue, this is exactly what Tinbox handles, running in your own Cloudflare account.
npx tinbox init yourdomain.com
Tinbox deploys the cron-backed scheduler, the send queue and consumer, and the recipient store with per-subscriber time zones into your own D1 and Queues. A scheduled digest or campaign becomes a definition rather than plumbing:
import { tinbox } from "tinbox/cf";
// Define a recurring digest; Tinbox owns the cron, queue, and per-recipient timing.
await tinbox(env).campaigns.schedule({
name: "daily-digest",
sendAt: { localHour: 8 }, // 8am in each subscriber's own time zone
audience: "digest-enabled",
render: async ({ subscriber, since }) => ({
subject: "Your daily digest",
html: await buildDigestHtml(env, subscriber.id, since),
}),
});
The actual delivery still happens over HTTPS in the consumer Tinbox deploys, because Cloudflare Workers cannot open outbound port 25 for direct-to-MX SMTP, which is why edge email needs an HTTPS-based sender in the first place. See why edge email needs an HTTPS sender for the underlying reason, and the cost math at volume for why owning the scheduler beats paying per email to a SaaS that runs the same cron for you. If you are weighing owning this against a hosted tool, here is an honest comparison.
Deploy this yourself: npx tinbox init yourdomain.com. The full cron and campaign-scheduling configuration is in the Tinbox docs.
Frequently asked questions
How do I run a scheduled job on Cloudflare Workers?
Add a triggers.crons array to wrangler.jsonc with a standard cron expression, then export an async scheduled(event, env, ctx) handler from your Worker. Cloudflare invokes that handler on the schedule. There is no separate cron service to run; the Worker itself is the scheduler.
What time zone do Cloudflare Cron Triggers use?
Cron Triggers always evaluate in UTC. There is no time-zone setting. To send a digest at 8am in a recipient's local time, you either schedule the cron at the UTC equivalent of your target zone, or run the cron hourly and inside the handler send only to recipients whose local hour is currently 8.
Are Cloudflare Cron Triggers exactly-once?
No. A scheduled invocation is at-least-once and can occasionally overlap with a previous run that is still executing, or fire close to but not exactly on the minute. Make the handler idempotent: record what period a digest covered and skip recipients who already received that period's digest, so a double-fire does not double-send.
How long can a Cloudflare scheduled() handler run?
A scheduled handler gets up to 15 minutes of wall-clock time and the standard CPU limits, which is generous but finite. For a large list, do not send inline in the handler. Query the work, enqueue one message per recipient onto a Cloudflare Queue, and let the consumer do the slow HTTPS sends so the cron handler returns fast.
How do I test a Cron Trigger locally without waiting for the schedule?
Run wrangler dev and hit the magic endpoint http://localhost:8787/cdn-cgi/handler/scheduled with curl. That invokes your scheduled() handler immediately. You can also pass ?cron=... to simulate a specific cron expression when you have multiple triggers.


