3 production bugs I shipped over time zones — and the 4 rules I now live by
Server in UTC, app in local time, database doesn't know what to store. Three times I burned real hours on time-zone bugs and the rules I keep ever since.
Time zones are the #1 reason code that “works on your machine” breaks in production. I’ve shipped all three classic time-zone bugs across 8 years of coding — here are the stories, plus the 4 rules I now live by so I don’t repeat them.
Bug 1: Storing local time in DB, then daylight saving arrives
I shipped this one in 2019, on a class scheduling system for an English center in Hanoi.
Schema at the time:
CREATE TABLE class_schedule (
id INT PRIMARY KEY,
start_at DATETIME, -- no time zone
duration_minutes INT
);
Insert: 2019-03-25 19:00:00 (7 PM evening class).
On my machine (TZ = Asia/Ho_Chi_Minh), Laravel Carbon::parse('2019-03-25 19:00:00') gave back exactly 7 PM. Staging server was VN-tz too. Tests passed, deploy.
Three weeks later, the client emailed: “Our Singapore branch is showing class times off by an hour.”
Turned out: that branch’s server was AWS Singapore, default TZ UTC. When classes were created from the Singapore web, the timestamp went into DB as UTC, but with no metadata saying it was UTC — just 2019-03-25 12:00:00. When the Vietnam web read it, code re-parsed in Vietnam tz → got 12 PM noon instead of 7 PM evening.
DST? Not in Asia, lucky. But on a US-client project right after, I hit the same shape, same bug — multiplied by every March and November.
Lesson: DATETIME without time zone is a knife waiting to cut you. Always use TIMESTAMP (MySQL) or TIMESTAMP WITH TIME ZONE (Postgres) — both auto-convert to UTC on store and back on read, based on the connection’s time_zone.
Bug 2: Cron on UTC server, customer expecting local time
2021, a B2B SaaS for car dealers. One feature was a “morning sales report” — emailed at 8 AM to each dealer.
I wrote:
0 8 * * * php /var/www/app/artisan reports:morning
Tested locally — fine. Deployed.
Next morning: zero emails. Then at 3 PM all the emails arrived in a flood.
Server was AWS Singapore, default UTC. 0 8 * * * = 8 AM UTC = 3 PM Vietnam. Should have been 0 1 * * * (1 AM UTC = 8 AM Vietnam).
But the problem didn’t end there. After the fix, a Da Nang dealer said it was fine. A month later, a Can Tho dealer complained. That’s when it hit me — Vietnam happens to be one tz, lucky us. But what if the client had been a US shop spanning Pacific/Mountain/Central/Eastern?
Lesson: cron jobs with time-zone-dependent business logic shouldn’t hardcode the time in crontab. Run cron */5 * * * * (every 5 min, just to tick), let the application code read the DB and figure out which dealer is due → send. The time-zone logic lives where the context is (DB stores each dealer’s tz), not in some random /etc/crontab.
Bug 3: new Date('2024-03-10') parsed differently across browsers
2023, an analytics dashboard. User picks a date range from a date picker. Frontend hits the API:
GET /api/sales?from=2024-03-10&to=2024-03-15
Server (Node, UTC) parses: new Date('2024-03-10') → 2024-03-10T00:00:00.000Z (UTC midnight). Queries DB from 2024-03-10 00:00 UTC to 2024-03-15 23:59 UTC.
A Vietnam user reported: “I asked for the report from March 10, but I’m seeing orders from late night March 9 in there.”
Correct. March 10 UTC midnight = March 10 7 AM Vietnam = late March 9 (in Vietnam) falls inside that window.
Fix: convert input to “start of day in Vietnam” before querying:
// Wrong
const from = new Date(req.query.from);
// Right
const from = new Date(`${req.query.from}T00:00:00+07:00`);
Smaller cousin: new Date('03/10/2024') — Chrome reads it as Mar 10 (M/D/Y, US format), Safari has at times read it as Oct 3 (D/M/Y, EU format). Inconsistent across browsers. Never parse a date string that lacks a timezone marker.
The 4 rules I now live by
After three burns, I have four hard rules. Every project I join, I push the team to adopt them:
Rule 1: Database stores UTC. Always.
Concretely:
- MySQL:
TIMESTAMP(auto UTC) instead ofDATETIME. Settime_zone = '+00:00'on the connection. - Postgres:
TIMESTAMPTZinstead ofTIMESTAMP. On insert, Postgres converts the input to UTC based on session tz. - MongoDB: BSON Date is UTC milliseconds natively — no tz to fight.
Server tz should also be UTC. No debate.
Rule 2: Convert at the display layer, not the data layer
Business logic works in UTC. Only at the edges — rendering UI or accepting user input — do you convert to local. Each user has a preferred time zone (stored in profile, never guessed from browser).
// Bad — converting in the middle of logic
const order = await Order.find(id);
order.createdAt = order.createdAt.toLocaleString('en-US'); // ⚠️ now a string
processOrder(order); // downstream consumers get a string instead of a Date
// Good — convert at the boundary, only for response
res.json({
...order,
createdAtDisplay: formatInTz(order.createdAt, user.timezone),
});
Rule 3: Every datetime leaving your API is ISO 8601 with offset
2024-03-10T00:00:00+07:00 ✅
2024-03-10T00:00:00Z ✅ (Z = UTC)
2024-03-10 00:00:00 ❌ (no tz, every parser guesses)
03/10/2024 ❌ (we don't even know which month)
Frontend parses with new Date(isoString) — every browser agrees when an offset is present.
Rule 4: Test against 3 simulated time zones
Before shipping any time-related feature:
# Test in UTC
TZ=UTC npm test
# Test in Vietnam
TZ=Asia/Ho_Chi_Minh npm test
# Test in US East (DST!)
TZ=America/New_York npm test
Pass all three → time-zone bugs are likely caught. Pass only one → you only tested on your own machine.
CI runs all three in parallel for integration tests:
# .github/workflows/test.yml
strategy:
matrix:
tz: [UTC, Asia/Ho_Chi_Minh, America/New_York]
env:
TZ: ${{ matrix.tz }}
Libraries I trust
JavaScript: luxon (the spiritual successor to moment.js, immutable, full IANA tz support). Or native Intl.DateTimeFormat for simple formatting.
PHP: Carbon — ships with Laravel. Carbon::now('Asia/Ho_Chi_Minh') is reliable in 99% of cases.
Python: zoneinfo (built in from 3.9+) instead of the older pytz.
Database: use the timezone-aware datetime types (TIMESTAMP / TIMESTAMPTZ), don’t reinvent.
Closing
Time zones are one of those parts of code where bugs don’t show up on your machine. You’re in Vietnam, dev local VN, staging is VN — everything looks fine. Then production deploys to AWS Singapore (UTC), or the first US customer signs up — and the bug surfaces. Too late.
If you remember just one thing from this post: DB stores UTC, convert at display, ISO 8601 with offset on the wire. Those three sentences alone block 80% of time-zone bugs.
The remaining 20%? That’s Daylight Saving Time. But that’s for another, much more tear-stained, post.