Skip to content

Event times across cities: how we stopped storing UTC and started storing local

7/28/2024Mobile DevelopmentEvent Ticketing Platform10 min read

The situation

A multi-city event-ticketing platform stores every concert, every theatre performance, every football match in the same Postgres table. The team made an early decision that looked obvious at the time: store every startsAt in UTC and convert on the way out. UTC is the universal anchor. UTC has no daylight saving. UTC is what every engineering blog post recommends. The team stored UTC, rendered local on the frontend, shipped the first ten venues, and moved on.

Eight months in, a Friday-night concert in a city two timezones east of the database server started displaying at the wrong hour. The customer who bought a ticket for "21:00" arrived at the venue to find the show had ended at 20:00 local time. The team spent the weekend tracing the bug, found that the timezone conversion on the frontend was using the customer's browser locale (not the venue's), and discovered something worse: there was no source of truth for "what timezone is this event in." UTC, it turned out, was the wrong place to anchor.

This is the post about what the team thought, what actually happened, and what they ship with now.

What we thought going in

The plan was clean. Every datetime column on every model is UTC. The Postgres column type is timestamptz, which is implicitly UTC at storage. The frontend converts to local time using Intl.DateTimeFormat and the user's browser timezone. The admin panel accepts a local time in the venue's region, converts to UTC on save, stores. No exceptions. No special cases.

This works perfectly for systems where there is one observer per event — Slack, email, calendar invites, anything where the question is "what time is this for the person reading it right now." It also works for systems where the event has no location — a livestream, a global webinar, a release at midnight UTC. The observer's local time is the correct local time, and UTC is the bridge.

The plan does not work for systems where the event has a fixed physical location and the customer expects the time displayed to be the local time at that location, regardless of where they are sitting when they look at the schedule.

What actually happened

A customer in Istanbul opened the page for a concert in Berlin. The concert is on a Saturday at 20:00 Berlin local time, which is 22:00 Istanbul local time, which is 19:00 UTC. The platform stored 19:00 UTC. The customer's browser, set to Europe/Istanbul, displayed the time as 22:00. The customer thought "great, 22:00 my time, I'll book a flight that lands at 19:00." The customer arrived at 22:00 Berlin time. The concert ended at 22:30 Berlin time. The customer missed almost the entire show.

This is not a customer error. The customer was reading the time the platform displayed. The platform was displaying the customer's local time. The customer reasonably assumed the time displayed was the time of the event, and the platform had no signal anywhere that disambiguated.

There was also a more pedestrian version of the bug in the venue management UI. The venue manager, sitting in Istanbul, was scheduling concerts for a venue in Izmir. Both cities are in the same timezone, so the local-vs-UTC conversion did not matter for save. But the team's database server (and one of the team members) was in London. When the venue manager set a concert for "20:00", the admin panel inferred the venue manager's browser timezone, converted to UTC, stored 18:00Z. The team member in London later edited the same concert from a London browser, saw "18:00" displayed (their local time was UTC at the time of the edit), assumed it was the storage value, and edited it back to "20:00" thinking they were correcting the UTC. The concert started at the wrong time for everyone.

Both bugs trace to the same root cause. The event has a place. The place has a timezone. Until the model knows that, every conversion is a guess.

The lesson, stated as a rule we would apply tomorrow

Store the local wall-clock time, store the timezone of the venue, and reconstruct the absolute moment on demand.

The lesson is not "stop using UTC." It is "UTC is a derived value, not a source of truth, when the event has a location." The Order datetime — when was this booking made — is UTC. The EventSession startsAt — when does the show start — is local-wall-clock plus a timezone.

The shape that landed:

model City {
  id        String  @id @default(cuid())
  slug      String  @unique
  name      String
  timezone  String  // IANA, e.g. "Europe/Berlin"
}

model Venue {
  id        String  @id @default(cuid())
  slug      String  @unique
  name      String
  citySlug  String
  city      City    @relation(fields: [citySlug], references: [slug])
}

model EventSession {
  id              String   @id @default(cuid())
  eventSlug       String
  venueSlug       String
  startsAtLocal   DateTime // wall-clock time at the venue's city
  endsAtLocal     DateTime // wall-clock time at the venue's city
  startSellingAt  DateTime // UTC — this one really is UTC
  paused          Boolean
}

startsAtLocal is a timestamp (no tz), not timestamptz. The renderer queries the city's timezone alongside the session and reconstructs the absolute moment for any consumer who needs it (sorting, "is this in the past," ticket emails, ICS exports). The customer-facing UI never displays anything but local-at-venue time, regardless of the customer's location.

The migration that got there:

-- 1) Add the local columns next to the UTC ones.
ALTER TABLE "EventSession"
  ADD COLUMN "startsAtLocal" timestamp,
  ADD COLUMN "endsAtLocal"   timestamp;

