DEV Community

Cover image for The Timezone Bug That Cost Us a Client
APIVerve
APIVerve

Posted on • Originally published at blog.apiverve.com

The Timezone Bug That Cost Us a Client

The support ticket came in at 7 AM on a Tuesday.

"Why did our promotional email go out at 3 AM? We specifically set it for 9 AM."

I checked the logs. The email system showed the job executed at exactly 09:00:00. On time, as configured.

Then I saw the timestamp: 09:00:00 UTC.

The client was in New York. Eastern Time. Their 9 AM was our 2 PM. But the system didn't know that. It just saw "9 AM" and used server time.

The email hit inboxes at 4 AM Eastern. Their open rate was 2%. The client was furious. We nearly lost the account.

This is the story of how we fixed it, and everything I learned about timezones in the process.

Where It Went Wrong

The code looked innocent enough:

// The problematic code
const scheduledTime = new Date();
scheduledTime.setHours(9, 0, 0, 0);
scheduleEmail(campaign, scheduledTime);
Enter fullscreen mode Exit fullscreen mode

No timezone specified. Date objects in JavaScript use the server's local timezone. Our servers ran in UTC. The client thought in Eastern.

Nobody caught it in testing because our test accounts were... also in UTC. Our QA team was in the same office as our servers. Every test passed because the bug only manifested for users in different timezones.

We'd been running this code for months. Most of our early clients happened to be in Europe, close enough to UTC that the timing was "close enough." Nobody complained about an email arriving an hour early or late.

Then we signed a US client who scheduled everything for 9 AM Eastern, and 4 AM emails destroyed their campaign.

Why Timezones Are Hard

I used to think timezones were simple. There are 24 hours in a day, so there are 24 timezones, each offset by one hour. Right?

Wrong. So wrong.

There are more than 24 timezones. Nepal is UTC+5:45. India is UTC+5:30. Some Australian territories use UTC+9:30. The offsets aren't all whole hours.

Timezones change. Countries adopt new timezone rules. Russia eliminated daylight saving time in 2011. Egypt has flip-flopped on DST multiple times. Morocco's DST rules depend on Ramadan.

Daylight saving time isn't universal. The US and Europe observe it. Japan doesn't. Arizona doesn't (except the Navajo Nation, which does). Some places changed their minds (Russia, again).

DST transitions aren't simultaneous. The US changes in March; Europe changes in late March. For a few weeks each year, the US-to-UK offset changes from 5 hours to 4 hours, then back to 5.

Historical data gets complicated. What was the offset in a particular location on a particular date in 1985? It depends on what laws were in effect then. The IANA timezone database tracks this, going back decades.

The consequence: you cannot hardcode timezone offsets. Eastern Time is UTC-5 is only true sometimes. Half the year it's UTC-4. And that's assuming the US doesn't change its DST rules (which Congress periodically threatens to do).

The First Attempt: Offset Storage

Our first fix was to store an offset with each user:

// First attempt - storing offsets
const user = {
  email: 'client@example.com',
  timezoneOffset: -5  // Eastern Time... sometimes
};

function scheduleForUser(time, user) {
  const utcTime = new Date(time);
  utcTime.setHours(utcTime.getHours() - user.timezoneOffset);
  return utcTime;
}
Enter fullscreen mode Exit fullscreen mode

This broke immediately when DST changed. Users who'd set their offset in January were suddenly an hour off in March.

We tried to be clever: detect when DST changes and adjust offsets automatically. But that requires knowing which DST rules each user follows, which depends on their location, which we didn't have.

Offsets are a snapshot. They're not durable timezone information.

The Correct Approach: Timezone Identifiers

The fix was to store timezone identifiers, not offsets:

// Correct approach - timezone identifiers
const user = {
  email: 'client@example.com',
  timezone: 'America/New_York'  // IANA timezone identifier
};
Enter fullscreen mode Exit fullscreen mode

America/New_York encodes not just the current offset, but all the rules about when DST applies, historical changes, and future scheduled changes. Libraries that understand IANA timezones handle all the complexity.

function scheduleForUser(localTime, user) {
  // Using a proper timezone library
  const zonedTime = DateTime.fromObject(
    { year: 2026, month: 3, day: 15, hour: 9, minute: 0 },
    { zone: user.timezone }
  );

  // Convert to UTC for storage
  return zonedTime.toUTC().toJSDate();
}
Enter fullscreen mode Exit fullscreen mode

Now "9 AM" means "9 AM in the user's timezone," correctly handling DST transitions, historical rules, and everything else.

Getting the User's Timezone

Where does user.timezone come from? This is harder than it sounds.

Option 1: Ask the user. Most reliable, most friction. Users don't always know their IANA timezone identifier (America/New_York vs America/Chicago). Dropdown lists help, but it's still an extra step.

Option 2: Detect from browser. JavaScript can get the browser's timezone:

const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Returns something like "America/New_York"
Enter fullscreen mode Exit fullscreen mode

