Dual Storage for Time: Designing Timezone-Safe Systems at Scale

Published
•14 min read•View as Markdown
Dual Storage for Time: Designing Timezone-Safe Systems at Scale
J

I write technical articles that analyze real world engineering problems through the lens of production systems.

My work focuses on architectural tradeoffs, UI layer system design, performance optimization, and reliability patterns derived from shipping and maintaining scalable applications.

Note: This article was originally published on Medium and is republished here for wider access and long-term reference.

How a seemingly simple feature uncovered the true complexity of global time, and why even industry giants struggle to get it right.

The Deceptively Simple Requirement

“Users should be able to schedule events across time zones.”

Four months ago, I thought this was straightforward. We’re building a platform where users in New York and Mumbai collaborate daily. They need to schedule sessions, and everyone should see the right time. Standard stuff, right?

Wrong. This feature became a masterclass in distributed systems design, edge case handling, and why time is perhaps the hardest problem in software engineering that nobody talks about.

The Wake-Up Call: When Filters Break Reality

We were building a filtering system for scheduled events. Simple feature: users filter by date range, status, participant. Should have taken a day.

Instead, we discovered our entire time handling was broken.

The symptoms:

  • Filter for “January 19” returns events from January 18 and 20

  • Event shows “9:00 PM” in the UI, but 9 PM where? New York? Mumbai? UTC?

  • Backend returns 2026-01-19T22:00:00Z, frontend displays it differently for each user

The filter wasn’t working because we had no consistent answer to: “What does this timestamp represent?”

This wasn’t a bug to fix. It was a fundamental architectural gap that would have corrupted data at scale if we shipped without addressing it.

The False Start: Why We Couldn’t Ship

Here’s what made this an urgent architectural decision rather than a “tech debt to fix later”:

Without timezone handling:

  • Every event scheduled across timezones would be ambiguous

  • Users editing events would accidentally shift times by hours

  • Any data already created would need manual correction

  • DST transitions would silently break recurring events

The business impact of shipping broken:

  • Users manually converting timezones (failure-prone, frustrating)

  • Support burden for “wrong time” confusion (unsustainable)

  • Data integrity issues requiring bulk updates (high-risk migration)

  • Lost credibility with early users (can’t afford as a new feature)

The tradeoff I faced: Delay shipping by 2 weeks to build timezone handling correctly, or ship fast and inherit months of data cleanup and support tickets.

I chose the delay. Here’s why that was the right architectural call.

The Research Phase: Learning from the Giants

Before implementing anything, I studied how companies that have to get this right approach the problem. This wasn’t about copying; it was about understanding the decision space.

Google Calendar’s Approach

Google stores events using dual representation:

{
  "start": {
    "dateTime": "2026-01-19T22:00:00-05:00",
    "timeZone": "America/New_York"
  }
}

Why both? Because they solve two different problems. An RFC 3339 timestamp with an offset captures the exact moment in history, while the timezone captures the intent: “this should happen at 10 PM New York time, even if DST rules change.”

This design decision prevents a subtle but critical bug: if DST rules change retroactively (Brazil did this in 2019), events shift with the intent, not stay fixed to historical offsets.

Key insight: Storing offset alone loses scheduling intent. Storing timezone alone loses the absolute moment. You need both.

Stripe’s Webhook Pattern

Stripe deals with global payments across timezones:

{
  "created": 1737158400,
  "scheduled_for": 1737244800,
  "timezone": "America/New_York"
}

All business logic runs on Unix timestamps (UTC). The timezone is metadata for display only.

This works for Stripe because payment deadlines are absolute moments. 11:59 PM UTC is a hard cutoff, regardless of where the user is.

Key insight: When the business logic needs absolute time (sorting, comparisons, deadlines), Unix timestamps eliminate ambiguity.

Microsoft Outlook’s Hybrid Model

Outlook does something clever for recurring events:

{
  "start": {
    "dateTime": "2026-01-19T22:00:00",
    "timeZone": "Eastern Standard Time"
  }
}

No offset. The dateTime is local to the specified timezone, and conversion happens at read time.

This prevents the “shifting recurring events” problem where a weekly 9 AM meeting suddenly becomes 10 AM after DST transitions.

Key insight: For recurring events, preserving local time intent matters more than fixing to an absolute moment.

The Pattern: Why All Three Approaches Work

Despite different implementations, all three share a fundamental principle:

Store both the moment (when in absolute time) and the context (how humans think about it).

You need both because:

  • Computers care about moments: Sorting, duration calculations, “is this event in the past?”

  • Humans care about context: “I meant 9 AM New York time, not 9 AM UTC”

