DocumentationSearch docs
  1. 01Getting started
  2. 02Next.js and Vercel
  3. 03Node servers and scripts
  4. More JavaScript platforms

    1. 04SvelteKit
    2. 05Nuxt and Nitro
    3. 06React Router and Remix
    4. 07NestJS
    5. 08Strapi
    6. 09Netlify
    7. 10Firebase
    8. 11Convex
    9. 12Trigger.dev
    10. 13Inngest
    11. 14Cloudflare Workers
    12. 15Supabase and pg_cron
  5. Ruby

    1. 16Ruby on Rails
    2. 17Ruby
  6. Python

    1. 18Django
    2. 19Celery
    3. 20Python
  7. PHP

    1. 21Laravel
    2. 22Symfony
    3. 23WordPress
    4. 24Drupal
    5. 25Craft CMS
    6. 26PHP
  8. Go

    1. 27Go
    2. 28Go schedulers
  9. Rust

    1. 29Rust
    2. 30Rust schedulers
  10. Elixir

    1. 31Elixir
    2. 32Elixir schedulers
  11. Java

    1. 33Java
    2. 34Java schedulers
  12. .NET

    1. 35.NET
    2. 36.NET schedulers
  13. Reference

    1. 37Schedules, grace, and timeouts
    2. 38What it catches
    3. 39Alerts
    4. 40AI triage
    5. 41Stores
    6. 42Dashboard and API
    7. 43MCP server
    8. 44Agent skill
    9. 45TypeScript API reference
    10. 46Limits and design notes
    11. 47Environment variables
    12. 48Stability
    13. 49Deprecations

Schedules, grace, and timeouts

Schedules, grace, and timeouts

Every port applies these rules the same way: TypeScript, Ruby, Python, PHP, Go, Rust, Elixir, Java, and .NET. The examples use the TypeScript names; each language’s page has its own spelling (cw.check() is client.check in Ruby, for one). In Rails, a job can take its schedule and timezone from Solid Queue or sidekiq-cron instead; see Schedule the job.

Schedule syntax

Form Example Notes
Cron, five fields 0 2 * * * minute hour day month weekday, standard syntax with */n, ranges, and lists
Cron, six fields 0 */30 * * * * a leading seconds field
Nickname @yearly (or @annually), @monthly, @weekly, @daily (or @midnight), @hourly each fires at the start of its period: @weekly is Sunday at 00:00, @yearly January 1st. @reboot is not a schedule and is refused
Interval every 15m, every 6h, every 2d at least one second (every 500ms throws); counted from the last run’s start, or from its finish when it ran longer than the interval, or from registration before the first run
None watched for failures, duration, and budgets; never missed

Two schedules that never make sense are settled the same way in every language. A one-time date in place of a cron expression (2026-12-01T00:00:00, or anything else with a colon after its first character) is refused when the job is declared: CronWatch watches jobs that repeat. A cron that names a date no month has (0 0 30 2 *, 0 0 31 4,6,9,11 *) is accepted and never fires, so the job is never due and never missed; its runs are still watched for failures, duration, and budgets.

Durations everywhere use the same units: ms, s, m, h, d, w, and compounds like 1h30m. A number is milliseconds. A duration string is at most 64 characters; a longer one is refused.

Timezone

A cron expression is read in the process’s timezone unless the job (or the client’s defaults) sets timezone to an IANA name. Vercel and GitHub Actions run their crons in UTC, so declare timezone: "UTC" for those or the two schedules will disagree and you will see phantom misses around midnight.

How a missed run is decided

Each cw.check() counts forward from the job’s last run:

  1. Finds the due time: the first time the schedule fires after the last run started. A run covers a fire it started up to one minute before (schedulers sometimes fire a touch early), so a run at 01:59:30 counts for 02:00 and the next day’s 02:00 is due. For a cron that fires more often than every two minutes, that slack shrinks to half the gap between fires, so one run never covers two. For an interval, the due time is the last run’s start plus the interval; when that run finished later than that (it ran longer than the interval), the due time is its finish plus the interval, so a long run is not reported missed the moment it ends.
  2. Adds grace (default 10m) to get the deadline.
  3. If now is past the deadline, the job is missed. An interval job whose last run is still going is busy, not missed; stuck covers a run that never ends.

Because the due time comes from the last run rather than from the latest fire, a job that runs more often than its grace (every five minutes with the default ten minutes of grace) is still caught, and so is a cron that fires once a year.

A missed condition opens once and sends one alert. It closes, without a message, the moment a run starts that catches the schedule up; a run that started before the missed fire (one still going when the fire was due) leaves it open when it finishes, so the same fire is never reported twice. The recovered message is sent by the next successful run, even if the run that closed it failed. If a run never comes, you hear about it once, not every check.

A job that has never run counts from when it was first registered: the first check or run in a process that declared it. Its due time is the first fire at or after registration; a fire that happened before registration is not expected.

Daylight saving time

A cron read in a timezone with daylight saving follows that zone’s clock. When clocks spring forward, a fire whose local time does not exist that night (02:30 when 02:00 jumps to 03:00) is expected at the same distance past the jump (03:30), and a run that starts at or after the jump and before that time also covers it. That is where vixie cron runs such a job (03:00), so either scheduler’s run counts. When clocks fall back, the repeated hour’s fire is expected once. Declaring timezone: "UTC" avoids the question entirely.

Timeouts and stuck runs

timeout is not maxDuration: timeout gives up on a run that is still going, while maxDuration flags a run that finished, successfully, but slowly (see slow). Set timeout comfortably above both the job’s usual length and its maxDuration.

Every run is recorded as running when it starts. Normally it is updated to ok or failed within the same call. If the process dies first, the row stays running. Each check marks any run older than the job’s timeout (default 1h) as timeout, counts it as a failure, and opens a stuck condition. The next run’s start closes it, and the next successful run sends the recovery. If the timed-out run does finish later, a success closes stuck and recovers, and a failure is not counted a second time.

Inside the process, job.signal is an AbortSignal that fires when the timeout elapses, so work that can stop early may honour it; nothing is killed for you. In Ruby, job.aborted? turns true and job.signal.check! raises once the timeout has passed.

In Go the run’s ctx is cancelled at the timeout, with a cause that names the job; in Rust job.cancelled() resolves then; in Elixir Cronwatch.cancelled?(job) turns true; in Java job.cancelled() turns true and what job.onCancel registered is called; in .NET the run’s CancellationToken is cancelled.

Next due

The dashboard and API show nextExpectedAt: the next fire after now for a cron, or for an interval the due time above: the last run’s start plus the interval, or its finish plus the interval when it ran longer.

Choosing a grace period

Grace absorbs the normal jitter between "scheduled" and "started": queue delays, cold starts, a scheduler that ticks once a minute. Ten minutes suits most daily jobs. A frequent job can use a shorter grace (every 5m with grace: "2m") to hear about a stop sooner, and a job that queues behind others a longer one. The grace only affects when a miss is reported, never whether a run counts.