edge-cases production dst reliability

Cron Edge Cases That Bite You in Production

by Sinthuyan Arulselvam  · April 13, 2026

Most cron jobs work fine in staging. Staging doesn't have Black Friday traffic, and staging doesn't have November. The edge cases covered here aren't theoretical — they're the kind of thing that wakes you up at 3 AM with a Slack message that says "why did we charge 4,000 customers twice?" or "the monthly report didn't run." Every one of these has burned a production system somewhere. Here's how to stop them from burning yours.

1. DST Spring-Forward: The Missing Hour

When clocks spring forward in March, 2:00 AM becomes 3:00 AM instantaneously. The hour between 2:00 and 3:00 AM simply does not exist on that date in any timezone observing DST. If your cron job is scheduled during that window, the system clock skips past it entirely.

A job defined as 30 2 * * * running in America/New_York will silently not fire on the second Sunday of March. No error. No alert. The scheduler sees that 2:30 AM never existed on that calendar day and moves on.

Real incident pattern: A financial services company ran end-of-day reconciliation at 0 2 * * *. It ran flawlessly for 11 months. On the spring-forward Sunday, the nightly reconciliation didn't run. The gap wasn't caught until Monday morning when downstream reports showed the previous Friday's data. Root cause: the UTC time for 2:00 AM EST is 7:00 AM UTC, but 2:00 AM EDT is 6:00 AM UTC — after DST, the UTC-expressed job ran at the wrong local time and fell into the missing hour.

Prevention

  • Schedule mission-critical jobs outside the 2:00–3:00 AM window entirely — 1:00 AM or 4:00 AM are safe choices.
  • Define cron schedules in UTC explicitly and never rely on implicit server timezone.
  • Add a post-run heartbeat: if the job hasn't phoned home by a deadline, page someone. Dead Man's Snitch and similar services exist for exactly this.
  • For managed schedulers (AWS EventBridge, GCP Cloud Scheduler), confirm whether they operate in UTC or local time — they differ.

2. DST Fall-Back: The Duplicate Hour

Fall-back is the opposite problem and arguably worse. When clocks fall back in November, 2:00 AM becomes 1:00 AM again. The hour between 1:00 AM and 2:00 AM is experienced twice. Any job scheduled in that window runs twice.

A job at 30 1 * * * in a DST-observing timezone fires once at 1:30 AM standard time and once at 1:30 AM daylight time. From the job's perspective, there was no indication it had already run. The system clock just happened to pass 1:30 AM twice.

Real incident pattern: An e-commerce platform ran subscription billing at 0 1 * * *. On fall-back Sunday in November, customers on annual plans were charged twice. The payment processor accepted both transactions. The company spent three days issuing refunds and fielding chargebacks, plus the reputational damage. The total cost was estimated at forty times the engineering budget for that quarter.

Prevention

  • Idempotency is non-negotiable. Every billing, email-sending, or state-mutating job must be designed so running it twice produces the same result as running it once. Store a job execution token (date + job name) in your database with a unique constraint. If the token already exists, skip.
  • Run financial operations in UTC at a time that never falls in the ambiguous window.
  • Before any write operation, check: "Has this exact logical task already been completed for this period?" Use the business key, not the wall clock.
sql
-- Idempotency guard for billing jobs
INSERT INTO billing_runs (billing_date, customer_id, status)
VALUES ('2024-11-03', 42, 'completed')
ON CONFLICT (billing_date, customer_id) DO NOTHING;

-- Check affected rows: if 0, this already ran. Exit.

3. The Month-End Trap

Day 31 exists in only seven months: January, March, May, July, August, October, and December. If you schedule a job with 0 0 31 * * expecting a monthly run, you get seven executions per year — not twelve. April, June, September, November, and February never satisfy the day-of-month condition.

February is the most brutal offender. A job at 0 0 28 2 * runs once a year. A job at 0 0 29 2 * runs once every four years — or less, given century years.

Expression Intended Actual runs/year Months skipped
0 0 31 * * Monthly on 31st 7 Apr, Jun, Sep, Nov, Feb
0 0 30 * * Monthly on 30th 11 Feb
0 0 29 * * Monthly on 29th 11 (or 12 in leap years) Feb (most years)
0 0 29 2 * Annual Feb job 0.25 Every non-leap year

Real incident pattern: A SaaS company ran their end-of-month invoice generation at 0 6 31 * *. For the first seven months of deployment everything looked fine. Then April arrived. Invoices for April didn't generate. The billing cycle broke. Support tickets flooded in on May 1st.

Workarounds

  • To run on the last day of every month: use your job runtime to compute the last day of the current month programmatically, not the cron expression. Schedule the cron daily and let the job self-terminate if it's not the last day.
  • Alternatively: 0 0 28-31 * * combined with a runtime check for tomorrow.getMonth() !== today.getMonth().
  • Some modern schedulers (Kubernetes CronJob, Temporal) support last-day-of-month semantics natively — prefer them for month-end work.

4. The Leap Year Silent Failure

