Recurring jobs
This page explains how recurring tasks work internally. If you're looking for how to use them, see the Recurring tasks guide.
The recurring job model
A recurring job is a document that stores:
- Template (
templateId) — the task template to copy for each occurrence. - Interval — frequency, every-N, days of week, time of day, and timezone.
- State —
nextRunAt,lastRunAt,spawnCount. - Skip rules —
skipDates[](specific calendar days to skip) andskipUntil(skip everything before this date). - End condition —
never,on_date, orafter_noccurrences. - Spawn status — optional override for the spawned task's status.
Schedule calculation
The scheduler uses a pure function (computeNextRunAt) that takes the interval definition, a reference date, and an optional end condition, and returns the next valid run date — or null if the series is exhausted.
Per-frequency logic
- Day: steps forward by N days until the candidate is past the
afterdate. - Week: scans up to 84 days, checking configured days of week and every-N-week alignment from the reference Monday.
- Month: scans up to 60 months, checks that the day exists in the target month (31st in February → handled), and verifies N-month alignment.
- Year: scans up to 8 years forward with the same day/month validation.
All calculations use the job's timezone (IANA, e.g. Europe/Moscow). Wall-clock time ↔ UTC conversion is done for each step — DST transitions don't cause missed or double-spawned tasks.
Spawning
A cron job runs every minute and calls tickDue().
Claiming (optimistic lock)
- Find a job where
nextRunAt ≤ nowandactive = true. - Check
shouldSkip— if today is inskipDates[]or beforeskipUntil, advance to the next non-skip date without spawning. - Atomically update
nextRunAtusing afindOneAndUpdatewith the originalnextRunAtvalue as a filter (CAS — compare-and-swap). If another scheduler instance got there first, thisfindOneAndUpdatereturnsnulland we move to the next job.
Task creation
When a job is claimed:
- The task template is copied into the target list using the task-copy pipeline.
- The new task gets:
seriesId= the recurring job's ID.occurrenceDate= UTC midnight of the scheduled day in the job's timezone.- Status =
spawnStatusId(if set) or the list's first open status.
nextRunAtis computed from the interval.spawnCountis incremented.- If
endCondition = after_nandspawnCount ≥ count, the job is deactivated.
Catch-up
If multiple occurrences were missed (e.g. the server was down for a day), each missed occurrence is spawned in sequence, one per tick. This prevents a thundering-herd of dozens of simultaneous task creations.
Skip logic
skipDates[] — array of calendar dates (local timezone) that should be skipped. When the scheduler encounters a job whose next candidate is in skipDates, it advances past it without spawning.
skipUntil — a single date boundary. All candidates before this date are skipped. Used for "pause until" scenarios.
findNextNonSkipRun wraps computeNextRunAt with a loop (max 200 iterations) that keeps advancing through skipped dates.
Edit modes
When a user edits an instance of a recurring task, three modes are available:
This occurrence only
The instance is updated directly. The occurrence's date is added to the job's skipDates[] so the normal schedule isn't disrupted. The job definition itself is not changed.
This and following occurrences (split)
- The current job's
endDateis set tooccurrenceDate - 1ms— it won't spawn past this point. - The task template is cloned (so past instances keep their references).
- A new recurring job is created starting from today, pointing at the cloned template.
- The current instance is updated with the user's changes.
This means the series is physically split into two independent jobs. Past instances remain untouched.
All occurrences
- The task template is updated with the new title, description, priority, and tags.
- All past instances with the same
seriesIdandspawnedFromTemplateIdare bulk-updated with the same fields (usingupdateMany). - Future spawns will use the updated template.
Dates, statuses, and assignees of past instances are preserved — only the template-copied fields are overwritten.
Migration notes
If you have recurring jobs created before timezone support was added, a one-time migration (pnpm migrate:timezone) backfills a default timezone. The system handles missing timezones gracefully (defaults to UTC), but per-job timezone is required for correct daylight saving behaviour.
Related
- Recurring tasks (guide) — how to create and manage them.
- Task lifecycle — how spawned task instances behave.