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
Formatting an instant in a specific display zone is the canonical "show this moment as a clock in that place" operation, and the production rule is to always pass timeZone explicitly and to treat it as a purpose-driven parameter. The same UTC instant renders as different wall-clock times in Tokyo, London, and Los Angeles, and which one you want depends on the feature: an event page shows the event's own zone, a user's dashboard shows the viewer's zone, an ops console might show UTC. Making the zone an explicit argument to your formatting function lets one implementation serve all of these correctly, whereas omitting it silently binds the output to the host machine's zone.
Because Intl.DateTimeFormat resolves the correct offset for the specific instant through the IANA database, the displayed time is automatically correct across daylight-saving changes — a summer instant and a winter instant in the same zone show their respective offsets with no special handling. Cache the formatter per (locale, zone, options), since a page showing many timestamps in a fixed display zone would otherwise reconstruct it repeatedly. And keep the instant as the stored source of truth, deriving the zoned display at render time, so the same moment can be shown in whatever zone each context requires without altering the underlying data.
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
The first pitfall is omitting timeZone, which binds the output to the host zone and makes the same instant display differently on a server than in a user's browser — a subtle, environment-dependent bug. Always pass the zone. The second is hard-coding an offset (UTC-5) instead of an IANA zone, which is wrong for half the year because it ignores daylight saving; pass America/New_York and let Intl apply the correct offset for the instant. The third is formatting the stored value in a fixed zone when the feature actually wants the viewer's zone (or vice versa); decide per feature which zone the display means.
A fourth pitfall is reconstructing the formatter for every timestamp in a list, which is a measurable cost; cache per (locale, zone, options). A fifth is confusing "format this instant in a zone" with "reinterpret this wall time as being in a zone" — the former projects a known moment, the latter changes which moment you mean; this page is about the former, and mixing them shifts the result by the offset. Keeping the instant canonical and the display zone explicit avoids the whole cluster.
- 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.
How do I format a date in a specific time zone for display?
Pass timeZone explicitly to Intl.DateTimeFormat, e.g. new Intl.DateTimeFormat(locale, { timeZone: 'Asia/Tokyo', ... }).format(instant). The instant is projected into that zone and rendered as its wall-clock time, regardless of the host zone. Use an IANA identifier (not a fixed offset) so the correct offset for that specific instant, including daylight saving, is applied automatically.
Why use an IANA zone instead of a fixed offset for display?
Because a fixed offset like UTC-5 is only correct for part of the year — it ignores daylight saving — so an instant in the other season displays an hour off. An IANA identifier like America/New_York names the full rule set, and Intl.DateTimeFormat applies the correct offset for the specific instant. Always pass the IANA zone and let the platform resolve the offset.
Which time zone should I format a stored instant in?
Whichever the feature means: an event's own zone for an event page, the viewer's zone for a personal dashboard, UTC for an ops console. The same instant legitimately renders as different wall-clock times, so make the display zone an explicit, purpose-driven argument. Keep the instant as the stored source of truth and derive the zoned display at render time.