| 1 | --- |
| 2 | title: Scheduled jobs with Cron Triggers |
| 3 | description: Run Worker code on a schedule — declare a cron expression with an Effect handler, deploy it, and prove the trigger fires with a bounded polling test. |
| 4 | sidebar: |
| 5 | order: 17 |
| 6 | --- |
| 7 | |
| 8 | Cron Triggers invoke a Worker on a schedule — nightly cleanups, |
| 9 | periodic syncs, heartbeats. In alchemy, a single call declares both |
| 10 | halves of a scheduled Worker: `Cloudflare.Workers.cron(expression, |
| 11 | handler)` attaches the cron expression to the Worker at deploy time |
| 12 | *and* registers the runtime `scheduled` listener that runs your |
| 13 | Effect on each fire. |
| 14 | |
| 15 | To run a [Workflow](/cloudflare/compute/workflows#schedule-a-workflow) |
| 16 | on a schedule, attach `schedules` to the Workflow itself instead of |
| 17 | wrapping `workflow.create()` in a `scheduled` handler. |
| 18 | |
| 19 | ## Declare a cron on the Worker |
| 20 | |
| 21 | Call `cron` in the Worker's Construction phase and provide |
| 22 | `CronEventSourceLive` as a layer: |
| 23 | |
| 24 | ```typescript |
| 25 | // src/worker.ts |
| 26 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 27 | import * as Effect from "effect/Effect"; |
| 28 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 29 | |
| 30 | export default Cloudflare.Worker( |
| 31 | "Worker", |
| 32 | { main: import.meta.url }, |
| 33 | Effect.gen(function* () { |
| 34 | yield* Cloudflare.Workers.cron("0 12 * * *", (controller) => |
| 35 | Effect.log(`scheduled at ${controller.scheduledTime}`), |
| 36 | ); |
| 37 | |
| 38 | return { |
| 39 | fetch: Effect.gen(function* () { |
| 40 | return HttpServerResponse.text("ok"); |
| 41 | }), |
| 42 | }; |
| 43 | }).pipe(Effect.provide(Cloudflare.Workers.CronEventSourceLive)), |
| 44 | ); |
| 45 | ``` |
| 46 | |
| 47 | At deploy time the call binds `0 12 * * *` onto the Worker's Cron |
| 48 | Triggers; at runtime it registers a `scheduled` listener that runs |
| 49 | your handler every day at noon UTC. The `controller` is Cloudflare's |
| 50 | `ScheduledController` — `controller.scheduledTime` is the fire time |
| 51 | and `controller.cron` is the expression that fired. |
| 52 | |
| 53 | :::note |
| 54 | A failing handler won't crash the Worker — the event source catches |
| 55 | the failure and moves on. If you need visibility into failed runs, |
| 56 | log or report the error inside the handler yourself. |
| 57 | ::: |
| 58 | |
| 59 | ## Multiple schedules |
| 60 | |
| 61 | Register `cron` once per schedule; each handler only runs for fires |
| 62 | of its own expression: |
| 63 | |
| 64 | ```typescript |
| 65 | yield* Cloudflare.Workers.cron("*/5 * * * *", () => syncFeeds); |
| 66 | yield* Cloudflare.Workers.cron("0 0 * * *", () => purgeExpired); |
| 67 | ``` |
| 68 | |
| 69 | The listener checks `controller.cron` against the expression it was |
| 70 | registered with, so a fire of `0 0 * * *` never runs the `*/5` |
| 71 | handler and vice versa. |
| 72 | |
| 73 | :::note |
| 74 | Workers whose `main` module exports its own `scheduled` handler can |
| 75 | attach schedules with the Worker's `crons` prop instead: |
| 76 | `{ main, crons: ["0 12 * * *"] }`. Pass an empty array to remove all |
| 77 | Cron Triggers. |
| 78 | ::: |
| 79 | |
| 80 | ## Deploy it |
| 81 | |
| 82 | Wire the Worker into a Stack and expose its URL and attached crons |
| 83 | as outputs: |
| 84 | |
| 85 | ```typescript |
| 86 | // alchemy.run.ts |
| 87 | import * as Alchemy from "alchemy"; |
| 88 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 89 | import * as Effect from "effect/Effect"; |
| 90 | import Worker from "./src/worker.ts"; |
| 91 | |
| 92 | export default Alchemy.Stack( |
| 93 | "CronApp", |
| 94 | { |
| 95 | providers: Cloudflare.providers(), |
| 96 | state: Cloudflare.state(), |
| 97 | }, |
| 98 | Effect.gen(function* () { |
| 99 | const worker = yield* Worker; |
| 100 | return { |
| 101 | url: worker.url.as<string>(), |
| 102 | crons: worker.crons, |
| 103 | }; |
| 104 | }), |
| 105 | ); |
| 106 | ``` |
| 107 | |
| 108 | `worker.crons` is an output attribute listing every cron expression |
| 109 | attached to the deployed Worker — the test below asserts against it. |
| 110 | |
| 111 | ```sh |
| 112 | bun alchemy deploy |
| 113 | ``` |
| 114 | |
| 115 | ## Test it: record each fire in a Durable Object |
| 116 | |
| 117 | Cloudflare's minimum cron granularity is one minute, so you can't |
| 118 | just sleep and assert — you need the handler to leave evidence you |
| 119 | can poll for. A Durable Object that records each `scheduledTime` is |
| 120 | perfect: |
| 121 | |
| 122 | ```typescript |
| 123 | // src/cron-counter.ts |
| 124 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 125 | import * as Effect from "effect/Effect"; |
| 126 | |
| 127 | export default class CronCounter extends Cloudflare.DurableObject<CronCounter>()( |
| 128 | "CronCounter", |
| 129 | Effect.gen(function* () { |
| 130 | const state = yield* Cloudflare.DurableObjectState; |
| 131 | return Effect.gen(function* () { |
| 132 | let times = (yield* state.storage.get<number[]>("times")) ?? []; |
| 133 | return { |
| 134 | record: Effect.fn(function* (time: number) { |
| 135 | times = [...times, time]; |
| 136 | yield* state.storage.put("times", times); |
| 137 | }), |
| 138 | snapshot: () => Effect.succeed({ times }), |
| 139 | reset: Effect.fn(function* () { |
| 140 | times = []; |
| 141 | yield* state.storage.put("times", times); |
| 142 | }), |
| 143 | }; |
| 144 | }); |
| 145 | }), |
| 146 | ) {} |
| 147 | ``` |
| 148 | |
| 149 | `record` appends a fire timestamp to transactional storage, |
| 150 | `snapshot` returns everything recorded so far, and `reset` clears |
| 151 | leftover state between test runs. |
| 152 | |
| 153 | ## Wire the cron to the counter |
| 154 | |
| 155 | Point the cron at the DO and expose the recordings over HTTP. The |
| 156 | trigger is `* * * * *` — every minute, the fastest schedule |
| 157 | Cloudflare supports — so the test doesn't wait long: |
| 158 | |
| 159 | ```typescript |
| 160 | // src/worker.ts |
| 161 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 162 | import * as Effect from "effect/Effect"; |
| 163 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 164 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 165 | import CronCounter from "./cron-counter.ts"; |
| 166 | |
| 167 | export default Cloudflare.Worker( |
| 168 | "Worker", |
| 169 | { main: import.meta.url }, |
| 170 | Effect.gen(function* () { |
| 171 | const counters = yield* CronCounter; |
| 172 | |
| 173 | yield* Cloudflare.Workers.cron("* * * * *", (controller) => |
| 174 | counters.getByName("default").record(controller.scheduledTime), |
| 175 | ); |
| 176 | |
| 177 | return { |
| 178 | fetch: Effect.gen(function* () { |
| 179 | const request = yield* HttpServerRequest; |
| 180 | const url = new URL(request.url, "http://localhost"); |
| 181 | |
| 182 | if (request.method === "GET" && url.pathname === "/times") { |
| 183 | const snapshot = yield* counters.getByName("default").snapshot(); |
| 184 | return yield* HttpServerResponse.json(snapshot); |
| 185 | } |
| 186 | |
| 187 | if (request.method === "POST" && url.pathname === "/reset") { |
| 188 | yield* counters.getByName("default").reset(); |
| 189 | return yield* HttpServerResponse.json({ ok: true }); |
| 190 | } |
| 191 | |
| 192 | return HttpServerResponse.text("Not Found", { status: 404 }); |
| 193 | }), |
| 194 | }; |
| 195 | }).pipe(Effect.provide(Cloudflare.Workers.CronEventSourceLive)), |
| 196 | ); |
| 197 | ``` |
| 198 | |
| 199 | Each fire records `controller.scheduledTime` on the `"default"` |
| 200 | counter instance; `GET /times` reads it back and `POST /reset` |
| 201 | wipes it so reruns start clean. |
| 202 | |
| 203 | ## Poll until it fires |
| 204 | |
| 205 | The test deploys the Stack once, resets the counter, then polls |
| 206 | `/times` with a bounded `Effect.repeat` until a fire newer than the |
| 207 | reset shows up: |
| 208 | |
| 209 | ```typescript |
| 210 | // test/cron.test.ts |
| 211 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 212 | import * as Test from "alchemy/Test/Vitest"; |
| 213 | import { expect } from "@effect/vitest"; |
| 214 | import * as Effect from "effect/Effect"; |
| 215 | import * as Schedule from "effect/Schedule"; |
| 216 | import * as HttpClient from "effect/http/HttpClient"; |
| 217 | import Stack from "../alchemy.run.ts"; |
| 218 | |
| 219 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 220 | providers: Cloudflare.providers(), |
| 221 | state: Cloudflare.state(), |
| 222 | }); |
| 223 | |
| 224 | const stack = beforeAll(deploy(Stack)); |
| 225 | afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); |
| 226 | |
| 227 | test( |
| 228 | "the scheduled handler fires on its cron trigger", |
| 229 | Effect.gen(function* () { |
| 230 | const { url, crons } = yield* stack; |
| 231 | expect(crons).toContain("* * * * *"); |
| 232 | |
| 233 | const client = yield* HttpClient.HttpClient; |
| 234 | |
| 235 | // Reset leftover state from prior runs. Doubles as a readiness |
| 236 | // probe — a fresh workers.dev URL takes a few seconds to start |
| 237 | // serving 200s. |
| 238 | yield* Effect.gen(function* () { |
| 239 | const res = yield* client.post(`${url}/reset`); |
| 240 | if (res.status !== 200) { |
| 241 | return yield* Effect.fail(new Error(`Worker not ready: ${res.status}`)); |
| 242 | } |
| 243 | }).pipe( |
| 244 | Effect.retry({ |
| 245 | schedule: Schedule.exponential("500 millis"), |
| 246 | times: 10, |
| 247 | }), |
| 248 | ); |
| 249 | const resetAt = Date.now(); |
| 250 | |
| 251 | // Cron granularity is one minute and there's some propagation |
| 252 | // delay after deploy, so poll up to ~3 minutes for the first fire. |
| 253 | const times = yield* Effect.gen(function* () { |
| 254 | const res = yield* client.get(`${url}/times`); |
| 255 | if (res.status !== 200) return []; |
| 256 | const body = (yield* res.json) as { times?: number[] }; |
| 257 | return (body.times ?? []).filter((t) => t >= resetAt); |
| 258 | }).pipe( |
| 259 | Effect.catch(() => Effect.succeed([])), |
| 260 | Effect.repeat({ |
| 261 | schedule: Schedule.spaced("5 seconds"), |
| 262 | until: (recent) => recent.length > 0, |
| 263 | times: 36, |
| 264 | }), |
| 265 | ); |
| 266 | |
| 267 | expect(times.length).toBeGreaterThan(0); |
| 268 | }), |
| 269 | { timeout: 240_000 }, |
| 270 | ); |
| 271 | ``` |
| 272 | |
| 273 | The poll is declarative and bounded: `Schedule.spaced("5 seconds")` |
| 274 | with `times: 36` caps it at ~3 minutes, `until` stops as soon as a |
| 275 | recent fire appears, and `Effect.catch` treats transient fetch |
| 276 | errors as "not yet" instead of failing the test. Never write a |
| 277 | `while (Date.now() < deadline)` loop for this — it ignores |
| 278 | interruption and hides intent. |
| 279 | |
| 280 | ## Where next |
| 281 | |
| 282 | - [Workers](/cloudflare/compute/workers) — the two-phase Worker model that |
| 283 | cron handlers live inside. |
| 284 | - [Durable Objects](/cloudflare/compute/durable-objects) — the stateful |
| 285 | building block the test uses to record fires. |
| 286 | - [Workflows](/cloudflare/compute/workflows#schedule-a-workflow) — |
| 287 | native cron schedules on the Workflow itself. |
| 288 | - [cron API reference](/providers/cloudflare/workers/cron) |