·QueueHub Team·13 min read

Timezone-Aware BullMQ Scheduling: DST, Global Timezones & Production Patterns

BullMQTimezoneDSTJob SchedulingCronProduction PatternsMulti-TenantNode.jsQueueHub

08:00 UTC is 17:00 in Tokyo and 01:00 in Los Angeles. When you configure a BullMQ cron with pattern: '0 8 * * *' and forget the tz option, you're scheduling at 08:00 UTC — not 08:00 local time. For a Tokyo-based system, that means your "morning report" fires at 5 PM local time, every single day.

Earlier posts covered the what of BullMQ's scheduling API — how to use upsertJobScheduler, set up repeatable jobs, and handle delays. This post covers the when — timezone internals, DST transitions, testing strategies, holiday-aware scheduling, and the production problems that emerge when you deploy schedules across multiple timezones.

Prerequisites: Familiarity with upsertJobScheduler, RepeatOptions, and Worker setup in BullMQ v5.16+.


How BullMQ's tz Option Works Under the Hood

BullMQ doesn't perform timezone conversion itself. It delegates entirely to cron-parser, which uses luxon internally to resolve IANA timezone names. Understanding this chain is critical for debugging timezone-related issues.

The flow is straightforward:

upsertJobScheduler({ pattern, tz })
  → Queue.upsertJobScheduler() stores the options in Redis
  → Worker's polling loop calls Repeat.getNextMillis(millis, opts)
  → getNextMillis passes opts to parseExpression(pattern, { currentDate, tz })
  → cron-parser delegates timezone resolution to luxon.DateTime.setZone()

The getRepeatConcatOptions method builds the repeat key from name, jobId, endDate, tz, and a suffix. This means two schedulers with identical cron patterns but different timezones produce different repeat keys and are treated as independent schedules. It also means changing a tenant's timezone requires removing and re-creating the scheduler — you can't just update the tz field.

Here's the canonical pattern for scheduling across timezones:

import { Queue } from 'bullmq';
import IORedis from 'ioredis';

const connection = new IORedis({
  host: 'localhost',
  port: 6379,
  maxRetriesPerRequest: null,
});

const queue = new Queue('reports', { connection });

// Schedule a report at 8 AM Tokyo time — NOT 8 AM UTC
await queue.upsertJobScheduler(
  'daily-report-tokyo',
  { pattern: '0 8 * * *', tz: 'Asia/Tokyo' },
  { name: 'generate-report', data: { region: 'tokyo', format: 'pdf' } },
);

// Same pattern, New York time — different tz, different schedule
await queue.upsertJobScheduler(
  'daily-report-nyc',
  { pattern: '0 8 * * *', tz: 'America/New_York' },
  { name: 'generate-report', data: { region: 'nyc', format: 'pdf' } },
);

Important caveat: The tz option is only effective with pattern (cron). It has no effect on every (millisecond interval) schedules, which always operate on UTC wall-clock time:

// BAD: tz has no effect with every — this still runs every 3600000ms from the UTC epoch
await queue.upsertJobScheduler(
  'hourly-every',
  { every: 3_600_000, tz: 'America/New_York' },
  { name: 'task', data: {} },
);

If you need a repeating interval aligned to a specific timezone (e.g., "every hour starting at 9 AM Tokyo time"), use a cron pattern instead of every.


DST Transitions: Spring Forward, Fall Back

Daylight Saving Time is where the vast majority of timezone-related production incidents occur. BullMQ handles the two edge cases differently.

Spring Forward (the Gap)

In March, clocks jump from 01:59 to 03:00. The 02:00–03:00 hour doesn't exist. A cron pattern 0 2 * * * in America/New_York would normally fire at 2 AM, but on spring-forward day it can't.

BullMQ + cron-parser + luxon handles this gracefully — cron-parser advances to the next existing time, which is 03:00. The job still runs once that night, just an hour later than usual:

import { CronExpressionParser } from 'cron-parser';

const interval = CronExpressionParser.parse('0 2 * * *', {
  currentDate: new Date('2026-03-08T00:00:00.000Z'),
  tz: 'America/New_York',
});

