DEV Community

Premysl Hnevkovsky
Premysl Hnevkovsky

Posted on

We're Inside the DST Gap Right Now — Your Code Might Not Be

A field guide for developers building apps that dare to cross meridians


You decided to build an app that tracks events worldwide. Bold move. Now let's talk about the moment you realize that time is not a simple integer and your clever Date.now() will absolutely betray you at the worst possible moment.

Welcome to timezone hell. Population: every developer who ever shipped a scheduling feature.

⚠️ Real-time relevance: I'm writing this on March 24, 2026 — and we're currently living inside the US–Europe DST gap window. The US switched to EDT on March 8, but Europe doesn't switch to CEST until March 29. If your app hardcodes timezone offsets between New York and London (or Prague, or Paris), it's wrong right now.


Step 1 — Know Where You Are (Spoiler: It Doesn't Matter)

You live somewhere. You know your timezone. Congratulations, that's completely irrelevant to your backend.

Your server doesn't care about Prague. Your server speaks UTC.

GMT vs UTC: GMT is a timezone. UTC is a time standard. They happen to share the same offset (+00:00), but GMT observes DST in some edge cases and is rooted in astronomical observation. UTC is atomic-clock precise and DST-free. Always store UTC. Never argue about this.

// Server Side - Kotlin 

// ❌ Don't do this
val now = LocalDateTime.now() // Whose now? YOUR now? Server's now? Tokyo's now? 

// ✅ Do this
val now = Instant.now() // Universal. Unambiguous. Boring in the best way.
Enter fullscreen mode Exit fullscreen mode

Step 2 — Know Where the Event Is

Your app cares about Tokyo Stock Exchange opening at 09:00 JST That's Tokyo's clock. Not yours.

First: get the canonical event time in its home timezone.

// Server Side - Kotlin

val tokyoZone = ZoneId.of("Asia/Tokyo")
val tokyoOpen = ZonedDateTime.of(
    LocalDate.now(tokyoZone),
    LocalTime.of(9, 0),
    tokyoZone
)
// tokyoOpen = 2026-03-24T09:00+09:00[Asia/Tokyo]
Enter fullscreen mode Exit fullscreen mode

Now convert to UTC so you have a real anchor point:

// Server Side - Kotlin

val tokyoOpenUtc = tokyoOpen.toInstant()
// 2026-03-24T00:00:00Z  ← this is your ground truth
Enter fullscreen mode Exit fullscreen mode

Good. Now you have something you can actually compute with.


Step 3 — The Countdown Is Just Subtraction (Or Is It?)

// Server Side - Kotlin

val countdown = Duration.between(Instant.now(), tokyoOpenUtc)
println("Market opens in: ${countdown.toMinutes()} minutes")
Enter fullscreen mode Exit fullscreen mode

Easy, right? Ship it.

...

Wait.


Step 4 — Did You Think About DST?

Japan doesn't observe DST. Lucky them. But your users might live somewhere that does.

Here's the trap: your countdown is correct (you're comparing Instant values — UTC under the hood). But the displayed local time on the client side may shift by ±1 hour depending on the season.

// Client side - TypeScript

const winterTs = new Date("2026-01-15T00:00:00Z");
winterTs.toLocaleString(undefined, { timeZoneName: "short" });
// Prague: "15. 1. 2026, 1:00:00 CET"   ← UTC+1, winter time

const summerTs = new Date("2026-06-01T00:00:00Z");
summerTs.toLocaleString(undefined, { timeZoneName: "short" });
// Prague: "1. 6. 2026, 2:00:00 CEST"   ← UTC+2, summer time ! 
Enter fullscreen mode Exit fullscreen mode

Same code. Different timestamp. Different hour. The browser handles the rule — but only if you let it. The moment you hardcode +01:00 you're frozen in January forever.

Use ZoneId, not ZoneOffset. One knows about DST. The other does not.

