Every Worker you've written so far responds to an incoming HTTP request. But what if you want code to run on a schedule — no request needed? That's what Cron Triggers do. They turn your Worker into a serverless cron job: fetch data every hour, clean up stale records nightly, warm a cache at 6 AM.
A Cron Trigger is a scheduled invocation of your Worker. Cloudflare calls your Worker at the times you specify — no incoming HTTP request required. Your Worker wakes up, does its work, and goes back to sleep.
Common use cases:
Cron Triggers are included on the free tier. Each invocation counts as one request against your 100K daily limit — the same as any HTTP request to your Worker.
Cloudflare uses standard 5-field cron syntax:
┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12)
│ │ │ │ ┌───── day of week (0–6, Sunday = 0)
│ │ │ │ │
* * * * *
Examples:
| Expression | Meaning |
|---|---|
0 * * * * | Every hour, on the hour |
*/5 * * * * | Every 5 minutes |
0 6 * * * | Every day at 6:00 AM UTC |
0 0 * * 1 | Every Monday at midnight UTC |
0 */2 * * * | Every 2 hours |
In your wrangler.jsonc, add a triggers block with one or more cron expressions:
{
"name": "quote-collector",
"main": "src/index.js",
"compatibility_date": "2024-01-01",
// KV namespace for storing fetched quotes
"kv_namespaces": [
{ "binding": "QUOTES", "id": "your-kv-namespace-id" }
],
// Schedule: run every hour on the hour
"triggers": {
"crons": ["0 * * * *"]
}
}
You can specify multiple schedules if needed:
"triggers": {
"crons": ["0 * * * *", "0 6 * * *", "0 0 * * 1"]
}
scheduled Event HandlerHTTP requests hit your fetch handler. Cron invocations hit a different handler: scheduled.
export default {
// Called by HTTP requests (GET, POST, etc.)
async fetch(request, env, ctx) {
// ...
},
// Called by Cron Triggers on schedule
async scheduled(event, env, ctx) {
// event.cron — the cron pattern that triggered this (e.g., "0 * * * *")
// event.scheduledTime — the time this was scheduled to run (ms timestamp)
// env — your bindings (KV, D1, secrets, etc.)
// ctx.waitUntil() — keep the Worker alive for async work
}
};
The scheduled handler doesn't return a Response (there's no one to send it to). Use ctx.waitUntil(promise) to tell the runtime "don't shut down until this async work finishes." Without it, your Worker might terminate before your fetch or KV write completes.
Let's build a Worker that runs every hour, fetches a random quote from a public API, and stores it in KV. It also exposes a fetch handler so you (or your frontend) can retrieve the latest quote via HTTP.
npx wrangler init quote-collector
cd quote-collector
npx wrangler kv namespace create QUOTES
Copy the output ID into your wrangler.jsonc.
{
"name": "quote-collector",
"main": "src/index.js",
"compatibility_date": "2024-01-01",
"kv_namespaces": [
{ "binding": "QUOTES", "id": "paste-your-id-here" }
],
"triggers": {
"crons": ["0 * * * *"]
}
}
// src/index.js
export default {
// HTTP handler — returns the latest stored quote
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/quote") {
const data = await env.QUOTES.get("latest", { type: "json" });
if (!data) {
return new Response(
JSON.stringify({ error: "No quote stored yet. Wait for the next cron run." }),
{ status: 404, headers: { "Content-Type": "application/json" } }
);
}
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" }
});
}
return new Response("Quote Collector API\n\nGET /quote — latest stored quote", {
headers: { "Content-Type": "text/plain" }
});
},
// Cron handler — fetches a new quote and stores it in KV
async scheduled(event, env, ctx) {
ctx.waitUntil(fetchAndStoreQuote(env));
}
};
async function fetchAndStoreQuote(env) {
// Using ZenQuotes API (free, no auth required)
const response = await fetch("https://zenquotes.io/api/random");
if (!response.ok) {
console.error(`Quote API returned ${response.status}`);
return;
}
const [quote] = await response.json();
const data = {
text: quote.q,
author: quote.a,
fetchedAt: new Date().toISOString()
};
await env.QUOTES.put("latest", JSON.stringify(data));
console.log(`Stored quote: "${data.text}" — ${data.author}`);
}
A single Worker can export both fetch and scheduled. The cron writes data into KV; HTTP requests read it out. One Worker, two entry points.
Start the dev server with the --test-scheduled flag to enable local cron testing:
npx wrangler dev --test-scheduled
Now you can trigger the scheduled handler manually by hitting a special endpoint:
# Trigger the cron handler
curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*"
# Then fetch the stored quote via your HTTP handler
curl "http://localhost:8787/quote"
The /__scheduled endpoint simulates a cron invocation locally. Your scheduled handler runs, fetches the quote, and stores it in the local KV emulator. Then your fetch handler can read it back.
In local dev, Wrangler uses a local KV emulator. Data persists between runs in your project's .wrangler/ directory, but it's separate from your deployed KV namespace.
Deploy like any other Worker:
npx wrangler deploy
That's it. Once deployed, Cloudflare reads the triggers.crons config and immediately starts scheduling your Worker. No additional setup, no separate cron service, no server to maintain.
You'll see your Worker listed in the Cloudflare dashboard with a "Triggers" tab showing the active cron schedules.
After your cron fires in production, you can see what happened:
quote-collector)Your console.log() statements from the scheduled handler show up here, along with any errors. You can also tail logs from the CLI:
npx wrangler tail
This streams live logs from your deployed Worker — both HTTP requests and cron invocations.
Here's the full Worker in one block for easy reference:
// src/index.js — Quote Collector Worker
// Cron: fetches a random quote every hour and stores in KV
// HTTP: serves the latest quote at GET /quote
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/quote") {
const data = await env.QUOTES.get("latest", { type: "json" });
if (!data) {
return new Response(
JSON.stringify({ error: "No quote stored yet. Wait for the next cron run." }),
{ status: 404, headers: { "Content-Type": "application/json" } }
);
}
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300"
}
});
}
if (url.pathname === "/") {
return new Response(
"Quote Collector API\n\nGET /quote — returns the latest stored quote\n",
{ headers: { "Content-Type": "text/plain" } }
);
}
return new Response("Not found", { status: 404 });
},
async scheduled(event, env, ctx) {
ctx.waitUntil(fetchAndStoreQuote(env));
}
};
async function fetchAndStoreQuote(env) {
const response = await fetch("https://zenquotes.io/api/random");
if (!response.ok) {
console.error(`Quote API failed: ${response.status} ${response.statusText}`);
return;
}
const [quote] = await response.json();
const data = {
text: quote.q,
author: quote.a,
fetchedAt: new Date().toISOString()
};
await env.QUOTES.put("latest", JSON.stringify(data));
console.log(`Stored quote: "${data.text}" — ${data.author}`);
}
And the configuration:
// wrangler.jsonc
{
"name": "quote-collector",
"main": "src/index.js",
"compatibility_date": "2024-01-01",
"kv_namespaces": [
{ "binding": "QUOTES", "id": "your-kv-namespace-id" }
],
"triggers": {
"crons": ["0 * * * *"]
}
}
What happens to your Cron Trigger Worker on the free tier if you've already used all 100K daily requests?
You now have the full picture of what Cloudflare Workers can do on the free tier: HTTP APIs, SQL databases, key-value storage, object storage, and now scheduled background jobs — all without a server.
Time to build something real. Go back to Lesson 1's project table and pick one:
Each project uses concepts from these five lessons. Pick one that interests you and build it end to end.
Cron Triggers Documentation — official reference for scheduling, syntax, limits, and debugging cron Workers.