console.log('Mar 7:', interval.next().toISOString());  // 2 AM EST
console.log('Mar 8:', interval.next().toISOString());  // 3 AM EDT — skipped gap
console.log('Mar 9:', interval.next().toISOString());  // 2 AM EDT — back to normal

Fall Back (the Fold)

In November, clocks fall back from 02:00 to 01:00. The 01:00–02:00 hour happens twice. A job configured for 0 1 * * * in America/New_York fires twice that night — once at 01:00 EDT (before the fall-back) and once at 01:00 EST (after):

const interval = CronExpressionParser.parse('0 1 * * *', {
  currentDate: new Date('2026-11-01T00:00:00.000Z'),
  tz: 'America/New_York',
});

const firstRun = interval.next().toISOString();   // 01:00 EDT (UTC-4)
const secondRun = interval.next().toISOString();  // 01:00 EST (UTC-5)

console.log('First run:', firstRun);   // 2026-11-01T05:00:00.000Z
console.log('Second run:', secondRun); // 2026-11-01T06:00:00.000Z

Production mitigation: If your job is not idempotent (e.g., charging a customer, sending a billing email), add a dedup check around the fall-back window:

import { Job } from 'bullmq';

async function processDstSafeReport(job: Job): Promise<unknown> {
  const prevMillis = job.opts?.repeat?.prevMillis as number | undefined;
  if (!prevMillis) {
    // Not a scheduler-created job — process normally
    return processReport(job);
  }

  // Check if this is a DST duplicate by comparing the expected UTC hour
  const expectedHour = new Date(prevMillis).getUTCHours();
  const nowHour = new Date().getUTCHours();

  if (nowHour === expectedHour) {
    // Same UTC hour — possible DST repeat
    const alreadyRan = await checkIfAlreadyProcessed(job.data.period);
    if (alreadyRan) {
      await job.discard();
      return { skipped: true, reason: 'DST duplicate' };
    }
  }

  await recordProcessing(job.data.period);
  return processReport(job);
}

Testing Timezone-Dependent Schedules

You can't test DST behavior by running BullMQ workers with real clocks — the transition happens at a fixed UTC moment, and your test suite won't wait for it. Instead, test the cron-parser output directly and use a mock repeat strategy for integration tests.

Unit Testing with CronExpressionParser

Test the timezone-relevant behavior of a cron pattern without spinning up any BullMQ infrastructure:

import { CronExpressionParser } from 'cron-parser';

interface ScheduleTestParams {
  pattern: string;
  timezone: string;
  currentDate: Date;
  expectedNext: Date;
}

function assertNextOccurrence(params: ScheduleTestParams): void {
  const interval = CronExpressionParser.parse(params.pattern, {
    currentDate: params.currentDate,
    tz: params.timezone,
  });
  const actual = interval.next().toISOString();
  const expected = params.expectedNext.toISOString();
  if (actual !== expected) {
    throw new Error(
      `Expected ${expected} but got ${actual} for pattern ${params.pattern} in ${params.timezone}`,
    );
  }
}

// 8 AM Tokyo = 23:00 UTC the previous day
assertNextOccurrence({
  pattern: '0 8 * * *',
  timezone: 'Asia/Tokyo',
  currentDate: new Date('2026-07-15T00:00:00.000Z'),
  expectedNext: new Date('2026-07-14T23:00:00.000Z'),
});

// Spring forward: 2 AM on Mar 8 should produce 3 AM EDT
assertNextOccurrence({
  pattern: '0 2 * * *',
  timezone: 'America/New_York',
  currentDate: new Date('2026-03-08T04:00:00.000Z'),
  expectedNext: new Date('2026-03-08T07:00:00.000Z'), // 3 AM EDT = 07:00 UTC
});

Testing the Full Pipeline with a Mock Repeat Strategy

For integration tests, create a test-specific repeatStrategy that lets you control time:

import { Queue, Worker, RepeatOptions } from 'bullmq';
import IORedis from 'ioredis';
import { CronExpressionParser } from 'cron-parser';