// Server Side - Kotlin

// ❌ Hardcoded offset - breaks in summer/winter
val prague = ZoneOffset.of("+01:00")

// ✅ Named zone - handles DST transitions automatically
val prague = ZoneId.of("Europe/Prague")
Enter fullscreen mode Exit fullscreen mode

Step 4.5 — The DST Overlap Problem (This Is Where It Gets Spicy) 🌐

Here's what most tutorials skip: different regions switch DST on different dates.
And that gap between switches is where hardcoded timezone differences silently break.

The US–Europe Gap Window (happening right now)

As of this writing — late March 2026 — we are living inside exactly this trap:

  • 🇺🇸 US switched to EDT on March 8 (second Sunday of March)
  • 🇪🇺 Europe switches to CEST on March 29 (last Sunday of March)

That's 3 weeks where the US–Europe offset is not the usual value:

Period                  | New York (ET) | Prague (CET/CEST) | Difference
------------------------|---------------|-------------------|------------
Winter (before Mar 8)   | EST  = UTC-5  | CET  = UTC+1      | 6 hours
Gap window (Mar 8–28)   | EDT  = UTC-4  | CET  = UTC+1      | 5 hours💥
Summer (after Mar 29)   | EDT  = UTC-4  | CEST = UTC+2      | 6 hours
Enter fullscreen mode Exit fullscreen mode

If you hardcoded "NYSE opens 3:30pm Prague time" — you're wrong for 3 weeks every spring and 3 weeks every autumn. Congratulations, your bug has a season.

// Server Side - Kotlin

// ❌ This is wrong 6 weeks per year
val nyseOpenInPrague = LocalTime.of(15, 30) // "I know the offset is 6h"

// ✅ Let the library do the calendar math
val nyseOpen = ZonedDateTime.of(
    LocalDate.now(ZoneId.of("America/New_York")),
    LocalTime.of(9, 30),
    ZoneId.of("America/New_York")
)
val pragueView = nyseOpen.withZoneSameInstant(ZoneId.of("Europe/Prague"))
println("NYSE opens at: ${pragueView.toLocalTime()} Prague time")
// Correctly prints 15:30 in winter, 15:30 in summer, and 14:30 in the gap 🎯
Enter fullscreen mode Exit fullscreen mode

The Australia Wildcard — When the Hemispheres Collide

Australia is on the opposite DST schedule because, well, opposite hemisphere. The ASX (Sydney) observes:

  • AEDT (UTC+11) — October through April (their summer)
  • AEST (UTC+10) — April through October (their winter)

This creates a beautiful four-state matrix with Europe alone:

Period               | Sydney        | London (GMT/BST) | Difference
---------------------|---------------|------------------|------------
Jan (EU winter,      | AEDT = UTC+11 | GMT  = UTC+0     | 11 hours
 AU summer)          |               |                  |
Apr transition week  | AEST = UTC+10 | BST  = UTC+1     | 9 hours 💥
Jul (EU summer,      | AEST = UTC+10 | BST  = UTC+1     | 9 hours
 AU winter)          |               |                  |
Oct transition week  | AEDT = UTC+11 | BST  = UTC+1     | 10 hours 💥
Enter fullscreen mode Exit fullscreen mode

The Sydney–London offset swings between 9 and 11 hours across the year, with two transition windows where it's a completely different value than either stable state.

Any app that displays "London time" relative to "Sydney time" from a hardcoded diff will be wrong four times per year, at transitions in both directions.

The only correct approach:

// Client Side - TypeScript

function getOffsetBetween(zone1: string, zone2: string, at: Date): number {
  // Never hardcode. Always compute. Timezone rules do the rest.
  const fmt = (tz: string) =>
    new Intl.DateTimeFormat("en", { timeZone: tz, timeZoneName: "shortOffset" })
      .formatToParts(at)
      .find(p => p.type === "timeZoneName")?.value ?? "";

  const parseOffset = (s: string) => {
    const m = s.match(/GMT([+-])(\d+)(?::(\d+))?/);
    if (!m) return 0;
    return (m[1] === "+" ? 1 : -1) * (parseInt(m[2]) * 60 + parseInt(m[3] ?? "0"));
  };

  return parseOffset(fmt(zone1)) - parseOffset(fmt(zone2));
}

