Timezones in Web Apps: Store UTC and Dodge the Bugs That Ship Anyway

Why timestamps break for users in other timezones — wall-time written as UTC, offsets that don't know about DST, cron running in the server's zone. The rules we apply in Laravel, Vue, and Go projects.

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.