Skip to content
Chapter 83Lesson 4

DST and recurring jobs

Scheduling recurring Trigger.dev jobs across Daylight Saving Time, naming an IANA timezone for wall-clock work and UTC for internal cadence.

A B2B customer has a daily report wired to fire at “9 AM Eastern.” It works all spring and summer. Then, on the morning of November 1, 2026, the report lands at 8 AM their time, and they file a ticket. You read the code and nothing is wrong: the cron expression still says nine, the timezone still says Eastern, and the job ran when it always ran. What shifted was the wall clock underneath it. On that date, Eastern time fell back an hour, and a schedule that ignored the shift drifted by sixty minutes.

Earlier lessons stored time that had already happened: an invoice’s createdAt, a dueDate, the timezone on a user’s profile. Scheduling is about time that hasn’t happened yet, and that future is where DST bites. You already hold both halves of the fix: the users.timeZone column from the profile lesson and Trigger.dev’s schedules from the background-work chapter. The rule that connects them is this: a job whose fire time means something to a human in a place names a timezone, and a job that just needs a cadence runs in UTC.

A DST-observing zone has two transitions a year, and they do opposite things.

Spring forward happens in the US on March 8, 2026: at 2 AM the clock jumps straight to 3 AM, so the hour from 2:00 to 2:59 does not exist that day. Ask for 2:30 AM and there is no real instant to point at. Fall back happens on November 1, 2026: at 2 AM the clock drops back to 1 AM, so the hour from 1:00 to 1:59 happens twice, making 1:30 AM two different instants an hour apart. This is why DST is a scheduling problem, not just a formatting curiosity: a job set for 2:30 AM in the spring gap has nowhere to fire, and a job set for 1:30 AM in the fall repeat has two places to fire and no rule for choosing.

12 AM
1 AM
2 AM
3 AM
4 AM
5 AM
An ordinary day. Every hour appears exactly once, evenly spaced, with no ambiguity anywhere.
12 AM
1 AM
2 AM skipped
3 AM
4 AM
5 AM
Spring forward, March 8 2026. The clock jumps from 1:59 AM straight to 3:00 AM, so the 2 AM hour does not exist. A job scheduled for 2:30 AM has no instant to fire at.
12 AM
1 AM 1st
1 AM 2nd
happens twice
2 AM
3 AM
4 AM
5 AM
Fall back, November 1 2026. The clock drops from 1:59 AM back to 1:00 AM, so the 1 AM hour happens twice. A job at 1:30 AM could fire twice, an hour apart.
12 AM
1 AM
2 AM
3 AM
4 AM
5 AM
never schedule in the 1 AM – 3 AM band
9 AM
12 PM
5 PM
always safe — never a gap or a repeat
The operating rule, in one picture: schedule in the green band, never in the amber one. 9 AM, noon, and 5 PM are never in a gap or a repeat.

The rule follows directly: never schedule a recurring job in the 1 AM to 3 AM window of a DST-observing zone. Nine in the morning, noon, and five in the afternoon are never inside a gap or a repeat, so they can’t drift and can’t double-fire, which sidesteps most DST pain. The rest of this lesson is for the times you can’t avoid that window, or when you construct a wall-clock time yourself instead of letting the scheduler do it.

The principle underneath: DST is a property of the calendar, not the timestamp. A Temporal.Instant is just a count of nanoseconds from a fixed epoch, so it carries no wall clock and is never ambiguous. Ambiguity appears only when you attach a wall-clock time and a zone together, which is exactly what a Temporal.ZonedDateTime is. So all of DST’s hard math lives in one type, and the scheduler owns most of it for you.

A recurring job can be expressed three ways, and two of them are really the same answer.

Option A: schedule in UTC and never touch it. You pick a fixed UTC time, and 0 13 * * * fires at 13:00 UTC every day. But 13:00 UTC is 9 AM in New York in winter and 8 AM in summer, because the UTC-to-Eastern offset shifts when DST flips. The customer’s wall-clock time moves by an hour twice a year. For a report read with morning coffee, that’s the bug from the introduction; for a job no human watches, it’s fine.

Option B: schedule a wall-clock expression plus a named IANA zone. You tell the scheduler “9 AM in America/New_York,” and it recomputes the next real instant from that zone’s DST rules every time. “9 AM Eastern” stays 9 AM Eastern all year: the scheduler fires at 13:00 UTC in winter and 14:00 UTC in summer, so the wall clock the customer sees never moves. This is the default for anything a human reads at a local time.

Option C: trigger on a UTC cron and recompute the local time yourself. It works, but it’s strictly more code for Option B’s result, so reach for it never; Option B already does this for you.

Collapse the three into one rule:

User-facing and wall-clock-meaningful → name the user’s or org’s zone. Internal cadence → UTC.