// Usage
const now = new Date();
console.log(getOffsetBetween("Australia/Sydney", "Europe/London", now));
// Returns the correct value today, tomorrow, and in October
Enter fullscreen mode Exit fullscreen mode

The Summary Rule

If you have ever typed a number of hours as a timezone difference between two places — you have a bug. You just don't know which week it will appear yet.


Step 5 — Did You Consider the Date?

Tokyo opens Monday 09:00 JST. You're in New York on Sunday evening. The event is
tomorrow in Tokyo and today in your database query.

// Server Side - Kotlin

val tokyoZone = ZoneId.of("Asia/Tokyo")
val nyZone    = ZoneId.of("America/New_York")

val nowInTokyo = ZonedDateTime.now(tokyoZone)  // Monday
val nowInNY    = ZonedDateTime.now(nyZone)      // Sunday

// Same Instant. Different dates. Different days of the week.
// Your "today's events" filter just silently excluded Tokyo.
Enter fullscreen mode Exit fullscreen mode

Rule: Always derive local dates from the event's timezone, not from your server's LocalDate.now().


Step 6 — Architecture: Server/Client Contract

🏗️ Here's the pattern that keeps you sane across the whole stack:

┌──────────────────────────────────────────────────────┐
│  SERVER                                              │
│  • Stores everything as UTC (Instant / epoch ms)    │
│  • Stores user's IANA timezone string alongside     │
│    user-facing timestamps                           │
│  • Never stores offsets (+01:00) — they're seasonal │
└───────────────────┬──────────────────────────────────┘
                    │ JSON: { "openTime": "2026-03-24T00:00:00Z",
                    │         "timezone": "Asia/Tokyo" }
┌───────────────────▼──────────────────────────────────┐
│  CLIENT                                              │
│  • Receives UTC timestamps                          │
│  • Renders in user's local timezone (browser API)   │
│  • Sends edits back as UTC or with explicit tz info │
└──────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
// Client Side - TypeScript

// Server response contract
interface MarketEvent {
  openTimeUtc: string;    // ISO-8601 UTC: "2026-03-24T00:00:00Z"
  marketTimezone: string; // IANA: "Asia/Tokyo"
}

// Client rendering
function formatEventTime(event: MarketEvent, displayZone: string): string {
  return new Intl.DateTimeFormat("en-US", {
    timeZone: displayZone,
    hour: "2-digit",
    minute: "2-digit",
    timeZoneName: "short",
  }).format(new Date(event.openTimeUtc));
}
Enter fullscreen mode Exit fullscreen mode

Step 7 — What About the Cloud ☁️ ?

"My app runs on three continents. Does my replica in Singapore care about Prague time?"

No. And it shouldn't.

Your databases store UTC-based timestamps:

  • Firestore / MongoDB → epoch-based UTC values (timezone is not stored at all)
  • PostgreSQL TIMESTAMPTZ → stored as UTC internally, converted on read/write

Timezones are not a storage problem.
They are a scheduling and interpretation problem.

And sometimes — they are your business logic.

The only time you should actually care about timezones in your backend:

  • Sharding data by date (whose “today”? see Step 5)
  • Log correlation across multi-region deployments
  • Scheduled jobs that must fire at "9am local" per region
  • Business rules tied to real-world time (markets, bookings, SLAs)

This is where many systems break.

Because this is NOT display logic — this is domain logic.

If your app says:

  • "Send email at 9:00"
  • "Market opens at 09:30"
  • "Booking starts at midnight"

