Stop running cron jobs twice (or skipping them entirely)
Two predictable failures hit any system that schedules work in local civil time: jobs scheduled inside the spring-forward gap don't fire, and jobs scheduled inside the fall-back overlap fire twice. ChronoShield resolves user-supplied schedules to deterministic UTC instants so neither happens silently.
A daily 2:30 AM billing job. On March 8 in America/New_York, 2:30 AM doesn't exist. Most schedulers either skip the run entirely or fire it at 3:30 AM along with the next one.
Either way: revenue or workflow logs out of sync once a year. Hard to debug because the job's own logs show no error.
A monthly subscription charge at 1:30 AM on November 1. 1:30 AM happens twice that day. Schedulers that interpret cron in local civil time fire twice.
Customers get charged twice. Refund tickets, chargebacks, the support team's least favorite Sunday of the year.
Why this matters for billing specifically
- Direct financial impact: double-charges create chargebacks, refunds, and Stripe disputes. Each chargeback costs $15–25 in fees plus the disputed amount, even if you win.
- Customer trust: "you charged me twice" is the single most damaging support ticket a billing system can generate. Refunding doesn't restore the trust.
- Regulatory risk: in regulated billing scenarios (utilities, telecom, certain SaaS jurisdictions), incorrect charge timestamps create audit liabilities even when refunded promptly.
- Silent month-boundary bugs: a usage-based billing system that aggregates "events from Jan 31 14:00 UTC to Feb 1 00:00 UTC" gets the cutoff wrong if a customer is in a different timezone — events get counted in the wrong month, sometimes double-counted.
The cron-gap problem
Most cron systems express schedules in local civil time: "30 2 * * *" means "every day at 2:30 AM in this server's timezone." On the day of the spring-forward transition, that local time doesn't exist.
What different schedulers do:
- Linux cron: skips the job entirely.
- Quartz.NET: fires at 3:00 AM (the next valid time) by default — sometimes user expects this, sometimes not.
- node-cron: behavior depends on configuration. Has had multiple historical DST bugs.
- systemd timers: skips, with a documented warning in the man page few people read.
- Application-level schedulers (Sidekiq-cron, Celery beat): varies; check yours.
The fix isn't to argue about which behavior is correct — it's to remove the ambiguity at the input boundary. Resolve the local-time rule to a UTC instant once, ahead of time, with an explicit policy. If the policy is next_valid_time, the job fires at 3:00 AM and you know it. If the policy is reject, you surface a configuration error to the customer instead of silently dropping their billing run.
The cron-overlap problem
Fall-back is worse because the failure mode is "fires twice" instead of "fires not at all." A billing job at 1:30 AM on the November transition day will run once at the pre-transition offset (UTC−4 in NY) and again at the post-transition offset (UTC−5). Same wall clock, different UTC instants, two firings.
The fix has two layers:
- Resolve user-supplied schedule rules to UTC at definition time, with an explicit ambiguity policy (typically
earlier). Then the scheduler fires once at a known UTC instant. - Make the job itself idempotent via a per-period idempotency key (e.g.
billing_run_2026_11_01). Even if a malformed scheduler somewhere fires twice, the second invocation is a no-op.
ChronoShield handles layer 1. Idempotency keys are your responsibility, but ChronoShield surfacing DST_OVERLAP reminds you the day exists, so you build the idempotency key before production hits the case.
Recommended implementation pattern
For any system that accepts a user-supplied schedule rule (cron expression, "every day at X", "first of every month at Y"), the canonical pattern is to resolve the next fire time fresh from the rule each iteration.
// Don't precompute 90 days of UTC instants. Recompute each fire fresh.
async function nextFireUtc(schedule) {
// schedule = { local_time: "02:30", time_zone: "America/New_York", rule: "daily" }
const localTomorrow = nextOccurrence(schedule); // your scheduling lib
const localDt = `${localTomorrow}T${schedule.local_time}:00`;
const resolved = await chronoshield.resolve({
local_datetime: localDt,
time_zone: schedule.time_zone,
resolution_policy: {
// Spring-forward gap: jump forward to the next valid time
invalid: "next_valid_time",
// Fall-back overlap: take the first occurrence (before transition)
ambiguous: "earlier",
},
});
return resolved.instant_utc;
}
// At each job run:
const next = await nextFireUtc(job.schedule);
await scheduler.enqueueAt(next, job.id);
Why recompute each iteration: if you precompute a list of UTC instants for the next 90 days, the first DST transition in that window will drift them by an hour. By the time the bug surfaces, you can't tell whether it was the schedule or the executor that misfired.
When to use validate vs. resolve vs. convert
| Scenario | Endpoint | Why |
|---|---|---|
| User defines a recurring schedule in local time | /validate (sanity check) + /resolve (next fire) |
Validate the rule input; resolve each occurrence to UTC at fire time. |
| Computing the start/end of a billing period in user's local civil time | /resolve |
e.g. "the first of next month at midnight America/Chicago." Resolve once, store the UTC instant. |
| Showing a customer their next billing date in their timezone | /convert |
UTC → local. Render the stored UTC instant in their zone. |
| Bulk-validate a list of scheduled rules at deploy/migration time | /batch |
Catch DST issues across hundreds of customer schedules in one round trip. |
Worked example: monthly subscription billing
A SaaS bills customers on the 1st of each month at 00:00 in their local timezone. Here's the full flow with DST safety.
async function computeNextBillingUtc(customer) {
// Customer: { id, time_zone: "America/Chicago", billing_anchor: 1 }
const nextLocalDate = firstOfNextMonth(now(), customer.time_zone);
const localDt = `${nextLocalDate}T00:00:00`;
const resolved = await chronoshield.resolve({
local_datetime: localDt,
time_zone: customer.time_zone,
resolution_policy: {
ambiguous: "earlier", // very rare at midnight, but explicit
invalid: "next_valid_time", // never triggered at midnight, but explicit
},
});
return resolved.instant_utc;
}
// At each successful billing run:
const nextRun = await computeNextBillingUtc(customer);
await db.subscriptions.update({
where: { id: customer.subscription_id },
data: { next_billing_utc: nextRun },
});
// Idempotency layer: every charge attempt uses a key derived from the UTC instant
const idempotencyKey = `bill_${customer.id}_${nextRun}`;
await stripe.charges.create({...}, { idempotencyKey });
What this protects against: a customer in a region that suddenly changes its DST policy (Iran, Turkey, Brazil, Egypt have all done this on short notice in the last decade). ChronoShield's monthly tzdb refresh picks up the change; your billing recomputes the next instant correctly without code changes.
Try it on your trickiest schedule
Get a free API key (no card), POST one of your existing schedule rules to /v1/datetime/resolve, and see what the next 12 months of UTC fire times look like with explicit policy applied.