Time Zone Handling: Stop Shipping Off-By-One Bugs to Customers in Tokyo
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:
- Schema
- Sign-up flow
- Settings UI
- 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:
- Library picks
- Why
- 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:
- Per-field categorization
- Schema fixes
- 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 RRULEpython-dateutil—rruleclass- 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:
- RRULE schema
- Library
- 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:
- Input contract
- Output contract
- Filter convention
- 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:
- Schema audit
- Migrations
- 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:
- Cron audit
- Pattern per category
- 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:
- Display rules
- Component patterns
- 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:
- Test patterns per module
- CI matrix
- 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:
- Audit query
- Per-table migration
- Validation
- 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