DEV Community

137Foundry
137Foundry

Posted on

How to Debug a Cron Job That Silently Skips Its DST Run

A recurring job scheduled for 2:30 AM local time runs reliably for months, then twice a year it either doesn't run at all or runs twice back to back. Nobody touched the cron config. This is the spring-forward and fall-back gap, and it's a specific, diagnosable failure mode once you know what to look for.

Step 1: Confirm the Job Is Scheduled in Local Time, Not UTC

The first thing to check is whether the scheduler is configured with a timezone-aware local time or a fixed UTC time. Many cron implementations default to the server's system timezone, and if that's a US or EU timezone observing DST, any job scheduled inside the 2:00-3:00 AM window during a spring-forward transition is scheduling itself into a wall-clock hour that doesn't exist that day.

# Check what timezone cron itself is interpreting schedules in
timedatectl status
cat /etc/timezone
Enter fullscreen mode Exit fullscreen mode

If the job is meant to run at a specific UTC instant regardless of local wall-clock time, switching the schedule to reference UTC directly sidesteps the entire DST ambiguity.

Step 2: Identify Which Transition Type Caused the Symptom

Spring-forward (an hour skips, e.g., 2:00 AM jumps straight to 3:00 AM) and fall-back (an hour repeats, 1:00-2:00 AM happens twice) produce different symptoms. A job scheduled inside the skipped hour during spring-forward typically doesn't fire at all that day, since the wall-clock time it was waiting for never occurred. A job scheduled inside the repeated hour during fall-back can fire twice, once during each occurrence of that hour, if the scheduler isn't explicitly deduplicating.

# Check your job's execution log around the transition date
grep "2026-03-08" /var/log/cron.log
grep "2026-11-01" /var/log/cron.log
Enter fullscreen mode Exit fullscreen mode

Cross-referencing the exact date against your region's published DST transition dates (available from the IANA Time Zone Database) confirms whether the missing or duplicated run lines up with a transition.

Step 3: Check Whether Your Scheduler Documents Its DST Behavior

Not every scheduler handles this the same way, and the "correct" behavior is genuinely ambiguous, there's no universally right answer for what a job scheduled at a now-nonexistent time should do. Some schedulers skip the run entirely for that day, others shift it to the nearest valid time, others fire on the pre-transition UTC equivalent. Reading your specific scheduler's documentation on this exact question, rather than assuming, is a five-minute step that saves hours of confused debugging later.

// Node-based schedulers (node-cron, node-schedule) generally rely on
// the underlying system timezone database via Intl, but confirm your
// specific library's stated behavior rather than assuming
const cron = require('node-cron');
cron.schedule('30 2 * * *', runJob, { timezone: 'America/New_York' });
Enter fullscreen mode Exit fullscreen mode

Step 4: Add Idempotency to Protect Against the Fall-Back Duplicate Run

Regardless of which specific behavior your scheduler has, the safest fix for the "runs twice" fall-back scenario is making the job itself idempotent, so a duplicate trigger doesn't cause a duplicate side effect. This is a good practice independent of DST entirely, since retries and scheduler quirks can cause duplicate triggers for other reasons too.

async function runIdempotentJob(jobId, date) {
  const alreadyRan = await checkJobLog(jobId, date);
  if (alreadyRan) {
    console.log(`Job ${jobId} already ran for ${date}, skipping duplicate`);
    return;
  }
  await executeJob(jobId);
  await recordJobLog(jobId, date);
}
Enter fullscreen mode Exit fullscreen mode

Step 5: For Business-Critical Jobs, Avoid Scheduling Inside the Transition Window Entirely

The most reliable fix for anything genuinely business-critical, payroll processing, billing runs, data exports with compliance deadlines, is simply avoiding scheduling inside the 1:00-3:00 AM local window in regions that observe DST. Moving a critical job to a time like 5:00 AM local, well outside any transition window, eliminates the entire failure category without needing to reason about scheduler-specific DST edge case behavior at all.

Step 6: Write a Regression Test Against the Actual Transition Dates

