Convert a Time Between Two Timezones in JavaScript
To convert a time between zones, build a Temporal.ZonedDateTime in the source zone and call .withTimeZone(targetZone) — same instant, new wall clock. Part of Working with ZonedDateTime Objects.
Why this scenario is tricky
Converting a time between zones trips people up because there are two completely different operations that both get called "convert," and mixing them up produces answers that are off by hours. The first operation preserves the instant: 3pm in New York is a specific moment, and asking "what time is it in London at that same moment" keeps the instant fixed and reads a different wall clock — 8pm. The second operation preserves the wall clock: taking "3pm" as a bare local time and reinterpreting it as 3pm London is a different instant entirely. Both are legitimate, but they answer different questions, and a converter that silently does one when you meant the other is a classic source of scheduling bugs.
Legacy Date makes this worse because it has no concept of a zone attached to a value — it is just a UTC instant with locale-dependent display methods. So developers reach for string manipulation or offset math, hard-code an offset like -5 that is only correct for half the year, and produce times that are an hour off during daylight-saving. There is no place in a Date to record which zone a wall-clock time was meant in, which is precisely the information a correct conversion needs.
Temporal fixes this by making the zone a first-class part of ZonedDateTime. Once a time carries its zone, converting to another zone is unambiguous: withTimeZone keeps the instant and recomputes the wall clock, and there is a separate, explicit path for the reinterpret-the-wall-clock case. The trickiness dissolves once the type forces you to say which operation you mean.
"3pm in New York, what time in London?" is not offset addition — offsets change with DST and the two zones do not shift on the same day. The correct operation is to anchor the source wall time to an instant, then read that same instant's wall clock in the target zone, which is exactly what withTimeZone does.
Minimal working solution
withTimeZone is the instant-preserving conversion. Given a ZonedDateTime anchored in New York, .withTimeZone('Europe/London') returns a new value that denotes the exact same moment on the timeline, displayed against London's wall clock and offset. Nothing about the underlying instant changes — only the zone lens through which you read it. This is the operation you want for "show this meeting time in the viewer's local zone" or "what will the clock in Tokyo say when this deadline hits," because the moment is fixed and only the presentation zone varies.
Because the conversion is a pure transformation returning a new immutable value, you can project one instant into many zones without disturbing the original — useful for a UI that shows a single event time to users around the world. Each projection carries its target zone, so it can be formatted, stored, or further converted correctly. The source value remains available for the canonical record.
import { Temporal } from '@js-temporal/polyfill';
const ny = Temporal.ZonedDateTime.from('2024-03-15T15:00:00[America/New_York]');
const london = ny.withTimeZone('Europe/London');
console.log(london.toString()); // same instant, London wall clock
Full production version
The production helper handles the other conversion — the one that takes a bare wall-clock string and moves it between zones by first anchoring it. Temporal.PlainDateTime.from(wall).toZonedDateTime(from) says "interpret this clock reading as a time in the source zone," producing a concrete instant, and .withTimeZone(to) then re-projects that instant into the target zone. Separating the anchor step from the projection step keeps the semantics explicit: you are stating both which zone the wall time was meant in and which zone you want to read it in, with no hidden assumption.
That explicitness matters at transitions. Anchoring a wall time in a zone can be ambiguous — during a fall-back hour a wall time occurs twice, and during a spring-forward gap it does not exist at all — and toZonedDateTime takes a disambiguation option ('compatible', 'earlier', 'later', 'reject') so you decide how to resolve it rather than getting a silent guess. For most scheduling the default 'compatible' is fine, but a system booking times right at a transition should choose deliberately. Once anchored, the projection to the target zone is exact, and the same instant round-trips through as many zones as you like without drift.
import { Temporal } from '@js-temporal/polyfill';
function convertZone(wall: string, from: string, to: string): Temporal.ZonedDateTime {
// Anchor the wall time in the source zone, then re-project.
return Temporal.PlainDateTime.from(wall).toZonedDateTime(from).withTimeZone(to);
}
Verification snippet
Assert the instant-preserving property directly: convert a New York time to London with withTimeZone and check that .epochNanoseconds (or .toInstant()) is unchanged while the wall-clock fields differ by the expected offset. This proves the conversion moved the lens, not the moment. Then assert the reinterpret path separately: anchoring "15:00" in New York versus London must yield different instants, confirming the two operations are genuinely distinct.
The high-value cases are at daylight-saving transitions, where offsets differ between the two zones on different dates. Convert a time on a date when New York has already sprung forward but London has not, and assert the wall-clock difference reflects the actual offsets on that date rather than a hard-coded five hours. Add a disambiguation case: anchor a wall time inside a spring-forward gap and assert your chosen policy resolves it as expected. Running these under several TZ values confirms the converter never leans on the host zone.
Common pitfalls
The number-one pitfall is hard-coding an offset — treating New York as always UTC-5 — which is correct only outside daylight time and an hour wrong for much of the year. Always convert through the IANA zone so the correct offset for that specific date is applied. The second is conflating the two conversion operations: using an instant-preserving conversion when you meant to reinterpret a wall time, or vice versa, which shifts the result by the offset between the zones. Decide which question you are answering and use the matching path.
A third pitfall is ignoring transition ambiguity when anchoring a wall time, which can silently pick the wrong instant during a fall-back hour or throw during a spring-forward gap. Pass an explicit disambiguation option when the input can land near a transition. Finally, storing only the offset of a converted time rather than its zone throws away the ability to re-project it after a rule change; persist the ZonedDateTime's full string, including the bracketed IANA zone, so the value stays reconstructable.
Frequently Asked Questions
How do I convert a time from one time zone to another?
Build a Temporal.ZonedDateTime in the source zone and call withTimeZone(targetZone). It keeps the same absolute instant and recomputes the wall-clock time for the target zone, correctly accounting for each zone's DST.
Why can't I just add the offset difference between two zones?
Because offsets change with daylight saving and the two zones rarely switch on the same date. A fixed offset gives the wrong answer for part of the year; re-projecting the instant with withTimeZone always uses each zone's rules for that moment.
How do I convert a time from one timezone to another in JavaScript?
Build a Temporal.ZonedDateTime in the source zone and call .withTimeZone(targetZone). This keeps the same instant on the timeline and recomputes the wall clock and offset for the target zone, so a 3pm New York meeting correctly shows as 8pm in London. Because the correct offset for that specific date is applied through the IANA zone, the result stays right across daylight-saving changes, unlike hard-coded offset math.
What is the difference between converting the instant and reinterpreting the wall time?
Converting the instant (withTimeZone) keeps the exact moment fixed and reads a different clock — the same event shown in another zone. Reinterpreting the wall time takes a bare clock reading like '3pm' and treats it as 3pm in a different zone, which is a different instant. Both are valid but answer different questions; a converter that does one when you meant the other is off by the offset between the zones, so choose the matching operation deliberately.
How do I handle a wall time that falls in a daylight-saving gap?
When anchoring a wall time with toZonedDateTime, pass a disambiguation option: 'compatible' (the default) picks a sensible instant, 'earlier'/'later' choose which side of a fall-back repetition to use, and 'reject' throws so you can handle the gap or overlap explicitly. A spring-forward gap has no valid wall time and a fall-back hour has two, so a system scheduling near a transition should set this option on purpose rather than accept a silent guess.