The systems that fail are the ones that try to pick one and derive the other. That’s a lossy conversion.

The Architectural Decision: Our Solution

Given our use case, scheduling sessions where users need to edit events and understand “when” from their perspective, I designed this contract:

Request (Creating an Event)

{
  "scheduledDate": "2026-01-19",
  "scheduledTime": "22:00", 
  "durationMinutes": 60,
  "eventTimezone": "America/New_York"
}

The client provides the intended local time and timezone. This is what the user actually selected: “January 19 at 10 PM Eastern.”

Response (Retrieving an Event)

{
  "eventId": 40,
  "startTimeUtc": "2026-01-20T03:00:00Z",
  "endTimeUtc": "2026-01-20T04:00:00Z",
  "scheduledDateTime": "2026-01-19T22:00:00",
  "eventTimezone": "America/New_York",
  "durationMinutes": 60
}

The server returns both representations:

  • startTimeUtc / endTimeUtc: The absolute moment (for sorting, filtering, logic)

  • scheduledDateTime + eventTimezone: The original intent (for editing, display)

Why this architecture:

  1. Single source of truth for “when”: startTimeUtc is unambiguous for all backend operations

  2. Preserves user intent: scheduledDateTime in eventTimezone shows what the user actually scheduled

  3. No conversion loss: Edit → Save → Display round-trips preserve original values

  4. Filter-friendly: Queries on UTC timestamps are straightforward

Alternative I rejected: Storing only UTC and converting to timezone on read.

Why I rejected it: You lose the original timezone context. If a user schedules “9 AM New York time” and later edits it, what timezone should the form show? Their current browser timezone? The event’s timezone? Without storing the original, you’re guessing.

This causes the exact bug we were trying to prevent: users editing events and accidentally shifting times.

The Implementation: What Actually Changed

Working with my backend engineer, here’s what we built:

Backend Storage Strategy

We store four critical pieces:

  • start_time_utc: The absolute moment (TIMESTAMPTZ in Postgres)

  • end_time_utc: The absolute end moment

  • scheduled_datetime: The original local time (TIMESTAMP without timezone)

  • event_timezone: IANA timezone identifier (e.g., "America/New_York")

The scheduled_datetime has no timezone attached because it's local to event_timezone. Together, they capture intent.

We also store duration_minutes separately because DST transitions can make "1 hour duration" ambiguous when crossing a DST boundary.

Frontend Library Choice: Luxon

I chose Luxon over moment.js or date-fns because:

  • First-class timezone support via IANA database

  • Immutable API prevents accidental mutations

  • Explicit about timezone context (forces you to think correctly)

  • Better DST transition handling

The critical pattern in our frontend code:

When creating an event:

const handleSubmit = async () => {
  const { date, time, timezone } = formData;

  // Build local datetime in selected timezone
  const localDT = DateTime.fromISO(
    `${date}T${time}`, 
    { zone: timezone }
  );

  // Validate it exists (handles DST gaps)
  if (!localDT.isValid) {
    showError(
      `${time} doesn't exist on ${date} in ${timezone} due to DST`
    );
    return;
  }

  // Send to server (server converts to UTC)
  await api.createEvent({
    scheduledDate: date,
    scheduledTime: time,
    eventTimezone: timezone,
    durationMinutes: 60
  });
};

Key decision: The client validates that the time exists in the selected timezone before sending to the server. This catches DST edge cases (like 2:30 AM on March 9, 2026 in New York, which doesn’t exist) at the point of user input, not as a cryptic server error.

When displaying events:

const formatEventTime = (
  event: Event,
  viewerTimezone: string
) => {
  // Parse the UTC timestamp
  const utcTime = DateTime.fromISO(
    event.startTimeUtc, 
    { zone: 'utc' }
  );

  // Convert to viewer's timezone for display
  const viewerTime = utcTime.setZone(viewerTimezone);

  // Also show original scheduled time for context
  const eventTime = DateTime.fromISO(
    event.scheduledDateTime,
    { zone: event.eventTimezone }
  );

  return {
    display: viewerTime.toFormat("dd MMM, hh:mm a"),
    tooltip: `Scheduled as: ${eventTime.toFormat("hh:mm a")} ${event.eventTimezone}`
  };
};

We show times in the viewer’s timezone by default (for convenience), but include a tooltip showing the original scheduled time (for clarity).

When editing events:

const loadEventForEdit = (event: Event) => {
  // Use scheduledDateTime in eventTimezone
  // NOT the UTC time!
  const eventDT = DateTime.fromISO(
    event.scheduledDateTime,
    { zone: event.eventTimezone }
  );

  setFormData({
    date: eventDT.toJSDate(),
    hour: eventDT.hour,
    minute: eventDT.minute,
    timezone: event.eventTimezone
  });
};

