Timezones and Locale Formatting
Instant, timezone, locale, display — four separate things. Most date bugs come from collapsing them, and most of the rest come from string manipulation.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
This timestamp is correct in the database. Why is it showing the wrong day?
Someone books a meeting, files an expense, or looks at yesterday's numbers. They expect the times shown to be the times they mean.
The server sends a date string, the client displays it. If the timezone is wrong, add or subtract the offset.
Adding an offset is arithmetic on a wall-clock reading, and wall clocks jump. Twice a year the offset you hardcoded is wrong, and for one hour a year some local times occur twice while others do not occur at all.
- Adding an offset is arithmetic on a wall-clock reading, and wall clocks jump. Twice a year the offset you hardcoded is wrong, and for one hour a year some local times occur twice while others do not occur at all.
- A timezone is not an offset.
America/New_Yorkis a set of rules that has changed before and will change again — governments alter DST dates and offsets with little notice, and any offset you stored is a snapshot of a rule, not a fact. - Slicing a string to get the date —
iso.slice(0, 10)— takes the UTC date, which for a user in Tokyo or Los Angeles is routinely the wrong day. - "Today" is not a global concept. A report for today is a different range for every user, and computing it in the server's timezone gives most of them somebody else's day.
- Some things are not instants at all. A birthday is a calendar date; converting it through a timezone can move it a day and has no business doing so.
What is actually happening
In the browser, not in the framework.
- An instant is a point on the timeline, the same for everyone. Store and transmit it as UTC — an ISO string with a
Z, or epoch milliseconds. This is the only representation that is unambiguous. - A timezone is a named set of rules —
Europe/Berlin— mapping instants to local wall-clock time, including historical and future DST transitions. Store the IANA name, never a numeric offset. - A locale decides how a wall-clock time is written: field order, separators, 12- or 24-hour, month names, era.
en-GBanden-USshare a language and disagree about date order. - Display is where the three combine, and it should be the only place any of them are applied. Convert at the edge, as late as possible.
Intl.DateTimeFormatdoes all of this correctly: given an instant, atimeZoneand a locale, it produces the right string, DST included. Every hand-rolled alternative is an incomplete reimplementation of it (Internationalization).- The relevant timezone is not always the viewer's. A flight departs in the airport's zone; a store opens in the store's. Which zone applies is a domain decision, and it must be stored alongside the instant when it is not the viewer's.
What this makes the browser do
And which of it is avoidable.
- Constructing
Intl.DateTimeFormatis expensive relative to using one; build per locale-and-options combination and reuse it, especially in lists (What a Component Costs to Render). - Timezone data lives in the browser and the OS, so formatting is cheap but the rules can differ between clients on different platforms or update schedules.
- Rendering relative times ("3 minutes ago") that update on a timer is a recurring, avoidable source of re-render churn (Long Tasks).
Four things, kept apart
Nearly every date bug is two of these four collapsed into one. A timestamp treated as a wall-clock reading; a calendar date treated as an instant; a zone treated as an offset; a formatted string treated as data.
Keeping them separate is most of the work, and it is a modelling decision rather than a library choice — no library can tell you whether the value you stored was a moment in time or a square on a calendar.
1// stored and transmitted: an instant, unambiguous2const instant = '2026-03-29T00:30:00Z'3 4// displayed: instant + zone + locale, all explicit5new Intl.DateTimeFormat('en-GB', {6 timeZone: 'Europe/Berlin',7 dateStyle: 'full',8 timeStyle: 'short',9}).format(new Date(instant))10 11// a calendar date is NOT an instant — no zone, no conversion12type BirthDate = `${number}-${number}-${number}` // '1990-08-26'13 14// what never to do:15// iso.slice(0, 10) -> the UTC date, not the user's16// new Date(iso).getHours() + 2 -> arithmetic on a wall clock17// parse(formatted) -> formatting is one-wayThe three lines at the bottom cover the overwhelming majority of date bugs in frontend code.
Which timezone is the right one
The viewer's timezone is the default and is often wrong. Whether it applies is a domain question, and getting it wrong produces confidently displayed times that nobody can act on.
Whose clock does this event happen on?
when Activity feeds, notifications, "when did this happen to me".
cost Two people looking at the same record see different strings — correct, and worth labelling in anything shared or exported.
when A flight departure, a store's opening hours, a venue's start time.
cost The zone must be stored with the event, because it cannot be derived from the instant or from the viewer.
when Cross-timezone meetings and anything someone will act on at a specific moment.
cost More text — and it removes the single most common cause of a missed call.
when A birthday, a due date, a public holiday — a square on a calendar, not a moment.
cost Model it as a calendar date and keep it away from every conversion path; the bug here is applying a zone, not omitting one.
when Financial cut-offs, reporting periods, "the accounting day".
cost Must be stated on screen, or users west of it will file on what they believe is the right day and be wrong.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| User in UTC-8 views a record | Shows yesterday's date | iso.slice(0, 10) took the UTC date | Format the instant in the user's zone with Intl.DateTimeFormat. |
| DST transition | A scheduled item runs twice, or not at all | Recurrence computed by adding 24 hours to a wall-clock time | Recur in the zone's local time and resolve each occurrence to an instant separately. |
| Government changes a zone's rules | Times silently shift by an hour | A numeric offset was stored instead of an IANA zone name | Store the zone name; let the platform apply current rules. |
| Birthday shown | A day earlier for some users | A date-only value parsed as midnight UTC and rendered in a negative offset | Model calendar dates as dates; never convert them through a zone. |
| "Last 7 days" report | Range does not match what the user counts as a week | Boundaries computed in the server's zone | Compute day boundaries in the user's zone, and say which zone the report used. |
| Tab left open overnight | "Today" now means yesterday | The current day computed once at load | Re-evaluate day-anchored values on visibility change (Long-Lived Clients and Version Skew). |
Displaying it so nobody has to compute
A time the user has to reason about is a time they can get wrong. Relative formats are friendly and imprecise; absolute formats are precise and easy to misread when they are numeric. The usual right answer is to show one and make the other available.
The <time> element is the small structural piece that makes this work for everyone: a human-readable string on screen, a machine-readable instant in the attribute, and an unambiguous full value in the accessible name.
semantics A <time> element with a machine-readable dateTime holding the UTC instant, visible text carrying the relative or short form, and an aria-label (or title plus label) carrying the full localized date, time and zone.
| Tab | Reaches the timestamp only if it is interactive — a static time should not be focusable. |
| Enter / Space | If it toggles between relative and absolute, activates that toggle; the toggle must be a real button. |
- — A static timestamp is not focusable and does not need to be.
- — If a tooltip carries the absolute time, it must open on focus as well as hover, and Escape must dismiss it.
- — Auto-updating relative times must never move focus.
- — The full unambiguous value from the accessible name: "26 August 2026 at 15:00 Central European Time".
- — The zone, whenever it is not the viewer's own.
- — Nothing on each tick — updating times must stay out of live regions.
usually broken by Rendering "3h" as bare text with no dateTime and no accessible name, inside a live region that re-announces every timestamp in the list on every tick.
| Situation | Show | Why |
|---|---|---|
| Recent activity | Relative ("3 minutes ago"), absolute in the accessible name | Recency is the information; the exact instant rarely is |
| Anything older than a few days | Absolute date | Relative stops being meaningful and starts being arithmetic |
| A scheduled meeting | Absolute, with the zone, in both zones if they differ | The user will act at that moment; ambiguity is a missed call |
| A deadline | Absolute with the zone spelled out | Numeric-only formats are ambiguous across locales |
| A birthday or due date | Calendar date only | It has no time and no zone; adding either introduces the bug |
| A financial cut-off | Absolute with the business zone named | Users elsewhere will otherwise file on the wrong day |
How to build it
Most important first.
- Store and transmit UTC instants. Store an IANA timezone name where the applicable zone is part of the data. Never store an offset as if it were a zone.
- Convert once, at display, using
Intl.DateTimeFormatwith an explicittimeZoneand locale. - Never parse or slice a formatted date string. Formatting is one-way — go back to the instant instead.
- Model calendar dates as calendar dates. A birthday or a due date is
2026-08-26with no time and no zone; running it through a timezone conversion is a bug, not a nicety. - Compute day boundaries in the user's zone. "Today" means their midnight to their midnight (How API Shape Drives UI Complexity).
- Show the zone when it is not obviously the user's, and be explicit in anything cross-timezone: "15:00 CET (14:00 your time)" prevents a category of missed meeting.
- For relative times, update on a sensible cadence and give the absolute value in a tooltip and in the accessible name.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A relative time like "3h" is compact and ambiguous. Put the full, unambiguous date and time in the accessible name —
<time dateTime="…" title="…">with anaria-label— so a screen-reader user is not asked to compute it (Semantics Are Behaviour). - Live-updating timestamps must not be inside a live region, or a list of them announces continuously and the page becomes unusable (Live Regions and Announcement).
- Use the
<time>element with a machine-readabledateTime. It costs nothing and gives assistive technology and other tools an unambiguous value. - Numeric-only formats like
03/04/26are ambiguous to read and worse to hear. Where the exact day matters — a deadline, an appointment — spell the month out. - Times in a table need the zone stated somewhere reachable, since a screen-reader user reading cell by cell may never encounter a heading that says which zone the column is in.
What can go wrong
- Off-by-one days at the start or end of a month, appearing only for users west or east of the server.
- A DST transition producing a duplicated or missing hour, so a scheduled job runs twice or not at all.
- A stored offset that was correct when written and wrong after a government changed the rules.
- Date-only values shifting a day because they were treated as midnight UTC and rendered in a negative-offset zone.
- "Last 7 days" computed in the server's zone, giving every user a range that is not theirs.
- A relative time that says "in 0 minutes" or "1 minutes ago" because the formatting was hand-rolled rather than done with
Intl.RelativeTimeFormat.
- A user crossing midnight in their own zone while a page is open sees "today" become stale, so anything anchored to the current day needs re-evaluation rather than a value computed once at load (Long-Lived Clients and Version Skew).
- Client and server can disagree about the current instant; anything security-relevant must use the server's clock, never the browser's.
- Timezone and locale are fingerprinting signals. Collecting them is usually necessary for display; storing them per user alongside other identifiers is a privacy decision worth making deliberately (Session Replay and the Privacy It Costs).
- Timezone is user-controlled input. Never interpolate a timezone or locale string into a path or query without validating it against a known set.
- Displaying a timestamp in the viewer's zone can disclose another user's location if that timestamp is derived from *their* local time rather than from an instant.
- "UTC everywhere solves it." UTC solves storage. Display still needs a zone, and "which zone" is a domain question UTC cannot answer.
- "A timezone is an offset." An offset is what a zone resolves to at one instant. Zones change; offsets are snapshots.
- "The date part of an ISO string is the date." It is the UTC date, which is not the user's date for a large share of the planet.
- "Date libraries handle this." Good ones help. None of them can decide whether a value is an instant or a calendar date — that is modelling, and it is the actual source of the bug.
- "We only operate in one country." Countries have multiple zones, users travel, and DST still exists.
Measuring it, and what changes in the field
- Error tracking grouped by timezone offset — date bugs cluster unmistakably in offsets far from the team's (Frontend Error Tracking).
- Explicit tests around DST transition instants, which is the one case that cannot be found by testing in your own zone.
- Field data on locale and timezone distribution, which usually shows a much wider spread than the team assumed (Real User Monitoring).
- A team in one timezone will not encounter most of these bugs in development, which is why they reach production.
- Near a DST boundary, code that is correct all year is wrong for one hour — and reproducing it requires deliberately setting the clock.
- In a scheduling or booking product, the applicable timezone is domain data and cannot be inferred from the viewer at all.
- Storing an IANA zone alongside an instant is more data and is the only thing that survives a rule change.
- Converting at display keeps storage unambiguous and means every rendering path must remember to do it — one shared formatting utility is the practical answer.
- Relative times are friendlier and less precise; showing both costs space and removes the ambiguity.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe instant / timezone / locale / display separation is a modelling truth about time itself, so it holds across every language, platform and storage engine.
- BROWSER-SPECIFICTimezone rule data is supplied by the browser or the operating system, so two clients can disagree about a recently-changed zone until both are updated — which is a reason to render anything legally or financially binding on the server rather than in the browser.
- SPEC-EVOLVINGA newer date and time API is being standardised to replace the historically error-prone built-in
Date, and availability differs across engines right now. Model the distinction between instants and calendar dates explicitly regardless of which API you use; that modelling is what survives the transition.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — client and server clocks disagree, so anything ordered or expiring must be anchored to a single authority rather than to whichever clock rendered it.
- — Testing & Reliability Engineering — DST-transition instants are the canonical case that only a deliberately chosen fixture will ever exercise.