Skip to content
Chapter 83Lesson 4

DST and recurring jobs

Scheduling future recurring jobs around Daylight Saving Time, naming an IANA timezone for wall-clock-meaningful work and running on UTC for internal cadence.

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

Every lesson before this one dealt with time that had already happened or was being stored: an invoice’s createdAt, a dueDate, the timezone on the user’s profile. This is the first lesson about scheduling time that hasn’t happened yet, and the future is exactly where DST causes trouble. 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. This lesson is the rule that connects them, and the whole rule fits in one sentence: a job whose fire time means something to a human in a place names a timezone, and a job that just needs to run on a cadence runs in UTC. Everything that follows hangs off that one distinction.

Before any scheduling code, you need a physical picture of what DST actually does, because the bug stays invisible until you can see the clock move. 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. If you ask for 2:30 AM, 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: 1:30 AM is two different instants, an hour apart. That is the entire mechanism, and it is why DST is a scheduling problem and not just a formatting curiosity. A naive scheduler asked to fire at 2:30 AM in the spring gap has nowhere to land. Asked to fire at 1:30 AM in the fall repeat, it has two places to land and no rule for choosing.

Scrub through the following diagram one transition at a time. Each panel is the same strip of hours. Watch what happens to the 1-to-3 AM band as you move from a normal day to each transition, and then to the band that’s always safe.

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 last panel gives you a rule you can follow 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 and never inside a repeat, so they can’t drift and can’t double-fire. This single habit sidesteps the overwhelming majority of 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.

There’s a deeper principle underneath the strip, and it’s the one to internalize: 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 is never ambiguous, because it carries no wall clock at all. Ambiguity only appears the moment you attach a wall-clock time and a zone together, which is precisely what a Temporal.ZonedDateTime is. So all of DST’s hard math lives in exactly one type, and the good news is that the scheduler owns most of it for you.

Now for the central decision. When a job is supposed to recur, you have three plausible ways to express it. The senior move is knowing that two of them are really the same answer and one of them is a trap.

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

Option B is to schedule a wall-clock expression plus a named IANA zone. You tell the scheduler “9 AM in America/New_York,” and it computes the next real instant using that zone’s DST rules every time. “9 AM Eastern” stays 9 AM Eastern all year: the scheduler quietly 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 specific local time.

Option C is a hybrid where you trigger on a UTC cron and recompute the right local time yourself in application code. It works, but it’s strictly more code for the exact same result as Option B. Reach for it essentially never. It’s worth naming only so you recognize that this is what Option B does for you under the hood, and you don’t reinvent it.

Collapse those three into the rule you’ll actually carry:

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

To route any job, use one question, the same kind of fast gut-check 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 customer’s 9 AM report, a billing-cutoff reminder, or a “your trial ends today” email timed to local business hours, it’s wall-clock-meaningful, so you name a zone. If no, as with a retention sweep, a metric rollup, or a cache warm, then all that matters is its cadence , and UTC is correct.

There’s a wrong answer that belongs to neither column, and it’s the one that ships silently: letting the server’s TZ decide. If you write a wall-clock schedule without naming a zone, the runtime fills in its own default timezone, and that default is different in different places. Your laptop runs in your local zone, while Vercel runs TZ=UTC. So an un-zoned “9 AM” schedule means “9 AM Eastern in dev, 9 AM UTC in prod.” It passes every test you run locally and then misfires the moment it deploys, because nothing in the code pins the zone and the runtime quietly disagrees with itself. The cure is a one-line habit: always pass the timezone explicitly, even when it’s UTC. Don’t let the runtime default decide when your job fires; make the intent legible in the code.

Walk the following decision tree. Start from the question a senior asks first, whether this time is meaningful to a human in a place, and route each concrete job to its home. Note that one of the branches is a deliberate decoy.

Where does this recurring job live?

The last leaf is the one to dwell on. Before you ask which zone a job belongs to, ask whether it recurs at all. A one-shot delivery is a different mechanism entirely, 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”

Let’s make the named-zone case concrete. The simplest shape is the static schedule from the background-work chapter: one global schedule, declared right in the task file, that the platform runs on your behalf. It’s DST-safe by construction, because in this form cron is an object, { pattern, timezone }, and the timezone you put there is what makes “9 AM Eastern” stay 9 AM Eastern across both transitions.

What this lesson adds, that the earlier chapter didn’t dwell on, is the seam: what the run handler actually receives, and how you get a Temporal value out of it. The payload hands you a Date, and a Date is exactly the type you spent this whole chapter keeping out of your domain. So you convert it the instant it arrives, using the codec from lib/temporal.ts, and the Date never travels any further. This is the same “Date at the seam, Temporal in the domain” discipline you’ve applied to database rows and Stripe payloads, now applied to a Trigger.dev payload.

