Format a Date in a Specific Timezone for Display
To display an instant in an arbitrary timezone — not the host's — pass an explicit IANA timeZone to Intl.DateTimeFormat; never add or subtract hours by hand. This recipe sits under Mastering Intl.DateTimeFormat Options.
Why this scenario is tricky
A Date is an absolute instant — a number of milliseconds since the Unix epoch, with no timezone of its own. "What time is it in Tokyo?" is a formatting question, not a data question: the underlying instant never changes, only how you render it. The instinct to "convert" the date by shifting its milliseconds is the central mistake.
The wrong approach — new Date(ts + offsetHours * 3600_000) — produces a Date whose absolute value is now wrong, then reads it back with local-time getters and hopes the two errors cancel. They do not. The offset you hard-code is correct only until a daylight-saving transition: New York is -05:00 in January and -04:00 in July, and a fixed -5 silently renders every summer timestamp an hour off. You would have to reimplement the entire IANA transition database to get it right — which is exactly what the engine already ships.
Intl.DateTimeFormat with a timeZone option does this correctly. It takes the absolute instant and renders it in the target zone using the live IANA rules, including historical and future DST transitions. The instant stays untouched; only the displayed wall-clock string reflects the zone. This is also why server rendering needs an explicit timeZone — without one the formatter uses the host's zone, which drifts between your laptop and a UTC container. For detecting the viewer's own zone see Safe Timezone Detection in Browsers; for the arithmetic behind offsets see Timezone Offset Math Explained.
Minimal working solution
Pass the target zone as timeZone. The formatter resolves DST for that exact instant — no offset math anywhere:
const instant = new Date('2024-06-19T18:40:00Z'); // absolute UTC instant
const tokyo = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tokyo', // render in Tokyo regardless of the host zone
dateStyle: 'medium',
timeStyle: 'short',
});
console.log(tokyo.format(instant)); // "Jun 20, 2024, 3:40 AM"
Full production version
Intl.DateTimeFormat construction is expensive, so cache one instance per (locale, timeZone, style) key and reuse it. Validate the zone up front so a typo fails immediately instead of silently falling back:
const formatterCache = new Map<string, Intl.DateTimeFormat>();
/** True if the runtime recognizes this IANA zone id. */
function isValidTimeZone(tz: string): boolean {
try {
// Constructing with an unknown timeZone throws a RangeError.
new Intl.DateTimeFormat('en-US', { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Format an absolute instant for display in a specific IANA timezone.
* @param instant - an absolute moment (Date or epoch ms); its value is never mutated.
*/
export function formatInZone(
instant: Date | number,
timeZone: string,
locale = 'en-US',
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' },
): string {
if (!isValidTimeZone(timeZone)) {
throw new RangeError(`Unknown IANA time zone: "${timeZone}"`);
}
const key = `${locale}|${timeZone}|${JSON.stringify(options)}`;
let fmt = formatterCache.get(key);
if (!fmt) {
// Always pass an explicit timeZone so SSR/serverless output never drifts to the host zone.
fmt = new Intl.DateTimeFormat(locale, { ...options, timeZone });
formatterCache.set(key, fmt);
}
return fmt.format(instant);
}
Temporal offers the same result with a more explicit model: a ZonedDateTime already carries its zone, so formatting it needs no separate timeZone argument.
import { Temporal } from '@js-temporal/polyfill';
const instant = Temporal.Instant.from('2024-06-19T18:40:00Z');
// toLocaleString reads the zone from the ZonedDateTime itself.
const zoned = instant.toZonedDateTimeISO('Asia/Tokyo');
console.log(zoned.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }));
Verification
The same instant must render to its known wall-clock time in each zone, and an invalid zone must throw rather than fall back:
const ts = new Date('2024-06-19T18:40:00Z');
console.assert(
formatInZone(ts, 'Asia/Tokyo', 'en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
=== '03:40',
'Tokyo is UTC+9 — 18:40Z renders as 03:40 next day'
);
console.assert(
formatInZone(ts, 'America/New_York', 'en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
=== '14:40',
'New York is EDT (UTC-4) in June, not the fixed UTC-5'
);
let threw = false;
try { formatInZone(ts, 'Mars/Olympus'); } catch { threw = true; }
console.assert(threw, 'invalid zone throws instead of silently using the host zone');
Common pitfalls
- Shifting milliseconds by a hard-coded offset. Breaks at every DST transition.
Wrong:
new Date(ts + 9 * 3600_000)then read local getters. Right:new Intl.DateTimeFormat(locale, { timeZone: 'Asia/Tokyo' }).format(ts). - Omitting
timeZoneon the server. Output silently follows the host zone and differs between dev and prod. Wrong:new Intl.DateTimeFormat('en-US', { timeStyle: 'short' })Right: addtimeZone: 'UTC'(or the intended zone) explicitly. - Recreating the formatter per call.
Intl.DateTimeFormatconstruction is the slow part; cache it. Right: key aMapon(locale, timeZone, options). - Using a UTC offset string as the zone.
timeZone: '+09:00'is not a valid IANA id and loses DST. Right: store and passAsia/Tokyo.
Frequently Asked Questions
How do I show a timestamp in a timezone other than the user's?
Create an Intl.DateTimeFormat with an explicit timeZone set to the target IANA id (for example Asia/Tokyo) and call .format(instant). The formatter renders that absolute instant in the chosen zone using live DST rules, and the underlying Date is never modified. Cache the formatter and reuse it for performance.
Why not just add the UTC offset to the timestamp?
Because the offset is not constant. Zones like America/New_York switch between -05:00 and -04:00 across the year, so a hard-coded offset is wrong for half of all dates. Adding milliseconds also corrupts the absolute value of the Date. Let Intl.DateTimeFormat (or Temporal.ZonedDateTime) apply the IANA transition rules instead.