All posts
Forward Development

Vendure scheduled tasks run in the server's timezone, not yours

Vendure evaluates cron expressions in the Node process timezone, so nightly jobs fire at the wrong local hour and drift at DST. How to audit and fix it.

  • Vendure
  • Migrations
  • Operations

The warehouse team says the nightly inventory file lands at 4am instead of 2am, and it used to land at 3am. Nobody deployed anything. The cron string in your ScheduledTask still reads 0 2 * * *. What changed is the calendar: the clocks went forward, and your task did not.

This is not a bug in your task. It is a gap in Vendure's scheduler contract. It was reported against @vendure/core 3.7.2 and verified on master at the time of the report, and it remains open as of 3.7.3, the current release.

Vendure never tells croner what timezone you meant

@vendure/core builds croner Cron instances in two places and passes no timezone option to either:

  • packages/core/src/scheduler/scheduler.service.ts, in createCronJob()new Cron(schedule, { name: task.id, protect: ... }, ...).
  • packages/core/src/plugin/default-scheduler-plugin/stale-task.service.ts, in getScheduleIntervalMs()new Cron(scheduleString), with no options at all.

Without a timezone, croner evaluates the expression in the process timezone. There is no timezone field on SchedulerOptions, ScheduledTaskConfig, or DefaultSchedulerPluginOptions, and SchedulerService.createCronJob is private — so there is no userland override. The only lever you have is the TZ environment variable on the process.

The docs make this invisible. The scheduled-tasks guide leads with cron.everyDayAt(3, 0) and cron.everyDayAt(0, 0). Neither the guide nor the API reference mentions timezones or UTC. "Every day at 03:00" reads like a promise about wall-clock time. It is actually a promise about whatever timezone the worker container happens to have — which in Docker, on most managed hosts, and in most CI-built images is UTC.

The obvious workaround collides with the other timezone advice

Set TZ=Europe/Stockholm and your schedules run at local wall-clock time. Problem solved — except you have just broken your timestamps.

VendureEntity.createdAt and updatedAt use @CreateDateColumn(), which maps to TIMESTAMP without time zone on Postgres. node-postgres both writes and reads that column in process-local wall-clock time. Vendure issue #3409 was closed on 2025-03-13 with exactly one piece of advice for that: set TZ=UTC.

So the two recommendations are mutually exclusive. TZ=UTC gives you correct naive timestamps and UTC-evaluated schedules. TZ=Europe/Stockholm gives you local schedules and reintroduces the naive-timestamp problem. There is no value of TZ that gives you both, which is why the current report is filed as a bug rather than a feature request.

The stale-task interval makes the drift worse than an hour

The second new Cron call site is not cosmetic. getScheduleIntervalMs() feeds stale-lock detection and DefaultSchedulerStrategy.computeLockHoldMs, and the computed value is cached for the lifetime of the process.

That matters for two reasons. First, if only one of the two call sites ever became timezone-aware, the assumed interval and the real job cadence would diverge — for a daily pattern that shows up at DST transitions, where the real day is 23 or 25 hours against an assumed 24, and for windowed expressions the gap is larger. Second, because the value is cached per-process, a long-running worker carries a stale assumption across a DST boundary until it restarts. The proposed fix in the issue routes both call sites through one shared helper for precisely this reason.

This is the kind of detail we look for when we take over an existing Vendure deployment: not "is the scheduler configured", but "do the scheduler's two internal clocks agree with each other".

Most of your tasks do not actually care what time it is

Before you change anything, sort your scheduled tasks into two buckets. This takes about ten minutes and it decides how much work you have.

Merely periodic. Cache warmers, search reindexes, session cleanup, health pings, retry sweeps. These need to run every N hours. Nobody downstream cares whether that is 02:00 or 04:00, and a one-hour DST shift is invisible. Leave these alone. If anything, rewrite them as interval expressions (0 */4 * * *) so the intent is unambiguous to the next person reading the file.

Time-of-day sensitive. Anything with a human or an external system on the other end: an order export a 3PL picks up at a fixed hour, an inventory sync timed to a supplier's file drop, a nightly financial close aligned to a business day boundary, a digest email meant to arrive before the shop opens, an end-of-day report that must cover that day in local terms. These are the ones that silently drifted.

The fastest audit: list every ScheduledTask registration in your project, then for each one write down the hour and who notices if it moves. If nobody notices, it goes in bucket one.

Fix the sensitive tasks without waiting for the option to land

Three mitigations, in the order we would apply them.

Pin TZ explicitly and write it down. Do not let the container's default decide. Set TZ=UTC in your process environment — not because UTC is prettier, but because it is the value #3409's resolution depends on, and because an explicit value survives a base-image change. An unset TZ is a schedule that can move when someone bumps FROM node:24-slim.

Express the schedule in UTC deliberately. With the process pinned to UTC, convert your intended local hour to UTC yourself and put a comment above the cron string saying what you did:

// 02:00 Europe/Stockholm during CET (UTC+1); this is 03:00 local during CEST.
// Accepted: this job is only sensitive to the CET half of the year.
new ScheduledTask({
    id: 'nightly-sync',
    schedule: '0 1 * * *',
    async execute() { /* ... */ },
});

That comment is the deliverable. It converts a silent drift into a documented tradeoff, and the next person to read it knows whether the offset is intentional.

Move local-time logic inside the task. For anything that genuinely must not slip, stop encoding the intent in the cron string. Schedule the task to run more often than you need — hourly, say — and have execute() check the current local hour in the target IANA zone (Intl.DateTimeFormat with timeZone, or your date library of choice) and return early if it is not the right one. It is a few extra no-op wakeups per day in exchange for a schedule that is correct on both sides of a DST boundary. It also puts the zone in TypeScript, where you can test it, instead of in a five-token string nobody reviews.

What to do this week

Check the TZ of your running worker container — not your local machine, the deployed worker. If it is unset, pin it. Then walk your ScheduledTask list and mark the time-of-day sensitive ones. For each of those, compare the hour it is actually firing against the hour you intended, and pick one of the three mitigations above.

Watch the upstream issue too. The proposed fix is additive and opt-in — timezone on SchedulerOptions as a global default, timezone on ScheduledTaskConfig as a per-task override, both resolved through one shared helper, with the default staying undefined so today's behavior is preserved. croner already exposes timezone on CronOptions and is already a dependency, so it is a pass-through rather than new scheduling logic. When it lands, the per-task override replaces the in-task hour check and you can delete the comments.

This class of problem — a default that is technically correct and operationally wrong — is what makes platform moves harder than they look on paper. If you are weighing a migration to Vendure or Medusa and want the scheduler, timestamp, and worker-topology questions answered before you commit, get in touch.

Need this done on a real stack?

Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.

Contact us