Because this bug only manifests on two specific calendar dates per year, it's easy to fix once and have it silently regress after a scheduler library upgrade or a refactor. A test that explicitly simulates the transition dates catches this before it reaches production again.

const transitionDates2026 = ['2026-03-08', '2026-11-01']; // US DST dates
transitionDates2026.forEach(date => {
  test(`job scheduling resolves correctly around ${date}`, () => {
    const scheduledRuns = resolveScheduleForDate(cronExpression, date);
    expect(scheduledRuns.length).toBe(1); // exactly one run, not zero or two
  });
});
Enter fullscreen mode Exit fullscreen mode

Step 7: Document the Decision for the Next Engineer

Whatever behavior you land on, document it directly next to the cron configuration or scheduling code, not in a separate runbook that will be forgotten. The next engineer who inherits this code and notices a job "missing" a run during a DST week needs to find the explanation in thirty seconds, not rediscover the whole investigation from scratch.

Our automation team has debugged this exact failure pattern across multiple client systems, almost always billing or reporting jobs scheduled inside the 1-3 AM window without anyone realizing that window is where DST transitions live. There's a broader set of related date-handling patterns, including how to test them properly, in our recent piece on date and time code snippets.

Why This Bug Survives So Long in Production

Part of what makes this particular failure mode so persistent is that it fails silently in the spring-forward direction and loudly, but confusingly, in the fall-back direction. A skipped run rarely triggers an alert unless someone explicitly built monitoring for "job X did not run today," which is a less common alerting pattern than "job X threw an error," since a skipped run produces no error at all, just an absence. A duplicated run, meanwhile, often does get noticed, but the symptom, two of something that should have happened once, gets attributed to a retry mechanism, a deploy race condition, or a load balancer quirk long before anyone thinks to check the calendar for a DST transition date. Teams frequently spend hours chasing the wrong hypothesis before someone notices the date coincidence. Standard monitoring guidance, including the "dead man's switch" pattern documented across most observability platforms, generally recommends alerting on the absence of an expected event within a time window rather than only alerting on explicit errors, precisely because failure modes like a skipped cron run produce no error signal on their own.

Cloud Scheduler Services Aren't Automatically Immune Either

It's tempting to assume that migrating a cron job to a managed cloud scheduling service (AWS EventBridge Scheduler, Google Cloud Scheduler, and similar) sidesteps this entire problem, since these are professionally maintained systems built by teams with far more scheduling expertise than an in-house cron setup. In practice, these services generally do handle DST transitions correctly for the timezone-aware scheduling they explicitly support, but the risk shifts to how your own job configuration specifies its schedule. A job configured with a raw UTC cron expression on a managed scheduler sidesteps DST ambiguity by definition, since UTC never observes daylight saving time, but a job explicitly configured against a named local timezone still depends on getting that specific configuration right. AWS's own documentation on EventBridge Scheduler spells out its specific timezone and DST handling directly, and it's worth reading your specific provider's equivalent page rather than assuming "managed service" means "problem solved automatically."

Container Orchestration Adds Its Own Layer of Timezone Uncertainty

Teams running scheduled jobs inside containers, via Kubernetes CronJobs or similar orchestration, face an extra layer worth checking: the container's own configured timezone (often UTC by default in most base images regardless of the host machine's timezone) may differ from what an engineer assumes when reading a schedule definition. The Kubernetes documentation on CronJob explicitly notes that CronJob schedules are interpreted according to the kube-controller-manager's configured timezone, which is a detail worth confirming directly rather than assuming it matches either the container image's default or any individual engineer's local expectation.

Bottom Line

A cron job that silently skips or duplicates during a DST transition isn't a scheduler bug, it's an unhandled edge case in wall-clock scheduling that every DST-observing region creates twice a year. Confirm the transition type, check your scheduler's documented behavior, make critical jobs idempotent, move genuinely critical schedules outside the transition window, and write a regression test against the actual calendar dates so the fix survives the next refactor.

Top comments (0)