Critical principle: Edit forms always show the event’s timezone, not the viewer’s timezone.

Why this matters: If a Mumbai user edits a “10 PM New York” event, showing it as “8:30 AM next day IST” in the edit form is confusing. They might “fix” it to “8:30 AM today,” which actually shifts the New York time to the previous day. Showing the event’s original timezone prevents this entire class of bugs.

The Edge Cases That Define Senior Engineering

Here’s what separates a working implementation from a robust one:

Edge Case #1: The Midnight Boundary

User input (Mumbai user):
- Date: January 19
- Time: 11:00 PM
- Timezone: America/New_York
UTC conversion: January 20, 4:00 AM UTC

The problem: What date do you show in a table? If you show “January 19” (original date), it contradicts the UTC timestamp. If you show “January 20” (UTC date), users get confused.

Our solution: Show dates in the viewer’s timezone for tables, but preserve the event’s timezone in edit forms. This way:

  • New York users see: “Jan 19, 11:00 PM”

  • Mumbai users see: “Jan 20, 9:30 AM” (next day, correctly converted)

  • Everyone editing sees: “Jan 19, 11:00 PM (America/New_York)” with their local equivalent in a tooltip

Edge Case #2: DST Transitions Create Non-Existent Times

On March 9, 2026, clocks in New York jump from 1:59 AM to 3:00 AM. If someone tries to schedule something for 2:30 AM, Luxon returns isValid: false.

Our handling:

  1. Client-side validation catches this immediately

  2. Show user-friendly error: “2:30 AM doesn’t exist on March 9 in America/New_York due to Daylight Saving Time”

  3. Suggest alternative: “Did you mean 3:30 AM?”

We also log these occurrences for analysis. If users hit this frequently, we might add UI guardrails during DST transition weekends.

Edge Case #3: The “Different Day” Warning

Most insidious UX problem:

Mumbai user schedules:
- January 19, 11:00 PM in New York
- This is January 20, 9:30 AM in Mumbai
If editing shows "January 20, 9:30 AM":
- User thinks: "Why is this the next day?"
- User "corrects" to "January 19, 9:30 AM" 
- Now it's January 18, 11:00 PM in New York ❌

Our solution: Always show times in the event’s timezone when editing, with the viewer’s timezone as reference:

┌─────────────────────────────────────────┐
│ Scheduled: 11:00 PM (America/New_York) │
│ Your timezone: Jan 20, 9:30 AM (IST)   │
│ ⚠️  Note: Different day in your zone    │
└─────────────────────────────────────────┘

This UI pattern prevents the accidental shift while still giving users context.

The Tradeoffs: What This Cost

Development time: 2 weeks additional implementation + 1 week testing

  • Alternative (ship without timezone handling): 2 days to ship, months of support burden

Code complexity: ~400 additional lines of timezone handling logic

  • But prevents an entire class of bugs across the application

Testing surface: DST transitions, midnight boundaries, timezone picker UX

  • Created automated test suite with 40+ edge cases

  • Programmatically validated DST transitions for 2026–2030

Risk of shipping broken data: If we’d shipped without this, early users would have scheduled events in ambiguous timezones

  • Fixing retroactively would require contacting users to confirm intent

  • High risk of corrupting data at scale

The decision: Better to delay shipping 2 weeks than inherit months of data cleanup, support tickets, and lost user trust.

The Scale Consideration: What Breaks at 10x

Our current approach works for our scale (hundreds of events per day). Here’s what would need to change at 10x or 100x:

Storage overhead:

  • Dual timestamps add ~16 bytes per event

  • At 100K events/day: 1.6MB/day additional storage (negligible)

  • At 10M events/day: 160MB/day (still acceptable on modern databases)

Query performance:

  • Filtering by start_time_utc is indexed and fast

  • If we needed to filter by “all events at 9 AM in any timezone,” we’d need additional indexes on scheduled_datetime

Data migration risk:

  • If we’d shipped without timezone handling, then tried to add it later:

  • Existing events have no timezone context

  • Retroactive conversion requires guessing user intent (impossible to do correctly)

  • Would need to contact every user for confirmation

This is why handling it correctly upfront was critical: the cost of fixing it later grows exponentially with data volume.

Measuring Success: The Outcomes

Before implementation:

  • Filters returned incorrect results 40% of the time for cross-timezone events

  • No way to know what “9 PM” meant in the UI

  • Edit → Save → Display flow would shift times unpredictably