Step through the definition below. It’s a weekday 9 AM Eastern summary. Watch the three places that matter: the zone, the handler signature, and the conversion at the seam.

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. This single field is what makes the schedule DST-safe. You could compute the right UTC time by hand, but you don’t. You name the zone and let the scheduler do the spring-forward and fall-back math. Pass it even here, where the intent is obvious, so the runtime default is never what 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 executes, the scheduler has already picked the correct real instant for “9 AM Eastern” on this date, using the zone’s rules. You didn’t compute that instant; the platform did.

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 this line 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 surfaces the genuinely hard part of this whole lesson, the part where juniors quietly ship bugs: 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, rather than declaring statically in code. That’s schedules.create, and there’s one shape detail that trips everyone, flagged back in the background-work chapter as the single most common mistake: 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, so it’s worth burning in.

You met this API already, so the following is recognition rather than a new concept. 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”

Here’s the scenario that separates a junior implementation from a senior one. 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 user changed their zone, the UI says Tokyo, and the report keeps arriving on New York’s clock. The propagation 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, because past intent is honored: a reminder scheduled in New York fires at the New-York-derived instant even after the user moves to Tokyo. The bug to avoid here is the opposite of the last one, a silent retroactive shift, where moving zones quietly drags every already-scheduled reminder an hour or three. If you do 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.

Read the following map of those 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. Do that and profile changes propagate for free, because there’s no schedules.update call to remember: the schedule reads the live zone each time it runs. It’s a real design choice with real upsides. It’s just not the default here, because it couples every fire to a profile read and makes the schedule’s behavior less self-contained. The point is that both options exist, and the senior move is to choose deliberately rather than default blindly.

Sort the following events into the two buckets. The whole exercise is the propagation model: 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 edges you still own: disambiguation and idempotent fires

Section titled “The edges you still own: disambiguation and idempotent fires”

The scheduler handles DST for you on cron-driven schedules. But there are exactly two places where it can’t, and a senior gets both right. They’re narrow on purpose, so don’t let this section grow in your head beyond these two.

Choosing what happens at a gap or a repeat

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

The moment you stop using a cron string and start constructing a wall-clock time yourself, with someZonedDateTime.with({ hour: 9 }) or by building a ZonedDateTime from a wall-clock value to compute a one-shot fire instant, you are now the one deciding what happens at a gap or a repeat. The scheduler isn’t in the loop anymore. Temporal hands you that decision through a disambiguation option, and to keep the cognitive load manageable, you only need two of its settings.

disambiguation: 'compatible' is the default, and it’s the default precisely because it needs no thought. 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 intuitively expect, which is why you’ll rarely override it.

disambiguation: 'reject' is the one deliberate opt-out. It 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 genuinely unacceptable and you’d rather the code stop and ask than quietly pick. Temporal does offer finer controls beyond these two for niche cases, but you can leave them on the shelf. Notice how the operating rule and the option reinforce each other: if you schedule in the safe 9 AM band, you never land in a gap or a repeat, so disambiguation never even 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. That’s from the background-work chapter, so it isn’t new. What is new is a DST-specific trap in how you build that key. Suppose you key it off the wall-clock string: `${scheduleId}:2026-11-01 01:30`. On a fall-back day, the 1:30 AM hour happens twice, and both fires produce the same wall-clock string, so they hash to the same key. One of two legitimate fires gets silently swallowed as a duplicate. The mirror failure is just as bad: a key too coarse to tell a genuine double-fire apart from a retry.

The structural 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 happen to share a wall-clock representation under fall-back are nonetheless different Instant values, an hour apart on the timeline. So keying on the instant makes the collision structurally impossible: the distinction the type already carries does the disambiguation for you. This is the same move you’ve seen all chapter, letting the type make the bug unrepresentable, applied to scheduling.

One last operational rule, worth stating once: never pre-compute a year of fire instants into a table. It’s tempting to calculate every future fire up front and store them, but the IANA timezone database, the tzdata , gets updated several times a year as countries change their DST rules, sometimes on short notice. 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, which is exactly what the scheduler does and exactly why you let it. The platform keeps tzdata current, so you never ship a hand-rolled timezone library to do its job.

Putting it together: cadence vs. wall-clock, side by side

Section titled “Putting it together: cadence vs. wall-clock, side by side”

Step back, because the whole lesson reduces to one contrast. Here are the two homes for a recurring job, placed side by side. Compare them closely: the difference between the two panels is the lesson.

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.

That’s the durable takeaway: wall-clock-meaningful jobs name a zone, cadence jobs run in UTC, and you pass the zone explicitly either way, even for UTC, so the runtime default is never what decides when your code fires. A junior writes a cron string and moves on. You ask “is this meaningful to a human in a place?” first, and the answer routes everything else.

Run a quick check on 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 primary sources behind everything in this lesson, the scheduling API and the disambiguation rules, are worth a bookmark, along with an interactive playground for watching DST move under your own hands.