VibeWeek
Home/Grow/Time Zone Handling: Stop Shipping Off-By-One Bugs to Customers in Tokyo

Time Zone Handling: Stop Shipping Off-By-One Bugs to Customers in Tokyo

⬅️ Day 6: Grow Overview

If you're shipping a SaaS in 2026 used by customers across time zones, time-zone bugs will eat you alive. The "looks fine in dev" / "broken for the Australia customer" pattern is the canonical TZ story. The bugs are sneaky: a Monday-morning report runs Sunday-night for Tokyo users; a "30 days ago" filter excludes today's data for the Sydney user; a Stripe webhook arrives at 11 PM local but the cron pretends it's tomorrow. Most of these come from one root error: storing local time instead of UTC, or rendering UTC without converting to user time zone.

A working time-zone strategy answers: where do you store timestamps (always UTC), where do you convert (only at the UI boundary), how do you handle user time zone (detect / store / let user override), how do you handle business time zones (company-headquartered HQ vs customer-localized), and how do you handle dates without time (the hardest case — birthdays, billing periods, daily reports).

This guide is the implementation playbook for time zones — the patterns, libraries, schemas, and pitfalls. Companion to Currency & FX Handling, Internationalization, and Cron & Scheduled Tasks.

Why Time Zones Matter

Get the failure modes clear first.

Help me understand why time zones cause bugs.

The categories of TZ bugs:

**1. Off-by-day filters**

User in Sydney; "today" filter on a UTC-stored field.
The day boundary is different. They see yesterday's data.

**2. Cron job timing**

"Send Monday-morning digest at 8 AM local."
Cron is UTC; sends at 8 AM UTC = midnight Sydney = wrong day for AU customers.

**3. DST transitions**

Bi-annual DST shifts shift "9 AM local" by an hour.
Recurring meetings drift; daily-report cron drifts.

**4. Date-without-time fields**

Birthday "1990-05-15" for user in Tokyo:
Stored as "2024-05-15T00:00:00Z" → in LA, that's May 14.
"Happy Birthday a day late" emails.

**5. Cross-region report aggregation**

"Daily revenue" for global SaaS:
Whose midnight is the day boundary? US? UTC? Customer's?

**6. Calendar events / scheduling**

User schedules "3 PM" in their TZ; rendered to attendee in different TZ.
DST flip mid-event. Half a meeting in EDT, half in EST.

The root cause for most of these: **mixing TZs in storage**.

For my context:
- Where TZs come up in our app
- What customer base looks like

Output:
1. Where TZs matter
2. Risk areas
3. Audit checklist

The biggest unforced error: storing localized timestamps in the database. Once you store "2026-04-30 14:00" with no offset, you've lost information. Was that LA time? UTC? You don't know — and the bug surfaces six months later when the customer's report is wrong.

The Core Rule: Store UTC, Convert at the Boundary

Help me set up the core TZ pattern.

The pattern:

**Storage layer (database)**:

- Always UTC
- TIMESTAMPTZ in Postgres (stores UTC; auto-converts on read using server TZ — set server TZ to UTC)
- Never TIMESTAMP WITHOUT TIME ZONE

**Application layer (code)**:

- All Date / Instant / DateTime objects in UTC
- Don't pass localized strings around
- Use ISO-8601 with Z suffix or explicit offset on serialization

**Boundary layer (UI / API output)**:

- Convert UTC → user TZ at the very last moment
- Pass user's IANA TZ name (e.g. "America/Los_Angeles") with the request
- Frontend renders with Intl.DateTimeFormat or library

**Boundary layer (UI / API input)**:

- User picks "April 30, 2 PM" in their TZ
- Frontend converts to UTC ISO string before sending
- Backend stores UTC

**Never**:

- Store localized timestamps
- Use date strings ("2026-04-30") for things that have a time component
- Use Date.toLocaleString() in business logic — only at display

For my stack:
- [Postgres / MySQL / etc.]
- [Backend language / framework]
- [Frontend framework]

Output:
1. Schema rules
2. Code rules
3. UI rules
4. Migration plan if you have non-UTC data

The single rule that prevents 90% of TZ bugs: the database holds UTC; conversion happens only at display.

Capturing the User's Time Zone

Most apps need to know each user's time zone for at least: scheduled actions, cron-style "9 AM their time" jobs, report rendering, and date filters.

Help me capture user time zone.

The two layers:

**1. Browser detection (default)**

