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.
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]
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
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")
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 !
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")
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
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 🎯
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 💥
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
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.
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 │
└──────────────────────────────────────────────────────┘
// 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));
}
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"))
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)
🧠 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.
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
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)