-- 2) Backfill by converting stored UTC + venue city timezone.
UPDATE "EventSession" es
SET "startsAtLocal" = ("startsAt" AT TIME ZONE c."timezone")::timestamp,
    "endsAtLocal"   = ("endsAt"   AT TIME ZONE c."timezone")::timestamp
FROM "Venue" v
JOIN "City" c ON v."citySlug" = c."slug"
WHERE es."venueSlug" = v."slug";

-- 3) Drop the UTC columns once the renderer has switched.

Two passes. The renderer was updated in a separate deploy to use startsAtLocal + the city's timezone via date-fns-tz. The old UTC columns lived alongside for a release while the team watched dashboards. Then they were dropped.

The query in the renderer:

import { fromZonedTime } from 'date-fns-tz';

function eventInstantUtc(session: { startsAtLocal: Date; venue: { city: { timezone: string } } }) {
  return fromZonedTime(session.startsAtLocal, session.venue.city.timezone);
}

function isInPast(session) {
  return eventInstantUtc(session) < new Date();
}

function formatForCustomer(session) {
  // Always render in the venue's local time, regardless of the customer's browser.
  return formatInTimeZone(
    eventInstantUtc(session),
    session.venue.city.timezone,
    "EEEE, d MMM yyyy 'at' HH:mm",
  );
}

Three helpers. Every consumer of an event's time goes through one of them. The browser's timezone is never consulted for rendering — only for the "your local time would be …" line that some customers find useful as a secondary display.

Where the lesson does not apply

The lesson is wrong for systems where the event has no place. A scheduled email blast going out at "9am to everyone in their local timezone" is the canonical inversion: there the recipient's timezone is the source of truth and UTC is the storage. A livestream that starts at "20:00 UTC" globally is another inversion — there is no venue.

It is also wrong for very small audiences in a single timezone. A team's internal calendar, a single-city league, an indie venue that only ever sells locally — for these, storing UTC and converting on the way out is fine, because the venue timezone and the customer timezone agree by definition.

The rule applies the moment the platform has more than one venue in more than one timezone, or has a single venue plus an out-of-city customer base. Both situations make the venue's local time the only display the customer will read correctly.

CTA — try the lesson against your last project

If your platform stores absolute timestamps for an event that happens in a physical place, find the place where the customer sees the start time and ask: whose local time is that? If the honest answer is "the customer's, via their browser timezone," there is a class of bug waiting that the team will discover the first time a customer travels for an event.

The migration is not as scary as it sounds. It is two columns, one backfill, and one renderer update. The hardest part — agreeing on which timezone is canonical for each venue — is a single City lookup if the venues are already linked to cities. They almost always are.

Trade-off

Storing local wall-clock plus timezone means every "sort by start time" query needs to join through to the venue's city. The query is slightly heavier than ORDER BY startsAt against a UTC column, and the index has to be on the join result for performance. Most platforms accept this cost because the alternative — the wrong time on a customer's ticket — is unacceptable.

There is also a subtle data-modelling cost. Wall-clock timestamps are ambiguous during daylight-saving transitions. A concert at "01:30 local time" on the night the clocks roll back happens twice; on the night they spring forward, it does not happen at all. The schema either accepts this ambiguity (the concert organiser picks one) or stores an explicit "this is the second 01:30" flag. Most platforms accept the ambiguity because no real venue schedules a show at 01:30 on a clock-change night.

Business impact

Customer trust is the slow-burning currency in ticketing. One wrong-time ticket spreads further than ten right ones. The shift from UTC-stored to local-stored eliminates an entire class of customer-support tickets: "the time on my ticket was wrong." For a venue manager, it eliminates an entire class of admin-panel confusion: "did I just save 20:00 or 18:00." Both savings compound. Every quarter where these tickets do not get filed is a quarter the team can spend on actual product work.

For the platform's brand, this is a quiet win. The customer never knows the platform thought about timezones at all — they just see the right time on the ticket. That is the standard the platform was supposed to meet, and it now does.

What to do next

If you are starting fresh, model startsAtLocal: timestamp and a timezone field on the venue (or on the city if your venues already link to cities). Add the two helpers, plumb them through every consumer, and ship.

If you have a UTC-based system in production, the cheapest move is to add the local columns alongside, backfill from the existing UTC values, and run the renderer off both with a feature flag. Once the flag has been on in production for a release without complaints, drop the UTC columns. The whole migration takes a small team a couple of days and the production risk window is one deploy.

The artifact to copy: a City.timezone column, a startsAtLocal timestamp on every event-like model, and three helpers (eventInstantUtc, isInPast, formatForCustomer) that every consumer of event time has to use. Everything else — emails, ICS exports, search filters — is a one-line call into those helpers.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox