Timezone Bugs in Web Apps: The Ones We Actually Hit
Real timezone bugs from a production app — a due date showing the previous day, "today" starting at the server's midnight, server-rendered times in the wrong zone — why each happened, and the rules that prevent them.
Most timezone bugs in web apps come from three mistakes: treating a calendar date ("due October 2") as an instant (midnight UTC), computing "today" in the server's timezone instead of the viewer's, and formatting times during server rendering with the server's zone. The fixes are rules, not libraries: store instants in UTC and dates as dates, compute day boundaries in the viewer's IANA timezone, pass that timezone to the server explicitly, and test with a viewer and server in different zones — including awkward offsets like UTC+5:45.
Bug 1: the due date that showed the previous day
Symptom. A task due "Oct 2" displayed as "Oct 1" for users in the Americas.
Cause. The due date was stored as a timestamp at midnight UTC — 2026-10-02T00:00:00Z — and formatted with the browser's local zone. In Los Angeles (UTC−7 in October), midnight UTC is 5 pm on October 1. The instant was right; the question was wrong. A due date isn't an instant; it's a calendar day.
Fix. Treat date-only values as dates. LetRelay's task board now reads the year, month and day parts and builds a local date from them:
export function dueDay(dueAt: string): Date {
const [y, m, d] = dueAt.slice(0, 10).split("-").map(Number);
return new Date(y, m - 1, d); // a local calendar day, whatever the viewer's zone
}Better still, store date-only values in a date column rather than timestamptz. PostgreSQL's date/time types distinguish them for exactly this reason.
Rule: decide for every field whether it's an instant (a moment: "checked in at…", "created at…") or a calendar value (a day or a wall-clock time: "due on…", "birthday", "meeting at 9:00 in the office's zone"). Store and format them differently.
Bug 2: "today" started at the server's midnight
Symptom. An attendance page's "Today" list, for a team in one timezone, dropped morning check-ins or showed yesterday's for part of the day.
Cause. The query used date_trunc('day', now()) — midnight in the database session's zone (UTC) — while the team lived in UTC+5:45. For the first 5 hours 45 minutes of their day, "today" in UTC was still yesterday.
Fix. Compute the day boundary in the viewer's timezone and pass it to the query. The browser knows its IANA zone name:
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; // e.g. "Asia/Kathmandu"LetRelay sends it in a cookie; server code reads it, validates it against the list of known zones (falling back to UTC), and the database function truncates in that zone:
where checked_in_at >= (date_trunc('day', now() at time zone p_tz)) at time zone p_tzRule: "today", "this week" and "overdue" are questions about the viewer's calendar. Never answer them with the server's clock alone.
Bug 3: server-rendered times in the server's zone
Symptom. Times rendered correctly after the page loaded, but the first paint showed a different hour — or React warned about a hydration mismatch.
Cause. A component formatted a timestamp during server rendering. The server ran in one zone; the browser in another. The server's HTML said "09:30", the browser's render said "15:15".
Fixes, in order of preference:
- Know the viewer's zone on the server (from the cookie above) and format with it explicitly:
new Intl.DateTimeFormat("en", { timeZone: tz, hour: "2-digit", minute: "2-digit" }). - Or render times only on the client, with a placeholder on the server.
- Never rely on the process's default zone on the server. Serverless platforms commonly run in UTC, and your laptop doesn't.
Bug 4: tests that passed because of where we were
Symptom. A timezone test passed locally and proved nothing.
Cause. The developer machine was in Kathmandu, as was the simulated server; the bug only appears when viewer and server disagree. We also found that Node on Windows ignores the TZ environment variable, so "run the tests in another zone" silently didn't.
Fix. Pass zones explicitly in tests — format with an explicit timeZone rather than depending on the process's zone — and write end-to-end tests where the viewer is in one zone (we used America/Los_Angeles) and the server in another. Run date tests near midnight in the awkward zone, or mock the clock to be there: LetRelay's attendance check ran inside the 00:00–05:45 Kathmandu window, where a UTC-based "today" is visibly wrong.
The other classic traps
Daylight saving time
- Not every day has 24 hours; adding
24 * 60 * 60 * 1000milliseconds to "tomorrow at 9:00" is wrong twice a year. Add calendar days in the target zone instead. - Some local times don't exist (the skipped hour) and some happen twice (the repeated hour). A meeting scheduled at 02:30 on a transition day needs a defined behaviour.
- Recurring events should store the zone ("9:00 America/New_York"), not a fixed offset, so they follow DST. iCalendar (RFC 5545) records the time zone with events for this reason.
Offsets aren't zones
"UTC+5:30" isn't a timezone; "Asia/Kolkata" is. Offsets change with DST and law; IANA zone names carry the history. Store zone names, not offsets.
Parsing dates from strings
new Date("2026-10-02") is parsed as UTC midnight, while new Date("2026-10-02T00:00") is parsed as local midnight. The same-looking strings mean different instants. Parse explicitly.
Relative dates from natural language
"Friday", "tomorrow" and "next Monday at 3" depend on the user's zone and today's date in it. LetRelay's assistant resolves these with a deterministic, unit-tested parser that receives the user's zone, and shows the resolved date on a confirmation card before acting — see AI agent actions with human confirmation. Don't ask a language model to do date arithmetic.
Rules we now follow
- Instants are stored as
timestamptz(UTC) and displayed in the viewer's zone. - Calendar dates are stored as dates, or read by their date parts — never shifted through a timezone.
- The viewer's IANA zone is known to the server (a cookie), validated, and passed explicitly to formatting and queries.
- "Today", "this week" and "overdue" are computed in the viewer's zone.
- No code depends on the server process's default zone.
- Tests pin zones explicitly and include a viewer and server in different zones, one of them with a non-hour offset.
These rules also matter for deadlines and SLAs — see the SLA tracking guide — and for scheduling, covered in booking internal meetings.
FAQ
Why does my date show one day earlier in some timezones?
It's probably a date-only value stored or parsed as midnight UTC, then displayed in a zone behind UTC, where that instant falls on the previous day. Store dates as dates or format them from their date parts.
How do I get the user's timezone on the server?
Read it in the browser with Intl.DateTimeFormat().resolvedOptions().timeZone, send it to the server (for example in a cookie), validate it against known IANA zones, and pass it explicitly to formatting and queries.
Should I store timestamps in UTC?
Store instants in UTC (timestamptz in Postgres) and convert for display. Store calendar dates and wall-clock times as what they are, with a zone name when needed.
How do I test timezone handling?
Pin zones explicitly in unit tests, and run end-to-end tests with the viewer and server in different zones — ideally including a non-hour offset and a time near midnight.
Sources
Building Relay — the internal request desk with AI triage and SLA tracking.
Run your internal requests on LetRelay
AI triage, SLA-tracked queues, and bottleneck analytics — the help desk your team actually likes. Free to start.
Keep reading
Scheduled Jobs in Supabase with pg_cron: A Practical Guide
How to run scheduled jobs inside Postgres on Supabase with pg_cron — idempotent schedules in migrations, catch-up logic, locking down job functions, outbound HTTP with pg_net, monitoring runs, and the free-tier pause.
Validate Next.js Server Actions with Zod (and Authorize Them)
Server Actions are public POST endpoints. How to validate every input with Zod, check authentication and authorization inside each action, return safe errors to forms, and keep secrets and database logic server-only.
Supabase Security Definer Functions: The Exposure You Might Miss
In Supabase, public-schema functions are callable over the API — and SECURITY DEFINER ones bypass row-level security. What our audit found in 117 such functions, the real damage paths, and the grants and rules that close them.