1---
2title: Scheduled jobs with Cron Triggers
3description: 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.
4sidebar:
5 order: 17
6---
7
8Cron Triggers invoke a Worker on a schedule — nightly cleanups,
9periodic syncs, heartbeats. In alchemy, a single call declares both
10halves of a scheduled Worker: `Cloudflare.Workers.cron(expression,
11handler)` attaches the cron expression to the Worker at deploy time
12*and* registers the runtime `scheduled` listener that runs your
13Effect on each fire.
14
15To run a [Workflow](/cloudflare/compute/workflows#schedule-a-workflow)
16on a schedule, attach `schedules` to the Workflow itself instead of
17wrapping `workflow.create()` in a `scheduled` handler.
18
19## Declare a cron on the Worker
20
21Call `cron` in the Worker's Construction phase and provide
22`CronEventSourceLive` as a layer:
23
24```typescript
25// src/worker.ts
26import * as Cloudflare from "alchemy/Cloudflare";
27import * as Effect from "effect/Effect";
28import * as HttpServerResponse from "effect/http/HttpServerResponse";
29
30export 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
47At deploy time the call binds `0 12 * * *` onto the Worker's Cron
48Triggers; at runtime it registers a `scheduled` listener that runs
49your handler every day at noon UTC. The `controller` is Cloudflare's
50`ScheduledController` — `controller.scheduledTime` is the fire time
51and `controller.cron` is the expression that fired.
52
53:::note
54A failing handler won't crash the Worker — the event source catches
55the failure and moves on. If you need visibility into failed runs,
56log or report the error inside the handler yourself.
57:::
58
59## Multiple schedules
60
61Register `cron` once per schedule; each handler only runs for fires
62of its own expression:
63
64```typescript
65yield* Cloudflare.Workers.cron("*/5 * * * *", () => syncFeeds);
66yield* Cloudflare.Workers.cron("0 0 * * *", () => purgeExpired);
67```
68
69The listener checks `controller.cron` against the expression it was
70registered with, so a fire of `0 0 * * *` never runs the `*/5`
71handler and vice versa.
72
73:::note
74Workers whose `main` module exports its own `scheduled` handler can
75attach schedules with the Worker's `crons` prop instead:
76`{ main, crons: ["0 12 * * *"] }`. Pass an empty array to remove all
77Cron Triggers.
78:::
79
80## Deploy it
81
82Wire the Worker into a Stack and expose its URL and attached crons
83as outputs:
84
85```typescript
86// alchemy.run.ts
87import * as Alchemy from "alchemy";
88import * as Cloudflare from "alchemy/Cloudflare";
89import * as Effect from "effect/Effect";
90import Worker from "./src/worker.ts";
91
92export 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
109attached to the deployed Worker — the test below asserts against it.
110
111```sh
112bun alchemy deploy
113```
114
115## Test it: record each fire in a Durable Object
116
117Cloudflare's minimum cron granularity is one minute, so you can't
118just sleep and assert — you need the handler to leave evidence you
119can poll for. A Durable Object that records each `scheduledTime` is
120perfect:
121
122```typescript
123// src/cron-counter.ts
124import * as Cloudflare from "alchemy/Cloudflare";
125import * as Effect from "effect/Effect";
126
127export 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
151leftover state between test runs.
152
153## Wire the cron to the counter
154
155Point the cron at the DO and expose the recordings over HTTP. The
156trigger is `* * * * *` — every minute, the fastest schedule
157Cloudflare supports — so the test doesn't wait long:
158
159```typescript
160// src/worker.ts
161import * as Cloudflare from "alchemy/Cloudflare";
162import * as Effect from "effect/Effect";
163import { HttpServerRequest } from "effect/http/HttpServerRequest";
164import * as HttpServerResponse from "effect/http/HttpServerResponse";
165import CronCounter from "./cron-counter.ts";
166
167export 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
199Each fire records `controller.scheduledTime` on the `"default"`
200counter instance; `GET /times` reads it back and `POST /reset`
201wipes it so reruns start clean.
202
203## Poll until it fires
204
205The test deploys the Stack once, resets the counter, then polls
206`/times` with a bounded `Effect.repeat` until a fire newer than the
207reset shows up:
208
209```typescript
210// test/cron.test.ts
211import * as Cloudflare from "alchemy/Cloudflare";
212import * as Test from "alchemy/Test/Vitest";
213import { expect } from "@effect/vitest";
214import * as Effect from "effect/Effect";
215import * as Schedule from "effect/Schedule";
216import * as HttpClient from "effect/http/HttpClient";
217import Stack from "../alchemy.run.ts";
218
219const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
220 providers: Cloudflare.providers(),
221 state: Cloudflare.state(),
222});
223
224const stack = beforeAll(deploy(Stack));
225afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
226
227test(
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
273The poll is declarative and bounded: `Schedule.spaced("5 seconds")`
274with `times: 36` caps it at ~3 minutes, `until` stops as soon as a
275recent fire appears, and `Effect.catch` treats transient fetch
276errors as "not yet" instead of failing the test. Never write a
277`while (Date.now() < deadline)` loop for this — it ignores
278interruption 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)