function createTestStrategy(clock: () => Date) {
  return (millis: number, opts: RepeatOptions): number | undefined => {
    const currentDate = clock();
    const interval = CronExpressionParser.parse(opts.pattern!, {
      currentDate,
      tz: opts.tz,
    });
    try {
      return interval.next().getTime();
    } catch {
      return undefined;
    }
  };
}

async function testDstBehavior(): Promise<void> {
  const connection = new IORedis({
    host: 'localhost',
    port: 6379,
    maxRetriesPerRequest: null,
  });

  const queue = new Queue('test-scheduler', {
    connection,
    settings: {
      repeatStrategy: createTestStrategy(
        () => new Date('2026-03-08T04:00:00.000Z'),
      ),
    },
  });

  await queue.upsertJobScheduler(
    'dst-test',
    { pattern: '0 2 * * *', tz: 'America/New_York' },
    { name: 'test-job', data: {} },
  );

  const schedulers = await queue.getJobSchedulers();
  const scheduler = schedulers.find(s => s.key?.includes('dst-test'));
  console.log('Next execution (UTC ms):', scheduler?.next);
  // Should resolve to ~07:00 UTC on Mar 8 (3 AM EDT, skipping the spring-forward gap)

  await queue.close();
  connection.disconnect();
}

Holiday-Aware and Business-Day Scheduling

BullMQ's cron scheduler doesn't know about holidays. A pattern 0 9 * * 1-5 fires on Christmas and New Year's Day. For production systems, you need a pre-processing layer.

Pattern: Holiday Calendar in the Worker

Use the date-holidays package to decorate your worker with holiday awareness:

import { Job } from 'bullmq';
import Holidays from 'date-holidays';

interface HolidayCheckOptions {
  country: string;
  state?: string;
  action: 'skip' | 'postpone' | 'prev-business-day';
}

async function holidayAwareWorker(
  job: Job,
  processor: (job: Job) => Promise<unknown>,
  options: HolidayCheckOptions,
): Promise<unknown> {
  const today = new Date();
  const hd = new Holidays(options.country);
  if (options.state) {
    hd.init(options.country, options.state);
  }

  const isHoliday = hd.isHoliday(today);

  if (isHoliday) {
    switch (options.action) {
      case 'skip':
        await job.discard();
        return { skipped: true, reason: `Holiday: ${isHoliday.name}` };

      case 'postpone': {
        const nextBusinessDay = findNextBusinessDay(today, hd);
        await job.discard();
        // Re-add the job with a delay to the next business day
        return { postponed: true, nextRun: nextBusinessDay.toISOString() };
      }

      case 'prev-business-day':
        // Run the job but log that it was moved forward
        console.log(`Job ${job.id} moved from holiday ${isHoliday.name}`);
        return processor(job);
    }
  }

  return processor(job);
}

function findNextBusinessDay(date: Date, hd: Holidays): Date {
  const next = new Date(date);
  next.setDate(next.getDate() + 1);
  while (next.getDay() === 0 || next.getDay() === 6 || hd.isHoliday(next)) {
    next.setDate(next.getDate() + 1);
  }
  return next;
}

const worker = new Worker(
  'reports',
  async (job) =>
    holidayAwareWorker(
      job,
      async (j) => ({ processed: true, reportId: j.data.reportId }),
      { country: 'US', state: 'NY', action: 'skip' },
    ),
  { connection },
);

Combining Holidays with Timezones

Holiday calendars are country-specific, but your schedules may run across multiple timezones. Convert the current time to the tenant's local date before checking holidays:

interface TenantSchedule {
  tenantId: string;
  timezone: string;
  holidayCountry: string;
  cronPattern: string;
}

async function processWithTimezoneHolidayCheck(
  job: Job,
  schedule: TenantSchedule,
): Promise<unknown> {
  const now = new Date();
  const hd = new Holidays(schedule.holidayCountry);

  // Convert current UTC time to tenant's local date
  const localDateStr = now.toLocaleDateString('en-CA', {
    timeZone: schedule.timezone,
  }); // Returns YYYY-MM-DD format
  const localDate = new Date(localDateStr + 'T00:00:00');

  if (hd.isHoliday(localDate)) {
    await job.discard();
    return { skipped: true, holiday: true, localDate: localDateStr };
  }

  return { processed: true, tenantId: schedule.tenantId };
}