```javascript
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
// e.g. "America/Los_Angeles"

Send this with sign-up; store on user record.

2. User override (optional)

In settings: TZ picker (e.g. "Pacific Time (Los Angeles)").

Why both: browser detection is right 95% of the time. The 5% is laptops in mixed-TZ travel; users moving; auto-detect picking wrong TZ.

Schema:

ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC';
-- Always IANA name (NOT abbreviations like "PST" — ambiguous)

Validation:

  • Must be IANA name ("America/Los_Angeles" not "PST")
  • Validate against the canonical list
  • Update on each login if browser-detected differs

TZ selector UI:

  • Show curated list (top 30 IANAs) + searchable full list
  • Group by region
  • Display as "Pacific Time (Los Angeles, San Francisco) — UTC-8"

Don't:

  • Use TZ abbreviations (PST, EST) in storage — ambiguous (CST = Central Std OR China Std)
  • Use UTC offsets ("UTC-8") in storage — DST changes; offsets drift

For my app:

  • [Where TZ matters]
  • [How to capture / when to prompt]

Output:

  1. Schema
  2. Sign-up flow
  3. Settings UI
  4. Validation

The classic mistake: **storing "PST" as the user's TZ**. PST/PDT switches twice yearly; "PST" is unambiguous when it's standard time but the offset is wrong half the year. Always store IANA names.

## Library Choices

Help me pick TZ libraries.

The 2026 landscape:

JavaScript:

  • Temporal (TC39 stage 3 → stage 4 in 2026)
    • Native date/time API; immutable; TZ-aware
    • Modern default; replaces moment / day.js for new code
  • date-fns-tz
    • date-fns + TZ support; tree-shakable
    • Battle-tested; migration path if Temporal unavailable
  • luxon
    • Successor to moment; TZ-aware; comprehensive
    • Good if you need parsing flexibility
  • moment — DEPRECATED 2020
    • Don't use for new code; bundle size + mutable API

Python:

  • zoneinfo (stdlib in 3.9+)
    • Native; uses system TZ data
    • Default for new Python
  • pendulum
    • Cleaner API; auto-DST; immutable
  • arrow — bridges naive/aware; less TZ-rigorous

Go:

  • time stdlib + IANA TZ data
  • Compile TZ data in: time/tzdata

Ruby / Rails:

  • ActiveSupport::TimeZone — built-in
  • TZInfo gem under the hood

Java / Kotlin:

  • java.time (Java 8+)
    • ZonedDateTime / Instant / OffsetDateTime
  • DON'T use Date / Calendar — legacy

For Postgres:

  • TIMESTAMPTZ everywhere
  • AT TIME ZONE for queries
  • Set server TZ to UTC

For my stack: [list]

Output:

  1. Library picks
  2. Why
  3. Migration if you have moment / Date / etc.

The 2026 shift: **Temporal is going GA in JavaScript**. New code on the frontend should target Temporal where supported (with polyfill); existing date-fns / luxon code is fine — don't rip-and-replace.

## Date-Only Fields (the Hardest Case)

Help me handle date-only fields.

The trap: "1990-05-15" stored as "2026-05-15T00:00:00Z"

  • For user in LA, that's May 14 9 PM
  • Birthday email goes out a day late

The categories of date-only:

1. True date (no time component)

Birthday, anniversary, due date, deadline date, billing date. Has no inherent time — depends on the user's TZ.

2. Date in a specific TZ

Subscription billing date in your business TZ ("billed on the 15th UTC"). Has a time but pinned to a TZ.

3. Date range with TZ

"Trial ends April 30 11:59 PM customer time." Has a time pinned to user TZ.

The right schema:

For category 1 (true date):

birthday DATE  -- store as DATE; no time; no TZ

Render: when displaying, treat as user's TZ. "Happy birthday" cron checks DATE against user's local current date.

For category 2 (date in business TZ):

billing_anchor_date DATE,
billing_timezone TEXT  -- usually 'UTC' or your HQ TZ

All date math done in that TZ.

For category 3 (date in user TZ):

trial_ends_at TIMESTAMPTZ,  -- store as UTC instant
trial_ends_user_timezone TEXT  -- pin which TZ this UTC instant maps to

Render: formatInTimezone(trial_ends_at, trial_ends_user_timezone).

The cron pattern for "send daily at 9 AM their time":

Don't run "for each user, calculate when 9 AM is, schedule" (too many cron entries).

Pattern:

  • Cron runs every 15 min, UTC
  • For each run, calculate which TZs are at 9 AM (round to nearest 15 min)
  • For each matching TZ, query users in that TZ and send

This means once cron in UTC; once query in user TZ; one path.

For my app:

  • Date-only fields list
  • Their categories
  • Schema today

Output:

  1. Per-field categorization
  2. Schema fixes
  3. Cron pattern

The hidden trap: **DATE columns in Postgres have no TZ**. Postgres `DATE '2026-05-15'` is just "May 15" — you must decide whose calendar that maps to. Document explicitly.

## Recurring Events & DST

Help me handle recurring events across DST.

DST shifts cause:

Spring forward (March in US):

  • 2:00 AM → 3:00 AM (1 AM-2 AM doesn't exist)
  • A meeting "2:30 AM weekly" lands on a non-existent time
  • Behavior: skip / move forward / move back?

Fall back (November in US):

  • 2:00 AM → 1:00 AM (1 AM-2 AM happens twice)
  • A meeting "1:30 AM weekly" — which 1:30 AM?
  • Behavior: first / second / either?

Cross-DST recurring patterns:

For "every Monday 9 AM Pacific":

  • January: 9 AM PST = 17:00 UTC
  • July: 9 AM PDT = 16:00 UTC
  • Storing 17:00 UTC = breaks in July

The right way:

event {
  rule: 'WEEKLY',
  byDay: 'MO',
  byHour: 9,
  timezone: 'America/Los_Angeles'
}

Compute next occurrence at query time; convert UTC at time-of-fire.

Library help:

  • rrule.js (frontend / Node) — RFC 5545 RRULE
  • python-dateutilrrule class
  • Both handle DST transitions correctly

The Google Calendar pattern:

GCal stores recurring events as RRULE in user TZ. When user moves TZ, "recurring 9 AM" stays at 9 AM in original TZ (or asks user "convert?"). This is the user-friendly default.

For my app:

  • What recurring events you have
  • DST behavior expected

Output:

  1. RRULE schema
  2. Library
  3. DST policy

The mistake to avoid: **storing recurring events as a list of UTC instants**. Six months later, DST shifted, every instant is wrong. Store the rule + TZ; compute instants on demand.

## API Design for Time Zones

Help me design TZ-aware API.

The contract:

Inputs (request body):

  • All times: ISO-8601 with Z or explicit offset
    • GOOD: "2026-04-30T14:00:00Z"
    • GOOD: "2026-04-30T14:00:00-08:00"
    • BAD: "2026-04-30 14:00" (ambiguous)
  • Dates without time: ISO date only
    • GOOD: "1990-05-15"
  • Optionally: caller's TZ in header
    • X-Timezone: America/Los_Angeles

Outputs (response body):

  • All times: ISO-8601 in UTC with Z suffix
  • "Display time" computed client-side
  • For data-export use cases (CSV), include TZ field separately:
    {
      "created_at": "2026-04-30T22:00:00Z",
      "created_at_local": "2026-04-30T15:00:00",
      "timezone": "America/Los_Angeles"
    }
    

Filters:

  • Date-range filters: caller specifies TZ for "today"
  • GET /reports?from=2026-04-01&to=2026-04-30&tz=America/Los_Angeles
  • Backend converts to UTC range using TZ

Don't:

  • Return localized strings
  • Accept date strings without TZ in time-having contexts
  • Mix TZ implicitly between fields

For my API:

  • Endpoint inventory
  • TZ-using fields

Output:

  1. Input contract
  2. Output contract
  3. Filter convention
  4. Migration plan

The pattern: **the API is UTC-only**. Clients send UTC; clients receive UTC. Display conversion is a client-side concern. Server-side does conversion only when user filters require it (e.g. "today in TZ X").

## Database Patterns

Help me set up TZ-correct database.

Postgres:

-- Server in UTC
ALTER DATABASE myapp SET timezone TO 'UTC';

-- Always TIMESTAMPTZ
CREATE TABLE events (
  id UUID PRIMARY KEY,
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),  -- UTC
  display_timezone TEXT  -- IANA, optional pin
);

-- Date-only when truly date-only
CREATE TABLE birthdays (
  user_id UUID REFERENCES users,
  date DATE NOT NULL  -- pure date; no TZ
);

-- Querying "today in user TZ"
SELECT * FROM events
WHERE occurred_at >= 
  date_trunc('day', NOW() AT TIME ZONE 'America/Los_Angeles')
  AT TIME ZONE 'America/Los_Angeles'
  AND occurred_at < 
  date_trunc('day', NOW() AT TIME ZONE 'America/Los_Angeles')
  AT TIME ZONE 'America/Los_Angeles' + INTERVAL '1 day';

MySQL:

-- Server in UTC
SET GLOBAL time_zone = '+00:00';

-- TIMESTAMP stores UTC implicitly; DATETIME does not
-- Prefer TIMESTAMP for instants:
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

-- For dates pinned to user:
display_timezone VARCHAR(64)

Common antipatterns:

  • TIMESTAMP WITHOUT TIME ZONE in Postgres for instants → ambiguous
  • DATETIME in MySQL for instants → also TZ-naive; bug-prone
  • VARCHAR storing "2026-04-30 14:00:00" → guaranteed bugs
  • Storing user's local time + offset separately → race conditions on DST

For my schema:

  • Tables / fields
  • Current types

Output:

  1. Schema audit
  2. Migrations
  3. Queries

The audit script every team should run: `SELECT column_name, data_type FROM information_schema.columns WHERE data_type LIKE '%timestamp%' OR data_type = 'datetime'`. Anything not TIMESTAMPTZ / TIMESTAMP-with-UTC in 2026 is a bug waiting.

## Cron & Scheduled Jobs Across Time Zones

Help me handle TZ-sensitive crons.

Three cases:

1. Business cron (UTC fine)

"Daily backup at 3 AM UTC" — TZ-agnostic. Just run UTC cron.

2. "9 AM in their TZ" digest

Pattern (covered above):

  • UTC cron every 15 min
  • For each run: which TZs are now at 9 AM (rounded)?
  • Query users matching those TZs; fire job

3. Business-hours-only operations

"Send sales emails only 9 AM-5 PM in customer TZ":

def can_send(user):
    tz = pytz.timezone(user.timezone)
    now_local = datetime.now(tz)
    return 9 <= now_local.hour < 17

Worker checks before sending; defers if outside.

Cron infrastructure:

  • Vercel Cron — UTC; configure in vercel.ts crons
  • AWS EventBridge — UTC by default
  • GitHub Actions cron — UTC
  • Cloudflare Workers cron — UTC
  • Inngest / Temporal — TZ-aware schedules built in

Inngest pattern:

inngest.createFunction(
  { id: 'daily-digest' },
  { cron: 'TZ=America/Los_Angeles 0 9 * * *' },
  async () => { /* runs 9 AM PT, DST-aware */ }
);

For my scheduler:

  • [Tool]
  • [TZ-sensitive jobs]

Output:

  1. Cron audit
  2. Pattern per category
  3. Tool migration if needed

The most common cron bug: **"9 AM" cron set in UTC for a business with mostly-EU customers**. 9 AM UTC = 4 AM in NY = 6 PM in Sydney = right time for nobody. Fix: per-TZ delivery using the every-15-min pattern, or move to a TZ-aware scheduler.

## Display Patterns

Help me render times in UI.

The patterns:

Absolute timestamps:

new Intl.DateTimeFormat('en-US', {
  dateStyle: 'medium',
  timeStyle: 'short',
  timeZone: user.timezone
}).format(date)
// "Apr 30, 2026, 2:00 PM"

Relative timestamps ("3 hours ago"):

Use Intl.RelativeTimeFormat or library (date-fns/formatDistance). TZ doesn't matter for relative.

Multi-TZ display (calendar / scheduling):

Show: "April 30 at 2 PM PT (5 PM ET, 10 PM UTC)" For attendees in different TZs.

Hover for full timestamp:

Display "3 hours ago"; tooltip shows full ISO + TZ. Pattern from GitHub, Slack.

TZ-explicit fields:

For audit logs, explicitly show TZ: "Logged in 2026-04-30 14:00:00 PT" Don't make user guess.

Don't:

  • Server-render localized strings (server doesn't know user TZ accurately)
  • Use toLocaleString() without explicit timeZone option
  • Mix TZ display in same view (one column local, one column UTC = confusion)

For my UI:

  • Timestamp display surfaces
  • User-facing vs internal

Output:

  1. Display rules
  2. Component patterns
  3. Audit checklist

The display rule of thumb: **client-side renders timestamps; server returns UTC**. SSR is the exception — render UTC initially, hydrate-replace with localized client-side. Or pass user TZ via cookie and SSR with it.

## Testing for Time Zone Correctness

Help me test TZ logic.

Test categories:

1. Process-level TZ

TZ=America/Los_Angeles npm test
TZ=Asia/Tokyo npm test
TZ=UTC npm test

Tests must pass in all TZs. CI should run at least: UTC + one non-UTC.

2. DST boundary tests

Pin test times around DST transitions:

test('schedules across spring forward', () => {
  const event = scheduleAt('2026-03-08T02:30:00', 'America/New_York');
  // 2:30 AM doesn't exist on DST day in NY
  expect(event.occurrence).toBe('...');
});

3. Date-only edge cases

User in LA at 11:59 PM creates record with "today's date":

test('birthday on user TZ boundary', () => {
  // Mock: now = "2026-05-15T06:30:00Z" (= May 14 11:30 PM PT)
  expect(getUserToday('America/Los_Angeles')).toBe('2026-05-14');
  expect(getUserToday('America/New_York')).toBe('2026-05-15');
});

4. Cron firing windows

Mock UTC time; verify which user TZs would fire:

test('9 AM digest fires at right UTC times', () => {
  // 17:00 UTC = 9 AM Pacific (DST)
  expect(usersToFireAt('17:00 UTC')).toContain('user-in-LA');
});

5. Round-trip tests

User input → store → retrieve → display = same value. "User picks April 30 2 PM in LA, sees April 30 2 PM in LA on read"

For my codebase:

  • TZ-touching modules
  • Coverage today

Output:

  1. Test patterns per module
  2. CI matrix
  3. Edge-case checklist

The single most useful CI change: **run tests in a non-UTC TZ as well as UTC**. `TZ=Pacific/Auckland npm test` catches half of "works on my machine" TZ bugs. Add to your CI matrix.

## Migration: Cleaning Up Legacy TZ-Naive Data

Help me migrate TZ-broken data.

The audit:

-- Find columns that should be TIMESTAMPTZ but aren't
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE data_type IN ('timestamp without time zone', 'datetime')
  AND table_schema = 'public';

Per-column decision:

  • Truly UTC-meaningful (created_at, updated_at) → migrate to TIMESTAMPTZ assuming UTC
  • Always-business-TZ (e.g. office_hours_start) → migrate + add explicit TZ column or document
  • Truly date-only (birthday, billing_anchor_date) → DATE type

The migration:

-- Postgres: assume legacy was UTC
ALTER TABLE events 
  ALTER COLUMN created_at TYPE TIMESTAMPTZ 
  USING created_at AT TIME ZONE 'UTC';

If legacy was actually local-server-TZ:

ALTER TABLE events 
  ALTER COLUMN created_at TYPE TIMESTAMPTZ 
  USING created_at AT TIME ZONE 'America/Los_Angeles';

(Pick the TZ your old server was running in.)

The harder one: text-stored dates:

You have created_at_str VARCHAR with "2024-04-30 14:00:00".

  • Step 1: assume a TZ (whatever your server was in)
  • Step 2: parse + convert to UTC TIMESTAMPTZ
  • Step 3: validate sample against expected
  • Step 4: cutover

Risks:

  • Wrong TZ assumption shifts every record
  • Validate against known good (a few records you can manually verify)
  • Backup before migrating

Code changes alongside:

  • All Date construction → UTC
  • All display → user TZ explicitly
  • All filters → take TZ argument

For my migration:

  • Affected tables
  • Suspected TZ assumption

Output:

  1. Audit query
  2. Per-table migration
  3. Validation
  4. Code change list

The biggest migration risk: **applying the wrong TZ assumption uniformly**. If half the records came from US-based servers and half from EU-based servers, a single ALTER will shift one half wrong. Audit by source before migrating.

## What Done Looks Like

A working TZ implementation:
- Database stores UTC (TIMESTAMPTZ in Postgres / TIMESTAMP in MySQL with UTC server)
- API contracts use ISO-8601 with explicit offset / Z
- User TZ stored as IANA name on user record
- Display conversion done at the UI layer, not in business logic
- Date-only fields use DATE type with explicit TZ semantics documented
- Recurring events stored as rules + TZ, not pre-computed instants
- Cron infrastructure either UTC + per-TZ dispatch, or TZ-aware scheduler
- CI runs tests in at least UTC + one non-UTC TZ
- DST transition tests exist
- No "PST" / "EST" / offset-only TZ storage

The proof you got it right: a customer in Sydney sees their "today" report return Sydney's today, not yesterday's. A customer in Tokyo gets the birthday email on their May 15, not May 14. A meeting at "9 AM Pacific" stays at 9 AM Pacific across DST.

## See Also

- [Currency & FX Handling](currency-fx-handling-chat.md) — companion localization concern
- [Internationalization](internationalization-chat.md) — language-localization patterns
- [Cron & Scheduled Tasks](cron-scheduled-tasks-chat.md) — TZ-sensitive job patterns
- [Database Migrations](database-migrations-chat.md) — for the TZ-data migration
- [Database Indexing Strategy](database-indexing-strategy-chat.md) — TIMESTAMPTZ index patterns
- [Logging Strategy & Structured Logs](logging-strategy-structured-logs-chat.md) — log timestamps
- [Customer Support Tools](https://vibereference.dev/product-and-design/customer-support-tools) — support tickets are TZ-sensitive
- [VibeReference: scheduling-tools](https://vibereference.dev/devops-and-tools/scheduling-tools) — TZ-aware schedulers