A job scheduled as 0 0 29 2 * — February 29th — runs once every four years. In between, it is silent. Three years and eleven months of zero executions, zero errors, zero indication anything is wrong.

This becomes dangerous when the job is assumed to run annually: annual certificate rotations, yearly data archival, license renewal checks. The three-year silence window means the job appears healthy in monitoring because "no execution" and "execution skipped" look identical from the outside.

Prevention

  • Never schedule annual maintenance tasks on Feb 29. Use Feb 28 with a runtime leap-year check if Feb 29 behavior is actually needed.
  • For any job that must run at least once per year, add a monitoring alert: "If this job hasn't run in 400 days, page someone."
  • Document expected run frequency explicitly in code comments alongside the cron expression.

5. Step Value Misconceptions

The step syntax */5 in the minutes field does not mean "every 5 minutes from when the job was registered." It means "every minute where minute % 5 === 0." That is: 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55.

If you register a job at 2:03 PM, */5 * * * * will next fire at 2:05 PM — two minutes later, not five. The step is pinned to the range origin (0), not to the registration time. This surprises teams who assume they're getting even distribution across a fleet.

The same applies to ranges: 10-50/10 * * * * fires at minutes 10, 20, 30, 40, 50 — starting from 10, not from 0. And 0 */6 * * * fires at hours 0, 6, 12, 18 — not "6 hours after deployment."

bash
*/5 * * * *      -- Fires at: :00 :05 :10 :15 :20 :25 :30 :35 :40 :45 :50 :55
3/5 * * * *      -- Fires at: :03 :08 :13 :18 :23 :28 :33 :38 :43 :48 :53 :58
10-30/5 * * * *  -- Fires at: :10 :15 :20 :25 :30 (stops at range end)

Prevention

  • Use 3/5 instead of */5 if you want firing at a specific offset (e.g., 3, 8, 13…).
  • When distributing load across multiple services, use different offsets: service A at 0/5, service B at 1/5, service C at 2/5.
  • Always validate your expression against a cron parser before deploying — the actual fire times frequently differ from intent.

6. DOM + DOW OR Behavior

Standard cron uses OR logic when both day-of-month (DOM) and day-of-week (DOW) are specified as non-wildcards. This is one of the most counterintuitive behaviors in cron and is explicitly defined in the POSIX spec.

The expression 0 0 1 * 1 does not mean "midnight on the 1st, only if it's a Monday." It means "midnight on the 1st of every month, OR midnight on every Monday." You get far more executions than intended.

bash
0 0 1 * 1    -- Fires on: every 1st of month + every Monday
             -- In a month with 4 Mondays + the 1st falls on a Tuesday: 5 executions
             -- Expected by most developers: 1 execution

0 0 1 * *    -- Fires on: every 1st of month only (safe)
0 0 * * 1    -- Fires on: every Monday only (safe)

Real incident pattern: A weekly newsletter platform set up 0 9 1 * 1 intending to send a special first-of-month edition only when the first fell on a Monday. Instead, subscribers received the special newsletter up to five times per month — once on the 1st regardless of day, and once every Monday. Unsubscribe rates spiked.

Prevention

  • If you need "first Monday of the month" semantics, schedule daily and implement the logic in the job itself: if (today.getDate() <= 7 && today.getDay() === 1).
  • Some schedulers (Quartz, Spring Scheduler) support L and # extensions that allow "first Monday" natively — check if your platform supports these.
  • Treat any cron expression with both DOM and DOW non-wildcards as a code smell requiring explicit comment justification.

7. High-Frequency Limits and the Sleep Chain Antipattern

Standard cron has 1-minute resolution. There is no */30 * * * * * in POSIX cron — seconds don't exist in the standard spec. Platforms that support second-level scheduling (Quartz, many cloud schedulers) are extensions, not standard behavior.

When teams need sub-minute execution, a common antipattern emerges: the sleep chain.

bash
#!/bin/bash
# DON'T DO THIS
while true; do
  run_job.sh
  sleep 30
done

This looks clever but creates severe problems in production. If run_job.sh takes 45 seconds, the next execution starts 75 seconds after the previous one began, not 30. If the script crashes, the loop dies silently. There is no missed-execution alerting, no distributed coordination, no back-pressure. The "sleep 30" is now drift 30 + runtime.

Prevention

  • For 30-second intervals: run the job twice per minute via two cron entries — * * * * * job.sh and * * * * * sleep 30 && job.sh.
  • For genuine sub-minute needs, use a proper queue (BullMQ, Celery, Sidekiq) with a dedicated worker process — not cron.
  • Cloud platforms like Cloudflare Workers Cron Triggers support 1-minute minimum; for sub-minute use Durable Object alarms.

8. Midnight and Day Boundaries

The expression 0 0 * * * fires at midnight. But "midnight" is ambiguous in distributed systems. Questions that production will force you to answer:

  • Is it the server's local midnight, or UTC midnight? These can be 12+ hours apart.
  • When a job at 0 0 * * * runs, what date does new Date() return? Exactly midnight, or a few milliseconds past?
  • If you filter database records by WHERE created_at >= TODAY, does "today" agree with the cron runner's date?

