Timezones in Web Apps: Store UTC and Dodge the Bugs That Ship Anyway
Timezone bugs share one signature: the feature works on the developer's machine, then a customer in another timezone reports that a daily digest arrived at midnight, or that a report is off by three hours. Nothing crashed. The data looks fine in the database and wrong on the screen, because the decision about which moment in time a value represents got made in the wrong place.
The rules below are short, and every one of them earns its keep the first time a user calls from a timezone you don't live in.
Store UTC, format late
Every timestamp in the database is UTC. Not "mostly UTC", not "UTC in production and local in dev" — always UTC. The user's timezone gets applied at the last possible moment, ideally in the browser, because the browser knows its own zone and the server doesn't. Format a date on the server and you've guessed the user's location; format it in the client and no guess is needed.
// Vue: format where the zone is known
dayjs(createdAt).format('MMM D, YYYY HH:mm') // uses the visitor's zone
The bug that ships most: wall-time written as UTC
This is the failure we've hit more than any other. A string like 2026-09-30 08:10 carries no timezone, so the language runtime fills in a default, which is usually the server's zone. If two machines with different zones parse the same string, the same moment gets stored twice, differently. Worse: one server parses it as local time and writes it into a column that every other part of the codebase believes is UTC. Nothing complains until someone notices that scheduled posts go out three hours late.
// wrong: parsed with the server's timezone
$date = new \DateTimeImmutable('2026-09-30 08:10');
// right: say which zone the string is in, convert, then store
$date = (new \DateTimeImmutable('2026-09-30 08:10', new \DateTimeZone('Europe/Riga')))
->setTimezone(new \DateTimeZone('UTC'));
JavaScript has the same trap with tighter spacing: new Date('2026-09-30T08:10') is parsed as local time, while new Date('2026-09-30T08:10:00Z') is UTC. The difference is three characters, and it moves the timestamp across half a day. When generating timestamps anywhere in the backend, end every datetime string with Z or attach the zone explicitly.
Offsets don't replace zones
UTC+03:00 describes a fixed offset; Europe/Riga describes a place and its rules, including when DST starts and ends. Store and accept named zones (the IANA list in PHP and most languages) and the twice-yearly seam handles itself. An offset is fine for display, never for storage or scheduling.
Scheduling: cron and the DST seam
Cron runs in whatever timezone the server has, which is a fact teams discover the first time a server moves to UTC and every "morning" job lands at a different local hour. Two fixes that work:
- Pin the clock. Set the system timezone explicitly (
timedatectl set-timezone Europe/Riga) or schedule with a tool that accepts a zone, and document it. - Compute customer-local runs. If a job must run at 09:00 for a customer, calculate that run time from the customer's zone inside a scheduler loop, rather than hardcoding a cron entry per customer.
The DST seam itself: in some zones, 02:30 doesn't exist on the spring transition day and occurs twice in autumn. Daily jobs scheduled in that window either misfire or fire differently in one season. Schedule them at a time that exists every day, or accept and test the seam.
Dates that aren't instants
Birthdays, invoice dates, contract deadlines: these are calendar dates, not moments. Store them as DATE columns, not timestamps. Passing a birthday through UTC conversion and back is how 1985-04-12 turns into April 11th for users west of you.
The cleanup pass
Once wall-time has leaked into a database, every fix is a data migration, so the cheap moment to check is before launch. Grep the backend for date formatting (it belongs in the client), for datetime parsing without a zone argument, and for hard-coded offsets. Run the test suite once with TZ=Pacific/Midway and once with TZ=Pacific/Kiritimati, the near-opposite edges of the clock. Anything sensitive to wall-time breaks loudly, which is exactly what you want before a customer's accountant finds it.