This works well but has caveats:

  • Users traveling show their current location, not home
  • VPN users might show the VPN's location
  • Some browsers in privacy mode obscure this

Option 3: Infer from location. If you know the user's city (from billing address, user input, or other context), you can look up the timezone:

async function getTimezoneFromCity(city) {
  const response = await fetch(
    `https://api.apiverve.com/v1/timezonelookup?city=${encodeURIComponent(city)}`,
    { headers: { 'x-api-key': API_KEY } }
  );
  const { data } = await response.json();

  return {
    timezone: data.timezone,        // "America/New_York"
    offset: data.timezone_offset,   // Current offset in minutes from GMT
    isDST: data.dst                 // Currently observing DST?
  };
}
Enter fullscreen mode Exit fullscreen mode

Best practice: Detect automatically, let users override, and remember their explicit choice. The browser timezone is a good default; user confirmation makes it reliable.

The Scheduling System Rewrite

With proper timezone support, we rewrote the scheduling system:

class ScheduledEmail {
  constructor(campaign, scheduledLocalTime, timezone) {
    if (!timezone) {
      throw new Error('Timezone is required for scheduling');
    }

    this.campaignId = campaign.id;
    this.timezone = timezone;
    this.scheduledLocal = scheduledLocalTime;  // "2026-03-15T09:00:00"
    this.scheduledUTC = this.convertToUTC(scheduledLocalTime, timezone);
  }

  convertToUTC(localTime, timezone) {
    const zoned = DateTime.fromISO(localTime, { zone: timezone });

    if (!zoned.isValid) {
      throw new Error(`Invalid time in timezone ${timezone}`);
    }

    return zoned.toUTC().toISO();
  }

  getDisplayTime(viewerTimezone) {
    // Show the scheduled time in the viewer's timezone
    const utc = DateTime.fromISO(this.scheduledUTC, { zone: 'UTC' });
    return utc.setZone(viewerTimezone).toFormat('MMM d, yyyy h:mm a ZZZZ');
  }
}
Enter fullscreen mode Exit fullscreen mode

Key changes:

  • Timezone is required, not optional
  • We store both local time (what the user intended) and UTC time (when it actually fires)
  • Display converts to the viewer's timezone with explicit zone labels

The UI Changes

Showing times without timezone context is ambiguous. We updated all time displays:

Before: "Scheduled for March 15, 9:00 AM"

After: "Scheduled for March 15, 9:00 AM ET (in 6 hours)"

The timezone abbreviation makes the time unambiguous. The relative time ("in 6 hours") provides an immediate sanity check without any timezone math.

function formatScheduledTime(email, viewerTimezone) {
  const scheduledUTC = DateTime.fromISO(email.scheduledUTC);
  const viewerLocal = scheduledUTC.setZone(viewerTimezone);

  const formatted = viewerLocal.toFormat("MMM d, yyyy h:mm a");
  const zone = viewerLocal.toFormat("ZZZZ");  // "ET", "PT", etc.

  const relative = scheduledUTC.toRelative();  // "in 6 hours"

  return `${formatted} ${zone} (${relative})`;
}
Enter fullscreen mode Exit fullscreen mode

Edge Cases We Discovered

Once we started taking timezones seriously, we found edge cases everywhere:

The DST gap. When clocks "spring forward," an hour disappears. 2:30 AM might not exist on that day. If a user schedules something for 2:30 AM on DST transition day, what happens?

// Handle the DST gap
const scheduled = DateTime.fromObject(
  { year: 2026, month: 3, day: 8, hour: 2, minute: 30 },
  { zone: 'America/New_York' }
);

if (!scheduled.isValid) {
  // This time doesn't exist - it fell into the DST gap
  // Option: Push to next valid time (3:30 AM)
  // Option: Error and ask user to pick different time
}
Enter fullscreen mode Exit fullscreen mode

The DST overlap. When clocks "fall back," an hour repeats. 1:30 AM happens twice. Which one does the user mean?