After implementation:

  • Zero timezone-related bugs in testing (validated programmatically)

  • 100% accuracy on DST transition edge cases

  • Edit → Save → Display round-trips preserve original intent

User impact:

  • Users no longer manually convert timezones (eliminated cognitive load)

  • Can confidently schedule across New York ↔ Mumbai without confusion

  • Clear UI showing both event time and “your time” builds trust

Team velocity impact:

  • Prevented months of support tickets for “wrong time” issues

  • Avoided high-risk data migration to fix broken timezone data

  • Created reusable patterns for future timezone-dependent features

If we’d shipped without this:

  • Estimated 15-20 support tickets/month for timezone confusion

  • Data corruption requiring manual user outreach for clarification

  • Loss of credibility with early adopters (can’t afford for a new feature)

The 2-week delay saved us months of support burden and protected data integrity from day one.

The Organizational Impact: Beyond This Feature

This wasn’t just about fixing one feature. It established patterns the team now reuses:

Internal documentation created:

  • “Timezone Handling Guide” for future features

  • Test suite template for DST edge cases

  • API design checklist for time-dependent endpoints

Decisions this informed:

  • How we handle recurring events (next feature on roadmap)

  • Webhook timestamp formats (adopted UTC + timezone pattern)

  • User settings for default timezone preference

Knowledge transfer:

  • Walked the team through the architecture decision in design review

  • Shared research on Google/Stripe/Outlook patterns

  • Now referenced when designing other time-dependent features

The multiplier effect: this decision cost two weeks, but it prevents similar bugs across 5+ future features that touch time. That’s organizational leverage, making the next ten decisions easier, not just solving one problem.

The Broader Lesson: Data Model IS Architecture

This problem taught me something fundamental about distributed systems:

Most timezone bugs aren’t coding errors. They’re modeling errors, and the code just exposes the flawed model.

Before we fixed the model:

  • ❌ Single timestamp (loses either moment or intent)

  • ❌ Ambiguous “date” field (which timezone?)

  • ❌ Client does all conversion (inconsistent across platforms)

After:

  • ✅ Separate moment (UTC) and context (local + timezone)

  • ✅ Server is source of truth for conversion

  • ✅ Client responsible for display preferences only

This pattern applies beyond timezones: Anywhere you have data that exists in multiple “perspectives” (currencies, units, locales), store both the canonical form and the user’s intent.

What I’d Do Differently

With hindsight:

Start with edge cases: I should have built the DST transition test cases before the happy path. We discovered the “non-existent time” edge case late in testing. Building tests first would have caught it in design.

More explicit naming: scheduledDateTime is better than generic naming because it signals "this is local time, not UTC." But we initially used eventDate which was ambiguous. Clear naming prevents misuse.

Timezone validation service: We ended up with timezone validation logic in both frontend and backend. Should have created a shared validation library from the start to ensure consistency.

Document the “why” alongside the “what”: Our API docs explain the fields but don’t explain why we chose this pattern over alternatives. Future engineers won’t understand the tradeoffs we considered.

The Checklist: Non-Negotiable Requirements

If you’re implementing timezone-aware scheduling:

  • [ ] Store both UTC timestamp and original local time + timezone

  • [ ] Use IANA timezone identifiers (not abbreviations like “EST”)

  • [ ] Validate times during DST transitions

  • [ ] Test with timezones that cross midnight boundaries

  • [ ] Show “different day” warnings in UI when relevant

  • [ ] Edit forms display in event’s timezone with viewer context

  • [ ] Document which system (client/server) owns conversion

  • [ ] Have a rollback plan if timezone logic breaks

The one I’d add after this experience: Build automated tests for DST transitions 3–5 years out. Timezone rules change, and your tests should catch that before users do.

Conclusion: Architecture Over Implementation

We thought we were building a scheduling feature. We actually built a distributed time coordination system.

The difference matters:

  • A feature is about implementation (write code that works)

  • A system is about architecture (design contracts that can’t be misused)

Timezones exposed this distinction because they’re fundamentally about distributed state:

  • Multiple systems (client, API, database) need shared understanding

  • Multiple perspectives (event time, viewer time, UTC) exist simultaneously

  • Multiple failure modes (DST, midnight boundaries, data migration) lurk at scale

Don’t optimize for the code you write today. Optimize for the code someone else maintains in 2 years when timezones rules change, DST policies shift, and your assumptions no longer hold.

That’s what separates good systems from great ones: not clever code, but thoughtful architecture that degrades gracefully and makes the right thing easy.

This article reflects real architectural decisions made during active development. We chose Luxon for timezone handling; production systems should always rely on established libraries rather than custom timezone logic. The principles remain universal.