Nabil Asofi

Journal / systems

Idempotent cron jobs: the scheduling trigger most teams forget

Idempotent cron jobs stop missed runs and overlapping executions from corrupting agent workflows. What to build before you trust a schedule.

Every scheduled job looks the same on the day you write it: a cron expression, a function, a green checkmark in the logs. The failure shows up later, when the scheduler misses a window, fires twice, or runs at the wrong hour because a server lives in UTC and a client lives somewhere else. Idempotent cron jobs are the fix for all three, and almost nobody builds for it until something has already been double-charged or double-emailed.

Why idempotent cron jobs matter more than the schedule itself

The schedule is the easy part. Writing 0 */4 * * * takes five seconds. What takes longer, and what most teams skip, is making the job safe to run twice. A job is idempotent when running it once produces the same end state as running it five times. That property matters because retries, duplicate triggers, and overlapping executions are not edge cases in a scheduled system, they are the expected behavior under any kind of load or network hiccup. If the job sends an email, charges a card, or writes a row, and it is not idempotent, every one of those failure modes becomes a customer-facing bug instead of a boring log entry.

The fix is almost always the same shape: before the job acts, it checks whether this specific unit of work has already been done, using an idempotency key, a status column, or a dedupe table. The job becomes a conditional, not a command. That single discipline removes most of the pain described below.

Missed cron runs are not rare, they are default

A deploy, a container restart, a scaling event — any of these can cause a scheduler to skip a tick, and most schedulers do not tell you when that happens. If your job logic assumes “this runs every hour, so I only need to look at the last hour of data,” a missed run quietly drops a window of work on the floor. Nobody notices until someone asks why a batch of records from last Tuesday never got processed.

The practical fix is to make each run look back further than its own interval and rely on state, not the clock, to decide what still needs doing. An hourly job that processes “everything with status = pending” survives a missed tick for free. An hourly job that processes “everything from the last hour” does not. This is the same diagnostic discipline that applies before you automate anything at all: automating a broken process just makes the gaps move faster, and a scheduler with silent missed runs is exactly that kind of gap.

Overlapping job executions turn a scheduler into a race condition

If a job takes nine minutes and runs every five, you now have two copies of it running at once, both reading and writing the same rows. This happens constantly with data-heavy jobs that grow slower as a table grows larger, and it is the single most common cause of duplicate writes in production schedulers.

Distributed locking for cron is the standard answer: before the job starts, it tries to acquire a lock (a row, a key, an advisory lock in the database), and if it can’t, it exits immediately instead of running alongside its own previous instance. This is cheap to build and it should be the default, not something you add after the first duplicate-row incident. It is also the kind of structural check that separates a genuine agent loop from a fixed pipeline with a schedule bolted on — the agent or workflow test is worth running on any system where a trigger decides when work happens, because the failure modes differ depending on which one you actually built.

Timezone bugs in scheduling are correctness bugs, not cosmetic ones

“Run at 9am” means nothing until you specify whose 9am. A cron schedule defined in a server’s local time, interpreted during daylight saving transitions, or read by a client in a different region, drifts by an hour twice a year and nobody catches it until a report goes out at the wrong time or a job fires during a maintenance window it was supposed to avoid.

Store and schedule everything in UTC, and convert only at the display layer for the user. Never let “local time” leak into the trigger logic itself. If a job genuinely needs to run at a wall-clock local time (send this at 9am for each user, in their timezone), that is a different and harder problem. The trigger has to be computed per-user, not expressed as a single cron line, and most scheduling bugs in this category come from pretending it is the simpler problem.

What to build before you trust a schedule

Treat every scheduled job as if it will run twice, run late, and run from the wrong timezone, because eventually it will. Build the idempotency check first, the lock second, and the UTC discipline third: the idempotency check is what saves you even when the other two fail. The same audit discipline that catches a scoring model quietly dropping good leads applies here: audit scheduled jobs on a schedule, not just when something breaks, because a job that silently skips a run for three weeks looks identical to a job that is working, right up until someone goes looking for the missing data.

The honest failure rate of any given scheduler setup is worth measuring directly rather than guessing at: log every trigger, every skip, every overlap rejection, and review that log on a cadence, not just when a customer complains.

Also on this subject