Multi-Tenant Timezone Scheduling

A SaaS platform with tenants across Tokyo, New York, and London needs each schedule to fire at "8 AM local time" — not all at the same UTC instant.

Architecture: Timezone Per Tenant

Store the IANA timezone per tenant. When upserting schedulers, pass the tenant's timezone as the tz option. Because the repeat key includes tz, a timezone change requires removing and re-creating the scheduler:

interface TenantConfig {
  id: string;
  name: string;
  timezone: string;       // IANA timezone, e.g., "Asia/Tokyo"
  reportCron: string;     // e.g., "0 8 * * *" (8 AM local time)
}

async function syncTenantScheduler(
  queue: Queue,
  tenant: TenantConfig,
): Promise<void> {
  const schedulerName = `report-${tenant.id}`;

  // Remove existing scheduler — tz change invalidates the repeat key
  const existing = await queue.getJobSchedulers();
  const found = existing.find(s => s.name === schedulerName);
  if (found) {
    await queue.removeJobScheduler(schedulerName);
  }

  // Re-create with the correct timezone
  await queue.upsertJobScheduler(
    schedulerName,
    { pattern: tenant.reportCron, tz: tenant.timezone },
    {
      name: 'generate-report',
      data: { tenantId: tenant.id, timezone: tenant.timezone },
      opts: {
        removeOnComplete: { age: 86400 },
        removeOnFail: { age: 604800 },
      },
    },
  );
}

Displaying Local Times to Users

When showing users when their next report will run, always convert from the stored UTC timestamp to their local timezone:

import { CronExpressionParser } from 'cron-parser';

function getNextLocalRun(
  cronPattern: string,
  timezone: string,
): { utc: string; local: string } | null {
  const interval = CronExpressionParser.parse(cronPattern, {
    currentDate: new Date(),
    tz: timezone,
  });
  try {
    const nextUtc = interval.next().toDate();
    const local = nextUtc.toLocaleString('en-US', {
      timeZone: timezone,
      hour: '2-digit',
      minute: '2-digit',
      weekday: 'long',
      timeZoneName: 'short',
    });
    return { utc: nextUtc.toISOString(), local };
  } catch {
    return null;
  }
}

// Example
const result = getNextLocalRun('0 8 * * *', 'Asia/Tokyo');
console.log(`Next run: ${result?.local} (${result?.utc})`);
// "Next run: Thursday, 08:00 AM JST (2026-07-16T23:00:00.000Z)"

Cron Precision and SLA Monitoring

BullMQ doesn't fire scheduled jobs at exactly the cron-specified millisecond. There's always a polling delay determined by the Worker's drainDelay and blockingConnectionTimeout. For most use cases this doesn't matter. For SLA-sensitive systems, it does.

Measuring Schedule Drift

Jobs created by a scheduler carry opts.repeat.prevMillis — the scheduled time. Track the delta between this value and the actual processing time:

import { Job, Worker } from 'bullmq';
import IORedis from 'ioredis';

interface DriftMetric {
  jobId: string;
  schedulerName: string;
  expectedTime: number;
  actualTime: number;
  driftMs: number;
}

class ScheduleDriftMonitor {
  private metrics: DriftMetric[] = [];

  record(job: Job): void {
    const prevMillis = job.opts?.repeat?.prevMillis as number | undefined;
    if (!prevMillis) return;

    this.metrics.push({
      jobId: job.id!,
      schedulerName: job.name,
      expectedTime: prevMillis,
      actualTime: Date.now(),
      driftMs: Date.now() - prevMillis,
    });
  }

  getAverageDriftMs(): number {
    if (this.metrics.length === 0) return 0;
    return this.metrics.reduce((sum, m) => sum + m.driftMs, 0) / this.metrics.length;
  }