Route any job with a single gut-check, the same kind the calendar-days lesson used to tell a date from a timestamptz: “Would a human be upset if this ran an hour off?” If yes, as with a 9 AM report, a billing-cutoff reminder, or a “your trial ends today” email, it’s wall-clock-meaningful, so name a zone. If no, as with a retention sweep, a metric rollup, or a cache warm, only its cadence matters, and UTC is correct.

A third answer belongs to neither column and ships silently: letting the server’s TZ decide. Write a wall-clock schedule without a zone and the runtime fills in its own default, which differs by machine. Your laptop runs your local zone; Vercel runs TZ=UTC. So an un-zoned “9 AM” means “9 AM Eastern in dev, 9 AM UTC in prod”: it passes every local test, then misfires on deploy. The cure is one habit: always pass the timezone explicitly, even when it’s UTC.

Where does this recurring job live?

Dwell on the last leaf: before asking which zone a job belongs to, ask whether it recurs at all. A one-shot delivery is a different mechanism, and mistaking one for the other is its own class of bug.

Wiring a static schedule with schedules.task

Section titled “Wiring a static schedule with schedules.task”

The simplest shape is the static schedule from the background-work chapter: one global schedule, declared in the task file, that the platform runs for you. Here cron is an object, { pattern, timezone }, and naming the zone is what keeps “9 AM Eastern” at 9 AM Eastern across both DST transitions.

The new piece is the seam. The run payload hands you a Date, the type you’ve spent this chapter keeping out of your domain, so you convert it the moment it arrives and the Date travels no further. Same discipline as database rows and Stripe payloads: Date at the seam, Temporal in the domain.

The example is a weekday 9 AM Eastern summary.

import { schedules } from '@trigger.dev/sdk';
import { instantFromDate } from '@/lib/temporal';
import { buildSummaryFor } from '@/lib/reports';
export const weeklySummary = schedules.task({
id: 'weekly-summary',
cron: {
pattern: '0 9 * * 1-5',
timezone: 'America/New_York',
},
run: async (payload) => {
const fireInstant = instantFromDate(payload.timestamp);
await buildSummaryFor({ instant: fireInstant });
},
});

The object form of cron: a pattern plus an explicit timezone. That one field makes the schedule DST-safe. You name the zone and let the scheduler do the spring-forward and fall-back math, rather than computing UTC times by hand. Pass it even when the intent looks obvious, so the runtime default never decides.

import { schedules } from '@trigger.dev/sdk';
import { instantFromDate } from '@/lib/temporal';
import { buildSummaryFor } from '@/lib/reports';
export const weeklySummary = schedules.task({
id: 'weekly-summary',
cron: {
pattern: '0 9 * * 1-5',
timezone: 'America/New_York',
},
run: async (payload) => {
const fireInstant = instantFromDate(payload.timestamp);
await buildSummaryFor({ instant: fireInstant });
},
});

The run handler. By the time it runs, the platform has already resolved “9 AM Eastern” on this date to the correct real instant using the zone’s rules.

import { schedules } from '@trigger.dev/sdk';
import { instantFromDate } from '@/lib/temporal';
import { buildSummaryFor } from '@/lib/reports';
export const weeklySummary = schedules.task({
id: 'weekly-summary',
cron: {
pattern: '0 9 * * 1-5',
timezone: 'America/New_York',
},
run: async (payload) => {
const fireInstant = instantFromDate(payload.timestamp);
await buildSummaryFor({ instant: fireInstant });
},
});

The seam. payload.timestamp is a Date (UTC), not an ISO string, so you convert it with instantFromDate, not Instant.from(...). From here on you hold a Temporal.Instant, and the Date is gone.

import { schedules } from '@trigger.dev/sdk';
import { instantFromDate } from '@/lib/temporal';
import { buildSummaryFor } from '@/lib/reports';
export const weeklySummary = schedules.task({
id: 'weekly-summary',
cron: {
pattern: '0 9 * * 1-5',
timezone: 'America/New_York',
},
run: async (payload) => {
const fireInstant = instantFromDate(payload.timestamp);
await buildSummaryFor({ instant: fireInstant });
},
});

Everything downstream takes the Instant. The domain never sees the Date; it stopped at the seam one line up.

1 / 1

Per-tenant schedules and the propagation problem

Section titled “Per-tenant schedules and the propagation problem”

The static schedule is one global job. The SaaS-shaped case is different: each customer wants their report at 9 AM in their own zone. That’s not one schedule but N schedules, one per user, each carrying that user’s timezone. It also raises the hard part of this lesson: what happens to all that scheduled work when a user changes their timezone.