A common bug: a job runs at exactly 00:00:00.000 UTC and queries for records from "today." Depending on the database driver and connection timezone, the date boundary may be interpreted as the previous day in some locales. Records from 23:59:59 the previous day appear to be "today's" records.

Prevention

  • Parameterize the date: pass the intended processing date explicitly as an argument to the job, rather than computing it from now() inside the job.
  • Use half-open intervals: WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'. Never rely on DATE(created_at) = TODAY in cross-timezone contexts.
  • Log the exact UTC timestamp of job start. Make it easy to audit what time the job believed it was.

9. Non-Gregorian Calendar Systems and Locale Pitfalls

Cron itself operates on the Gregorian calendar. But if your server's locale is set to a system that uses a different calendar — Hebrew, Islamic (Hijri), Persian, Japanese imperial — date arithmetic in your job code can produce unexpected results when it calls locale-aware APIs.

JavaScript's Intl.DateTimeFormat with calendar: 'islamic' will give you a different "month" and "day" than the Gregorian values your cron expression uses. Python's locale.setlocale can affect strftime output. PHP's date() is Gregorian, but IntlDateFormatter is not.

Practical scenario: A multi-region SaaS running month-end processing for Middle Eastern customers. The job fires on Gregorian month boundaries, but the customer-facing invoice dates are rendered in Hijri calendar. A mismatch in what "month" means caused invoices to show the wrong billing period label — not a crash, but a compliance issue that required manual correction across hundreds of accounts.

Prevention

  • Keep all internal scheduling and date arithmetic in UTC Gregorian. Convert to locale-appropriate display only at the presentation layer.
  • Never use locale-aware date functions for job control logic. Use UTC epoch timestamps and ISO 8601 strings for all internal comparisons.
  • Test jobs in CI with LC_ALL=C TZ=UTC to prevent locale contamination.

10. Overlapping Executions

Cron has no built-in concept of "is the previous instance of this job still running?" It fires at the scheduled time regardless. If your job runs for 90 seconds and fires every minute, you now have two concurrent instances after the second trigger, three after the third. This compounds until the system collapses under the load or hits a resource limit.

This is not an edge case — it's the default behavior. And it's invisible in monitoring unless you're explicitly tracking concurrent execution counts.

sql
-- What happens with a 90s job on a 60s interval:
T+0:00  Instance 1 starts
T+1:00  Instance 2 starts   (Instance 1 still running)
T+1:30  Instance 1 finishes
T+2:00  Instance 3 starts   (Instance 2 still running)
T+3:00  Instance 4 starts   (Instance 2 still running, Instance 3 still running)
-- Database connection pool exhausted by T+5:00

Real incident pattern: A data pipeline job ran every 5 minutes and processed records from a queue. A downstream API started responding slowly — jobs took 7 minutes instead of 3. Within 35 minutes, 7 concurrent instances were running, each holding database connections and hammering the slow API. The database connection pool hit its limit. The API rate limiter kicked in. Everything cascaded. The fix took 4 hours; the cause took 2 days to identify.

Prevention

  • Locking: Use a distributed lock (Redis SET NX PX, database advisory lock, or a dedicated lock table) at job start. If the lock is held, exit immediately.
  • Kubernetes CronJob: Set concurrencyPolicy: Forbid to skip triggers when the previous job is still running, or Replace to kill the old one.
  • Timeout enforcement: Every job must have a hard timeout. If it exceeds its expected runtime, kill it and alert — don't let it run indefinitely.
  • Design for idempotency: If two instances do overlap despite locking failures, the result should still be correct. Locks prevent overlap; idempotency is your fallback when locks fail.
javascript
// Distributed lock pattern (Redis)
const lockKey = 'job:monthly-report:lock';
const lockTtl = 300_000; // 5 minutes in ms

const acquired = await redis.set(lockKey, String(Date.now()), {
  NX: true,
  PX: lockTtl,
});

if (!acquired) {
  console.log("Job already running. Exiting.");
  process.exit(0);
}

try {
  await runMonthlyReport();
} finally {
  await redis.del(lockKey);
}

The Common Thread

Every one of these edge cases shares a root cause: cron is a fire-and-forget primitive built in 1975. It has no awareness of time zones, calendar semantics, job duration, or execution history. It fires a command at a time. Everything else is your problem.

The production-grade approach treats cron expressions as just the trigger mechanism and builds all correctness guarantees in the job itself: idempotency for duplicate runs, locking for overlapping runs, dead-man monitoring for missing runs, and explicit UTC timestamps for all time-based logic.

Use CronBase to validate your expressions before they reach production. Paste the expression, confirm the actual fire times against your expectations, and check the DST transitions for your target timezone. The five seconds it takes to verify is cheaper than the three days it takes to recover.

Explore related resources

Related seed schedules

Related Guides