Use one cron trigger and route by the scheduled time

  • scheduling
  • serverless
  • reliability

A side project of mine runs a handful of scheduled jobs: a few content runs spread across the day, a daily email in the morning, and a weekly roundup on Sunday. The jobs themselves live in CI workflows, and a tiny serverless worker kicks them off on a schedule, because the CI’s built-in cron turned out to skip a lot of runs.

The worker platform lets you register several cron expressions for one worker, so I did the obvious thing and gave each job its own:

[triggers]
crons = ["20 11,16,21 * * *", "20 13 * * SUN", "20 12,13 * * *"]

Then the handler looked at which cron string fired and picked a job. It deployed cleanly. It passed its tests. And for days, the daily email simply never went out.

Nothing errored. There was no failed dispatch in the logs, because there was no dispatch at all. When I finally opened the platform’s invocation metrics (how many times the scheduler actually called the worker, broken down by trigger), only the first cron had ever fired. The other two were configured, visible in the dashboard, and completely silent.

I never got a satisfying answer for why. What I did get was a better design.

One trigger, routing in code

The fix was to register exactly one cron that covers the union of every time anything needs to run, and move the “which job is this?” decision into a plain function:

[triggers]
crons = ["20 11,12,13,16,21 * * *"]
const STORY = { file: 'generate-content.yml' };
const DAILY = { file: 'daily-email.yml', inputs: { dry_run: 'false' } };
const WEEKLY = { file: 'weekly-email.yml', inputs: { dry_run: 'false' } };

/** Which job runs for a scheduled time (ms since epoch). null means nothing. */
export function targetFor(scheduledTime) {
	const d = new Date(scheduledTime);
	const hour = d.getUTCHours();
	const sunday = d.getUTCDay() === 0;
	if (hour === 11 || hour === 16 || hour === 21) return STORY;
	if (hour === 12) return sunday ? null : DAILY;
	if (hour === 13) return sunday ? WEEKLY : DAILY;
	return null;
}

The scheduler’s only job now is to wake the worker up five times a day. Everything else is ordinary code.

Route on the scheduled time, not the cron string

My first version keyed off the cron expression that fired. That turned out to be fragile in a couple of ways I didn’t expect:

  • The string has to match exactly. If the platform normalizes SUN to 1, or you edit a space in the config, the lookup quietly falls through to the default job. I had a little set of “equivalent spellings” to paper over this, which should have been a warning sign.
  • Day-of-week numbering varies by platform. Standard cron counts Sunday as 0; the platform I use counts it as 1 and rejects 0. Every cron string you have to reason about is another place to get that wrong.

The scheduler hands the handler the time the run was scheduled for, separate from when it actually started. That’s the value to route on. It’s a number, it means the same thing on every platform, and it doesn’t drift if the run starts a few seconds or minutes late. I avoid Date.now() here for the same reason: a run that starts at 12:59:58 because of a queue should still count as the 12:00 hour slot it was scheduled for, not wander into the next one.

A bonus: targetFor is a pure function. Testing it is a table of timestamps and expected jobs, with no mocked scheduler and no deploy. “Sunday at 12:20 UTC runs nothing” is one assertion instead of a hope.

Log every decision, including “nothing”

The part that hurt the most in the original bug wasn’t the missing trigger. It was that a missing run and a healthy quiet day looked identical in the logs: empty.

So now the handler logs on every invocation, before it does anything else:

export default {
	async scheduled(controller, env, ctx) {
		const iso = new Date(controller.scheduledTime).toISOString();
		const target = targetFor(controller.scheduledTime);
		console.log(`scheduled at ${iso} -> ${target ? target.file : 'nothing'}`);
		if (target) ctx.waitUntil(dispatch(env, target, { label: iso }));
	},
};

Five lines a day, one per wake-up, each saying what it decided. If a line is missing, the scheduler didn’t call me. If the line says nothing when I expected a job, the routing is wrong. If the line is there but the job never started, the problem is downstream.

The dispatch itself logs the HTTP status and the first few hundred characters of the response body, success or failure. And it runs inside waitUntil, the platform’s way of saying “keep this invocation alive until this promise settles.” Without it, a scheduled handler can return before the outbound request (and its log line) finishes, which is a lovely way to lose exactly the evidence you need.

Check the scheduler, not just the job

The general lesson I took away: for anything scheduled, there are two separate questions. Did the job succeed when it ran? And did it run at all? Most monitoring answers the first one. The second one is the one that bit me.

Practical ways to answer it:

  • Look at invocation counts per trigger if your platform exposes them, right after you change a schedule. That’s how I found the bug, and I should have looked on day one.
  • Have the job leave a mark when it runs, like a log line, a row, or a timestamp somewhere, so “nothing happened” is something you can see rather than infer.
  • Alert on absence. A check that says “no daily email was created by 11 a.m.” catches a dead trigger, a broken route, and a failing job with one rule.

Tradeoffs

  • The trigger fires at the union of all times. Some wake-ups do nothing on some days (Sunday at 12:00, here). That’s a log line and a few milliseconds, which I’m happy to pay.
  • Adding a job means a code change, not just a config change. I see that as a feature. The schedule and the routing live next to each other, and the routing has tests.
  • It assumes jobs share a minute. Everything here runs at :20. If two jobs need different minutes, the single cron gets a little uglier (20,40 ...) and the router checks minutes too. Still one place to look.
  • Duplicate runs still need to be harmless. One trigger doesn’t make the scheduler exactly once. The jobs it starts are idempotent, so a retried or doubled wake-up can’t send two emails. That part doesn’t change with this design; it just stops being the thing you debug at 7 a.m.

The short version

  1. Register one cron that covers every time you need.
  2. Route in a pure function keyed on the scheduled time, in UTC.
  3. Log the decision on every wake-up, including when the answer is “nothing.”
  4. Keep the invocation alive until the dispatch and its log line finish.
  5. Verify the scheduler actually calls you, and alert when an expected run is missing.

Multiple triggers look tidier in a config file. One trigger and a dozen lines of routing is what I can actually trust.