“Send me my report at 9 AM in my zone” is a per-user schedule you create at runtime when the user opts in, not something you declare statically in code. That’s schedules.create, and one shape detail trips everyone up: the object flips. In the static schedules.task form, cron is an object with timezone nested inside it. In schedules.create, cron is a plain string and timezone is a top-level field. Same concept, different shape, and the type checker won’t always catch the mistake.

Note externalId and deduplicationKey: they’re what make this idempotent. The externalId ties the schedule back to your user row, and the deduplicationKey means that if the opt-in handler runs twice, you get one schedule, not two.

src/jobs/daily-summary.ts
export const enableDailySummary = async (user: User) => {
await schedules.create({
task: weeklySummary.id,
cron: '0 9 * * 1-5',
timezone: user.timeZone,
externalId: user.id,
deduplicationKey: `summary:${user.id}`,
});
};
export const disableDailySummary = async (scheduleId: string) => {
await schedules.del(scheduleId);
};

The two highlighted lines are the shape flip: cron is a plain string here, and timezone sits at the top level, the inverse of the nested object in the static form. On opt-out, schedules.del(scheduleId) removes the schedule (the v4 method is del, not delete). The id comes from the create result, or from a schedules.list({ externalId }) lookup keyed on the user.

What happens when the user changes their zone

Section titled “What happens when the user changes their zone”

A user moves from New York to Tokyo and updates the timezone on their profile, the exact surface from the profile lesson. What should happen to everything that depended on their old zone?

The mistake is treating “their scheduled stuff” as one undifferentiated bucket. It’s three buckets, and they react to the change in three different ways.

Future recurring schedules need to be re-registered with the new zone. Their daily 9 AM summary should now fire at 9 AM Tokyo time. That schedule is still pending and still wall-clock-meaningful, so you update it: schedules.update(scheduleId, { task, cron, timezone: newZone }). Two shape notes, both echoing create: the first argument is a schedule id (resolve it from the externalId via the create result or schedules.list), and update takes cron as a plain string with a top-level timezone, the flat shape, not the nested object form. Skip this step and you’ve built the canonical “the profile timezone exists but the schedules still fire in the old zone” bug: the UI says Tokyo, and the report keeps arriving on New York’s clock. The chain is simple once you see it: a profile-zone change means you recompute the schedule’s zone.

Future one-shot instants should be honored as they are. A “remind me at 5 PM next Friday in my zone” job was converted to a fixed Temporal.Instant at scheduling time, back when the user was in New York. That instant is now a settled fact. Changing the profile zone does not retroactively shift it: a reminder scheduled in New York fires at the New-York-derived instant even after the user moves to Tokyo. The bug to avoid is the opposite of the last one, a silent retroactive shift that quietly drags every already-scheduled reminder an hour or three. If you want to offer a rebase, make it an explicit, opt-in prompt (“you have 4 scheduled reminders; update them to Tokyo time?”), never an automatic one.

Already-fired past instants never change. Last month’s report sent when it sent. History is immutable, so there is nothing to propagate.

The map below sets the three classes side by side: what each one is, whether it reacts to a zone change, and how.

Data class
What it is
Reacts to a zone change?
How
Data class Future recurring schedule
What it is A pending repeat, e.g. the daily 9 AM summary.
Reacts? Yes
How Re-register — schedules.update with the new zone.
Data class Future one-shot instant
What it is A single delivery already pinned to a Temporal.Instant.
Reacts? Honored as-is
How Don't touch it; offer an explicit rebase prompt if at all.
Data class Past, already-fired instant
What it is History — a job that already ran.
Reacts? Never
How Immutable; nothing to do.
A profile-zone change touches only future recurring schedules. Pinned one-shot instants and past fires are settled, so leave them alone.

There’s a legitimate alternative worth naming, because it changes the trade-off. Instead of storing the zone on each schedule and updating it on every profile change, you can have the job recompute the zone from the user’s profile on every fire. Then profile changes propagate for free, with no schedules.update call to remember, because the schedule reads the live zone each time it runs. The cost is that every fire now does a profile read and the schedule’s behavior is less self-contained. Both options are valid; choose deliberately.

Sort the following events into the two buckets: does this thing need its schedule re-registered, or is it already settled and should be left untouched?

A user just changed their profile timezone. Sort each piece of scheduled work into how it should react. Drag each item into the bucket it belongs to, then press Check.

Re-register the schedule Future, recurring, wall-clock-meaningful — recompute its zone
Honor as-is — don't touch it Settled, past, or never depended on the user's zone
The user’s weekly 9 AM digest, after they move to Tokyo
A one-shot reminder already scheduled for next Friday at 5 PM
Last month’s report, which already sent
A daily summary the user just enabled for the first time
An internal retention sweep running in UTC