That time belongs to a specific timezone, and must be modeled explicitly.

// Server Side - Kotlin

// ❌ Ambiguous — whose 9:00?
LocalTime.of(9, 0)

// ✅ Explicit — tied to real-world meaning
ZonedDateTime.of(date, LocalTime.of(9, 0), ZoneId.of("America/New_York"))
Enter fullscreen mode Exit fullscreen mode

Never rely on your server region timezone (e.g. AWS region). It is irrelevant to your application logic.

For everything else: UTC in, UTC out, convert at the edges.


The Mental Model

Event happens in the real world
        ↓
Expressed in event's local timezone  (09:00 JST)
        ↓
Stored as UTC on your server         (00:00 UTC)
        ↓
Transmitted as UTC over the wire
        ↓
Displayed in user's local timezone   (browser/client)
Enter fullscreen mode Exit fullscreen mode

🧠 That's it. The whole game. Fight anyone who breaks this pipeline.


🎯 Bonus: Real Lessons from Building TradeDialer

I built TradeDialer, a global market hours tracker covering 25 exchanges with live countdowns and index data.

Trade-Dialer markets in UTC


Here's what actually hurt:

1. Countdown wording is a UX problem, not just a math problem

A raw duration like "opens in 61 hours" is technically correct and practically useless on a Friday afternoon. The countdown needs contextual language:

Opens in 2h 34m          ← same day, simple case
Opens Monday 09:30       ← weekend ahead
Opens in 3 days          ← holiday closure
Enter fullscreen mode Exit fullscreen mode

Every one of those branches must be computed in the exchange's local timezone, not the server's. "Monday" in Tokyo is not "Monday" in New York.


2. Status thresholds need their own timezone logic

TradeDialer uses a four-state color system to communicate urgency at a glance:

Color Meaning
🟢 Green Market is open
🟡 Amber Closing in < 1 hour
🔵 Blue Opening in < 1 hour
⚫ Gray Closed (weekend / holiday / outside hours)

The amber and blue thresholds are computed as a diff against the exchange's session end/start — as ZonedDateTime, not a raw UTC comparison. Otherwise the "closing soon" window misfires around DST transitions.


3. Live data fallback is a timezone-triggered state machine

When US markets close, direct exchange feeds dry up. TradeDialer automatically switches to a Yahoo Finance fallback for foreign markets that are still open — and surfaces the source change with a visible badge so users always know what they're looking at.

The trigger for that switch? Computed using exchange timezone + session hours. Not UTC midnight. Not server time. The exchange's own clock.


4. Holiday calendars are a maintenance tax, not a one-time task

Holiday data looks static — until a government announces a surprise market closure three days before it happens (this is a real thing that happens). Building a market hours app means committing to keeping holiday data fresh across 25 exchange calendars, globally.

Design your holiday config as versioned and hot-reloadable, not hardcoded constants. You will need to push an update on short notice someday.


5. Scope decisions must be explicit, not accidental

NYSE pre-market starts at 04:00 EST. NYSE after-hours runs until 20:00 EST. TradeDialer deliberately excludes extended hours and shows only regular session data.

"We don't show extended hours" is a product decision.
"Extended hours are broken" is a bug.
Know which one you're shipping.


TL;DR — The Timezone Survival Kit

Rule Why
Store as Instant / UTC epoch No offset confusion
Use IANA zone IDs, not offsets DST is handled for you
Never hardcode hour differences between zones The gap window will find you
Derive dates per-timezone Date line is real
Convert only at display time Single source of truth
Evaluate holidays in exchange timezone Local days, not UTC days
Model data quality explicitly Trust is a feature
Make holiday data hot-reloadable Governments are unpredictable
Never trust LocalDateTime across systems It's a lie with no context

Built while debugging why TradeDialer showed Tokyo as opening at 1am. It was. In Prague. In December. Nobody told the server.

Check it out: trade-dialer.com


Top comments (0)