  getP99DriftMs(): number {
    if (this.metrics.length === 0) return 0;
    const sorted = [...this.metrics].sort((a, b) => a.driftMs - b.driftMs);
    const idx = Math.floor(sorted.length * 0.99);
    return sorted[idx].driftMs;
  }
}

async function setupSlaMonitoring(connection: IORedis.Redis): Promise<void> {
  const monitor = new ScheduleDriftMonitor();

  const worker = new Worker(
    'sla-sensitive',
    async (job) => {
      monitor.record(job);
      return { processed: true };
    },
    { connection, concurrency: 1, drainDelay: 1000 },
  );

  // Export metrics periodically
  setInterval(() => {
    console.log({
      avgDriftMs: Math.round(monitor.getAverageDriftMs()),
      p99DriftMs: Math.round(monitor.getP99DriftMs()),
      totalJobsTracked: monitor.metrics.length,
    });
  }, 300_000);
}

Factors That Increase Drift

Factor Typical Impact Mitigation
drainDelay (default: 5s) Up to 5s lag per cycle Set to 1000ms for SLA-critical queues
Redis contention 10ms–500ms per operation Dedicated Redis instance
Worker concurrency 100ms–2s per extra worker Match concurrency to workload
Slow job processing Cascading delay to next job Isolate scheduler jobs from heavy processing
Network latency to Redis 1ms–50ms per call Co-locate Redis in the same Availability Zone

Timezone Database Maintenance

Timezone definitions change. Governments abolish DST, shift timezone boundaries, or rename zones. When you update luxon (via a package.json upgrade), the underlying IANA timezone data changes.

Pattern: Timezone Fingerprinting

Detect when a timezone's definition changes by comparing the next five cron occurrences over time:

import { createHash } from 'node:crypto';
import { CronExpressionParser } from 'cron-parser';

interface TimezoneFingerprint {
  timezone: string;
  hash: string;
}

function fingerprintTimezone(tz: string, baseDate: Date): TimezoneFingerprint {
  const interval = CronExpressionParser.parse('0 8 * * *', {
    currentDate: baseDate,
    tz,
  });
  const nextFive = interval.take(5).map(d => d.toISO());
  const hash = createHash('sha256')
    .update(nextFive.join('|'))
    .digest('hex');
  return { timezone: tz, hash };
}

async function detectTimezoneDrift(
  queue: Queue,
  tzFingerprints: Record<string, string>,
): Promise<string[]> {
  const drifted: string[] = [];
  const baseDate = new Date();

  for (const [tz, prevHash] of Object.entries(tzFingerprints)) {
    const current = fingerprintTimezone(tz, baseDate);
    if (current.hash !== prevHash) {
      console.warn(`Timezone ${tz} definition changed! Re-creating schedulers...`);
      drifted.push(tz);
    }
  }

  return drifted;
}

Best Practices for Timezone Data

  • Store full IANA names ("America/New_York") in your database, never abbreviations like "EST" or "CST". Abbreviations are ambiguous — "CST" could be Central Standard Time, China Standard Time, or Cuba Standard Time.
  • Validate timezone names at input time using luxon's DateTime.setZone() to catch typos before they reach BullMQ.
  • Re-create schedulers when a timezone definition changes — because the repeat key includes tz, the old key still points to the old definition.

Conclusion

Timezone-aware scheduling transforms BullMQ from a "fire at UTC milliseconds" engine into a true local-time scheduler. The tz option on upsertJobScheduler is a single string property, but its implications span DST transitions, multi-tenant architecture, testing strategy, SLA monitoring, and timezone database maintenance.

Three key takeaways:

  1. Always pass tz when your cron pattern describes a local-time schedule. BullMQ handles the UTC conversion through cron-parser + luxon.
  2. Test DST boundaries with CronExpressionParser directly — never assume the library handles your specific timezone's transitions correctly.
  3. Monitor drift and holidays as separate concerns from the scheduling itself.

QueueHub helps visualize which schedulers are firing in which timezones, track drift metrics, and alert when DST transitions cause anomalous behavior. Try it free on your next BullMQ project.

Related Articles