The externalId is doing quiet work across all of this. It’s the externalId that lets you find a user’s schedule again to update its zone, or delete it on opt-out, without storing Trigger.dev’s internal schedule id yourself.

The scheduler handles DST for you on cron-driven schedules. Two cases it can’t, and you own both.

Choosing what happens at a gap or a repeat

Section titled “Choosing what happens at a gap or a repeat”

Once you stop using a cron string and start constructing a wall-clock time yourself, with someZonedDateTime.with({ hour: 9 }) or by building a ZonedDateTime to compute a one-shot fire instant, you decide what happens at a gap or a repeat. Temporal hands you that decision through a disambiguation option, and you need two of its settings.

disambiguation: 'compatible' is the default. On a spring-forward gap it moves forward by the length of the gap, picking the later instant, so a request for the missing 2:30 AM resolves to 3:30 AM. On a fall-back repeat it picks the earlier of the two possible instants. That matches what most people expect, which is why you’ll rarely override it.

disambiguation: 'reject' throws a RangeError the instant it hits a gap or a repeat, turning an ambiguous wall-clock time into a loud error instead of a silent guess. Reach for it when a fire that’s wrong by an hour would be unacceptable and you’d rather the code stop than quietly pick. Temporal offers finer controls beyond these two for niche cases. If you schedule in the safe 9 AM band you never land in a gap or a repeat, so disambiguation never fires.

Keying idempotency on the instant, not the wall clock

Section titled “Keying idempotency on the instant, not the wall clock”

Every scheduled fire carries an idempotency key, from the background-work chapter. The DST-specific trap is in how you build it. Suppose you key off the wall-clock string: `${scheduleId}:2026-11-01 01:30`. On a fall-back day the 1:30 AM hour happens twice, both fires produce the same wall-clock string, so they hash to the same key, and one of two legitimate fires gets silently swallowed as a duplicate.

The fix is to key on the Instant, not the wall clock. Compare the two approaches below.

const wallClock = fireZoned.toPlainDateTime().toString();
const key = `${scheduleId}:${wallClock}`;

Two distinct fires collide. Under fall-back, both 1:30 AM fires produce the same wall-clock string, so they share one key, and the second fire is silently dropped as a duplicate.

Two instants that share a wall-clock representation under fall-back are still different Instant values, an hour apart on the timeline. Keying on the instant makes the collision structurally impossible: the distinction the type already carries does the disambiguation for you.

One operational rule: never pre-compute a year of fire instants into a table. The IANA timezone database, the tzdata , gets updated several times a year as countries change their DST rules, sometimes on short notice, so a November instant you computed in January can be wrong by the time November arrives. Compute only the next fire, at fire time, from the current data. The platform keeps tzdata current, so you never ship a hand-rolled timezone library to do its job.

Here are the two homes for a recurring job, placed side by side.

export const retentionSweep = schedules.task({
id: 'retention-sweep',
cron: { pattern: '0 3 * * *', timezone: 'UTC' },
run: async () => {
const cutoff = Temporal.Now
.zonedDateTimeISO('UTC')
.subtract({ days: 30 })
.toInstant();
await deleteExpired({ before: cutoff });
},
});

Cadence, not wall clock. No human cares that it runs at 3 AM UTC, only that it runs nightly, so it’s UTC, stated explicitly. The 30-day subtraction happens on a ZonedDateTime and then converts to an Instant, because calendar-unit math needs a calendar.

Wall-clock-meaningful jobs name a zone, cadence jobs run in UTC, and you pass the zone explicitly either way so the runtime default never decides when your code fires.

Check the load-bearing claims before moving on.

Each claim is about how DST and recurring jobs behave. Mark each statement True or False.

A fixed UTC cron string like 0 13 * * * keeps a 9 AM Eastern report arriving at 9 AM Eastern all year round.

False. 13:00 UTC is 9 AM Eastern only in winter; once DST flips, the same UTC time is 8 AM Eastern. To pin the wall-clock time you name the zone and let the scheduler do the DST math.

disambiguation: 'compatible' throws an error when you ask for a time that falls in a spring-forward gap.

False — 'reject' throws. 'compatible' moves forward by the gap and picks the later instant (2:30 AM resolves to 3:30 AM); it never throws.

Keying a fire’s idempotency on its Temporal.Instant survives fall-back’s repeated hour, where a wall-clock-string key would not.

True. The two fires in the repeated hour are different Instant values an hour apart, so they produce different keys; identical wall-clock strings would collide.

When a user changes their profile timezone, their already-scheduled one-shot reminders should automatically rebase to the new zone.

False. A one-shot was pinned to a fixed Instant at scheduling time; past intent is honored. Offer an explicit rebase prompt if anything — never a silent automatic shift.

The scheduling API and the disambiguation rules, plus an interactive playground for watching a DST transition move under your own hands.