- Published on
Timezone Bugs in Distributed Systems — When 9 AM Means Different Things
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Timezone bugs are uniquely frustrating: they work perfectly in development (single timezone, single machine), fail intermittently in staging (team in different cities), and cause real damage in production (global users, scheduled jobs running at wrong times, reports covering wrong date ranges).
- Rule #1: Store Everything in UTC
- Rule #2: Always Specify Timezone in Date Parsing
- Rule #3: Database Timezone Configuration
- Rule #4: Cron Jobs in the Right Timezone
- Rule #5: "End of Day" and Date Range Calculations
- Rule #6: Serializing Dates in APIs
- Timezone Safety Checklist
- Conclusion
Rule #1: Store Everything in UTC
// ❌ Storing local times in the database
await db.query(`
INSERT INTO appointments (user_id, scheduled_at, timezone)
VALUES (?, '2026-03-15 14:00:00', 'America/New_York')
`)
// What does 14:00:00 mean? Local time? UTC? Which timezone?
// Ambiguous, unordered, ungroupable by time
// ✅ Store UTC timestamps — convert to local only for display
import { toZonedTime, fromZonedTime } from 'date-fns-tz'
async function scheduleAppointment(
userId: string,
localTime: string, // '2026-03-15 14:00:00'
userTimezone: string // 'America/New_York'
) {
// Convert user's local time to UTC for storage
const utcTime = fromZonedTime(new Date(localTime), userTimezone)
await db.query(`
INSERT INTO appointments (user_id, scheduled_at_utc, user_timezone)
VALUES (?, ?, ?)
`, [userId, utcTime, userTimezone])
}
// Display: convert back to user's timezone
async function getAppointment(appointmentId: string, displayTimezone: string) {
const appt = await db.query('SELECT * FROM appointments WHERE id = ?', [appointmentId])
return {
...appt,
displayTime: toZonedTime(appt.scheduled_at_utc, displayTimezone)
.toLocaleString('en-US', { timeZone: displayTimezone }),
}
}
Rule #2: Always Specify Timezone in Date Parsing
// ❌ Parsing without timezone — depends on server locale
const date = new Date('2026-03-15')
// Interpreted as midnight UTC? Midnight local? Depends on environment!
const date2 = new Date('2026-03-15T14:00:00')
// Interpreted as local time — wrong on UTC servers!
// ✅ Always be explicit about timezone
import { parseISO } from 'date-fns'
import { fromZonedTime } from 'date-fns-tz'
// If you know the timezone of the input
const utcDate = fromZonedTime('2026-03-15T14:00:00', 'America/New_York')
// For API inputs: require timezone offset in ISO 8601
const iso = '2026-03-15T14:00:00-05:00' // East Coast (EST)
const utcFromIso = parseISO(iso) // Always parses correctly
// Validate ISO 8601 with timezone from API clients
function parseUserTimestamp(input: string): Date {
if (!input.match(/[+-]\d{2}:\d{2}$|Z$/)) {
throw new Error('Timestamp must include timezone offset (e.g., 2026-03-15T14:00:00-05:00)')
}
return parseISO(input)
}
Rule #3: Database Timezone Configuration
-- PostgreSQL: use TIMESTAMPTZ (timestamp with time zone) not TIMESTAMP
-- TIMESTAMPTZ stores UTC internally, converts on display
-- ❌ TIMESTAMP — stores whatever you give it, no timezone conversion
CREATE TABLE events (
created_at TIMESTAMP -- Ambiguous! Is this UTC or local?
);
-- ✅ TIMESTAMPTZ — always stored as UTC, timezone-aware queries
CREATE TABLE events (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Setting session timezone for display
SET timezone = 'America/New_York';
SELECT created_at FROM events; -- Now displayed in EST
-- Range queries work correctly with TIMESTAMPTZ
SELECT * FROM events
WHERE created_at >= '2026-03-15 00:00:00-05:00' -- Midnight Eastern
AND created_at < '2026-03-16 00:00:00-05:00';
// Node.js: set PostgreSQL client timezone to UTC
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
// Ensure all connections use UTC
pool.on('connect', (client) => {
client.query("SET timezone = 'UTC'")
})
// Also set Node.js timezone (important for date formatting)
// process.env.TZ = 'UTC' // Set at app startup
// Or: TZ=UTC node server.js
Rule #4: Cron Jobs in the Right Timezone
// ❌ Cron runs based on server timezone — could be UTC, could be anything
import cron from 'node-cron'
cron.schedule('0 9 * * *', handler) // "9 AM" — but where?!
// ✅ Use UTC for server-side cron — calculate the right UTC time
// "Run at 9 AM New York time" = "14:00 UTC in winter, 13:00 UTC in summer"
// DST makes this tricky — use timezone-aware libraries
import { CronJob } from 'cron'
// cron.js supports timezone natively
const job = new CronJob(
'0 9 * * *',
handler,
null,
true,
'America/New_York' // Timezone-aware — handles DST automatically
)
// For cloud cron (AWS EventBridge, GCP Cloud Scheduler):
// Always schedule in UTC, or use the timezone parameter if available
Rule #5: "End of Day" and Date Range Calculations
// ❌ Common mistake: "today's orders" uses server timezone
async function getTodaysOrders() {
const today = new Date()
today.setHours(0, 0, 0, 0) // Midnight SERVER TIME (might be UTC!)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return db.order.findAll({
where: { createdAt: { $gte: today, $lt: tomorrow } }
})
// User in Tokyo sees "today" as UTC day — might span 2 calendar days
}
// ✅ Always specify timezone for date range calculations
import { startOfDay, endOfDay } from 'date-fns'
import { toZonedTime, fromZonedTime } from 'date-fns-tz'
async function getTodaysOrdersForTimezone(userTimezone: string) {
const now = new Date()
const userNow = toZonedTime(now, userTimezone)
const startOfUserDay = fromZonedTime(startOfDay(userNow), userTimezone)
const endOfUserDay = fromZonedTime(endOfDay(userNow), userTimezone)
return db.order.findAll({
where: {
createdAt: {
$gte: startOfUserDay, // UTC equivalent of 00:00 in user's tz
$lte: endOfUserDay, // UTC equivalent of 23:59 in user's tz
}
}
})
}
Rule #6: Serializing Dates in APIs
// ❌ Sending JavaScript Date object — serializes to local server time string
res.json({ createdAt: new Date() })
// → {"createdAt": "2026-03-15T09:00:00.000Z"} ✅ (this is actually fine)
// But: if Date was created incorrectly, it propagates the bug
// ❌ Sending a formatted local time string
res.json({ createdAt: new Date().toLocaleDateString() })
// → {"createdAt": "3/15/2026"} — which timezone? Unusable!
// ✅ Always serialize as ISO 8601 UTC
res.json({
createdAt: new Date().toISOString(), // Always UTC with Z suffix
// → "2026-03-15T14:00:00.000Z"
})
// ✅ Or include timezone offset
res.json({
createdAt: {
utc: new Date().toISOString(),
userLocal: toZonedTime(new Date(), user.timezone).toISOString(),
timezone: user.timezone,
}
})
Timezone Safety Checklist
- ✅ Server timezone explicitly set to UTC (
TZ=UTC) - ✅ Database uses TIMESTAMPTZ (not TIMESTAMP) in PostgreSQL
- ✅ All dates stored and compared in UTC
- ✅ User timezone stored with their profile
- ✅ Convert to local only for display, never for storage
- ✅ Cron library configured with explicit timezone
- ✅ API inputs require ISO 8601 with timezone offset
- ✅ API outputs always in ISO 8601 UTC (
toISOString()) - ✅ Date range queries use explicit timezone conversion
Conclusion
Timezone bugs share a common root cause: treating "time" as if there's only one timezone. The fix is a single rule applied consistently — UTC everywhere internally, convert to local only at the display layer. Store TIMESTAMPTZ in PostgreSQL, set TZ=UTC on all servers, require ISO 8601 with offset on API inputs, always specify timezone when calculating date ranges, and use a timezone-aware cron library. Once you internalize "UTC internally, local for display," entire categories of timezone bugs become impossible.