// Handle the DST overlap
const ambiguousTime = DateTime.fromObject(
  { year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
  { zone: 'America/New_York' }
);

// Luxon defaults to the first occurrence (still in DST)
// You may need to let users specify which they mean
Enter fullscreen mode Exit fullscreen mode

Timezone changes. Russia changed its timezone rules in 2014. If you had a recurring event scheduled, old occurrences used old rules, new occurrences use new rules. Our database had to track which version of timezone rules was in effect when each event was created.

Date vs datetime confusion. "March 15" is unambiguous anywhere in the world. "March 15, 9 AM" depends on timezone. "March 15, midnight" is especially tricky — in some timezones, that midnight might be March 14 or March 16 in UTC.

Recurring Events Are Worse

A one-time event is relatively simple. Recurring events add another dimension of complexity.

"Every Tuesday at 9 AM" sounds simple. But:

  • Which timezone's Tuesday?
  • Does 9 AM stay fixed through DST changes, or does the UTC time change?
  • What if the timezone rules change between occurrences?

We decided: recurring events store the local time and timezone, and recalculate UTC for each occurrence.

class RecurringSchedule {
  constructor(pattern, localTime, timezone) {
    this.pattern = pattern;      // "weekly:tuesday"
    this.localTime = localTime;  // "09:00"
    this.timezone = timezone;    // "America/New_York"
  }

  getNextOccurrence() {
    // Find next Tuesday
    let next = this.findNextMatch(this.pattern);

    // Apply local time in the user's timezone
    next = next.set({
      hour: parseInt(this.localTime.split(':')[0]),
      minute: parseInt(this.localTime.split(':')[1])
    });

    // Convert to UTC for scheduling
    return next.setZone(this.timezone).toUTC();
  }
}
Enter fullscreen mode Exit fullscreen mode

This means "9 AM Tuesday" stays at 9 AM local time even when DST changes the UTC offset.

Storing Times in the Database

We established a convention: all times in the database are UTC. No exceptions.

CREATE TABLE scheduled_emails (
  id UUID PRIMARY KEY,
  campaign_id UUID REFERENCES campaigns(id),
  scheduled_utc TIMESTAMPTZ NOT NULL,
  scheduled_local TIME NOT NULL,
  scheduled_date DATE NOT NULL,
  timezone VARCHAR(64) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

Why store both UTC and local? Because the local time is what the user intended. If timezone rules change, we might need to recalculate the UTC time, and we need the original local time to do that.

TIMESTAMPTZ in PostgreSQL stores the time in UTC and converts for display based on session timezone. This is what you want. Avoid TIMESTAMP WITHOUT TIME ZONE for anything user-facing.

Testing Timezone Code

Timezone bugs are hard to catch because your tests run in your timezone. We added explicit timezone testing:

describe('Scheduling', () => {
  it('schedules correctly for Eastern Time', () => {
    const email = new ScheduledEmail(
      campaign,
      '2026-03-15T09:00:00',
      'America/New_York'
    );

    // March 15 is during DST, so ET is UTC-4
    expect(email.scheduledUTC).toBe('2026-03-15T13:00:00.000Z');
  });

  it('handles DST transition correctly', () => {
    // March 8, 2026 is when DST starts
    const beforeDST = new ScheduledEmail(
      campaign,
      '2026-03-07T09:00:00',  // Day before
      'America/New_York'
    );

    const afterDST = new ScheduledEmail(
      campaign,
      '2026-03-09T09:00:00',  // Day after
      'America/New_York'
    );

    // Before DST: ET is UTC-5
    expect(beforeDST.scheduledUTC).toBe('2026-03-07T14:00:00.000Z');

    // After DST: ET is UTC-4
    expect(afterDST.scheduledUTC).toBe('2026-03-09T13:00:00.000Z');
  });

  it('handles different timezones', () => {
    const tokyo = new ScheduledEmail(campaign, '2026-03-15T09:00:00', 'Asia/Tokyo');
    const london = new ScheduledEmail(campaign, '2026-03-15T09:00:00', 'Europe/London');

    // Same local time, different UTC
    expect(tokyo.scheduledUTC).toBe('2026-03-15T00:00:00.000Z');   // UTC+9
    expect(london.scheduledUTC).toBe('2026-03-15T09:00:00.000Z');  // UTC+0 (before UK DST)
  });
});
Enter fullscreen mode Exit fullscreen mode

The Client Relationship

We kept the client. Barely.

The post-mortem email was long and detailed. We explained what went wrong, why it happened, and exactly what we'd changed to prevent it. We offered a free month of service.

They appreciated the transparency. The detailed technical explanation showed we understood the problem and took it seriously. The specific fixes we described gave them confidence it wouldn't happen again.

What could have been a lawsuit became a case study in how to handle mistakes well.

Lessons Learned

Never assume timezone. If time matters, timezone must be explicit. No defaults, no guessing.

Store times in UTC. Convert for display, but store canonically. UTC is the lingua franca of timestamps.

Use timezone identifiers, not offsets. America/New_York handles DST. -05:00 doesn't.

Show timezone in the UI. "9 AM ET" is unambiguous. "9 AM" is not.

Show relative time. "In 6 hours" provides a sanity check that doesn't require timezone math.

Test across timezones. Your default test environment masks timezone bugs. Test explicitly with different zones.

Handle edge cases. DST gaps, overlaps, timezone rule changes — they all happen. Decide how to handle them before they surprise you.

We'd been running the scheduling system for months before that Tuesday morning support ticket. The bug was there the whole time, waiting for the wrong combination of user timezone and scheduled time to expose it.

Now it's part of our engineering onboarding: the story of the 4 AM email and why timezones deserve respect.


The Timezone Lookup API returns IANA timezone identifiers from coordinates, handling all the complexity of timezone boundaries and DST rules. The World Time API gives you the current time in any timezone. Build scheduling that works for users everywhere.


Originally published at APIVerve Blog

Top comments (0)