Intl.DurationFormat for Human-Readable Durations
Render durations such as 2h 15m or 1:35:00 in any locale without hand-built string concatenation, using the Intl.DurationFormat API. Part of Intl API & Legacy Date Patterns.
Problem framing
Durations show up everywhere: video runtimes, cooking timers, "estimated time remaining", session lengths, SLA windows. Developers reach for manual string building — ${hours}h ${minutes}m — and immediately hit problems. The output is not localized (English-only abbreviations), pluralization is wrong (1 hours), unit ordering and separators differ per locale, and values are never normalized (you display 0h 95m instead of 1h 35m). Intl.DurationFormat solves all of these from CLDR data, but it is brand new, so you must guard for availability.
How styles map a duration record to output
Intl.DurationFormat takes a plain duration record — an object of unit keys — and renders it according to a style and per-unit display options. The hardest thing to keep straight is how the four styles change the same record into very different strings, and how that interacts with locale. The diagram below shows one Temporal-derived duration rendered across styles and locales.
The record drives what values exist; the style drives how each unit's label and the separators are rendered; the locale drives word forms, list conjunctions, and digit shapes.
API reference
| Member | Signature | Returns | Notes |
|---|---|---|---|
| Constructor | new Intl.DurationFormat(locales?, options?) |
Intl.DurationFormat |
Expensive — cache the instance like Intl.DateTimeFormat. |
.format() |
df.format(duration) |
string |
duration is a record { years?, months?, weeks?, days?, hours?, minutes?, seconds?, milliseconds?, microseconds?, nanoseconds? }. A Temporal.Duration is accepted directly since it is structurally compatible. |
.formatToParts() |
df.formatToParts(duration) |
Array<{ type, value, unit? }> |
For semantic markup / per-unit styling. |
options.style |
'long' | 'short' | 'narrow' | 'digital' |
— | Sets the default per-unit style; 'digital' renders hours/minutes/seconds as a clock (1:35:00). |
options.<unit> |
e.g. hours: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit' |
— | Per-unit override of the style default. |
options.<unit>Display |
'auto' | 'always' |
— | 'always' keeps a zero-valued unit; 'auto' drops it. |
options.fractionalDigits |
number |
— | Decimal places on the smallest displayed unit. |
There are no timezone caveats here — a duration is an elapsed amount, not a point in time. The caveats are availability (Stage 3) and normalization (the API does not carry minutes into hours for you).
Availability and the polyfill (read this first)
Intl.DurationFormat is a TC39 Stage 3 proposal and is only present in recent engines. Never assume it exists. Feature-detect and fall back to the @formatjs/intl-durationformat polyfill (install it from npm as @formatjs/intl-durationformat):
// Feature-detect, then lazily install the polyfill if missing.
async function ensureDurationFormat(): Promise<void> {
// The constructor is absent on older runtimes — guard before using it.
if (typeof (Intl as any).DurationFormat === 'function') return;
const { DurationFormat } = await import('@formatjs/intl-durationformat');
// Attach to the Intl namespace so call sites stay engine-agnostic.
(Intl as any).DurationFormat = DurationFormat;
}
Approach A: manual string building (fragile, avoid)
The tempting non-API approach concatenates strings by hand. It is not localized, mishandles pluralization, and forces you to normalize manually.
// Fragile: English-only labels, naive pluralization, manual carry.
function manualFormat(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60); // manual carry from seconds
const s = totalSeconds % 60;
const parts: string[] = [];
if (h) parts.push(`${h} hour${h === 1 ? '' : 's'}`); // breaks for languages with >2 plural forms
if (m) parts.push(`${m} minute${m === 1 ? '' : 's'}`);
if (s) parts.push(`${s} second${s === 1 ? '' : 's'}`);
return parts.join(', '); // wrong list conjunction in most locales
}
manualFormat(8130); // '2 hours, 15 minutes, 30 seconds' — only correct in English
Limitations: no locale awareness, wrong plural rules (Polish, Russian, Arabic have multiple plural categories), wrong list separators, no digital clock style, and you own all the carry math.
Approach B: Intl.DurationFormat
The same output, localized, with correct pluralization and list formatting handled by CLDR.
import { Temporal } from '@js-temporal/polyfill';
await ensureDurationFormat(); // installs polyfill on older engines
// Cache the formatter — construction parses locale data and is expensive.
const longFmt = new Intl.DurationFormat('en-US', { style: 'long' });
const digitalFmt = new Intl.DurationFormat('en-US', { style: 'digital' });
const d = { hours: 2, minutes: 15, seconds: 30 };
longFmt.format(d); // '2 hours, 15 minutes, 30 seconds'
digitalFmt.format(d); // '2:15:30'
// Per-unit override: long hours, narrow minutes, hide zero seconds.
const mixed = new Intl.DurationFormat('en-US', {
style: 'short',
hours: 'long', // override just this unit
secondsDisplay: 'auto', // drop seconds when they are 0
});
mixed.format({ hours: 1, minutes: 5, seconds: 0 }); // '1 hour, 5 min'
Note the disambiguation between style and per-unit keys: style sets defaults, individual unit keys override them. <unit>Display: 'always' is the equivalent of forcing a zero to appear (useful for clocks that must show 0:05:00).
Production implementation
A reusable utility normalizes input with Temporal.Duration, validates it, guards availability, and caches formatters per locale+style.
import { Temporal } from '@js-temporal/polyfill';
type DurStyle = 'long' | 'short' | 'narrow' | 'digital';
const fmtCache = new Map<string, Intl.DurationFormat>();
function getFormatter(locale: string, style: DurStyle): Intl.DurationFormat {
const key = `${locale}|${style}`;
let f = fmtCache.get(key);
if (!f) {
f = new Intl.DurationFormat(locale, { style }); // build once, reuse
fmtCache.set(key, f);
}
return f;
}
export async function formatDuration(
input: Temporal.Duration | Temporal.DurationLike,
opts: { locale?: string; style?: DurStyle; largestUnit?: Temporal.SmallestUnit<'hours'> } = {},
): Promise<string> {
await ensureDurationFormat();
const { locale = 'en-US', style = 'short', largestUnit = 'hours' } = opts;
// Normalize first: round() carries overflow (95 min -> 1h 35m) and clamps the top unit.
// 'relativeTo' is omitted because hours/minutes/seconds are exact (no calendar units here).
const normalized = Temporal.Duration.from(input).round({ largestUnit });
// A negative Temporal.Duration formats with a locale-aware minus sign automatically.
return getFormatter(locale, style).format(normalized);
}
// SSR / serverless: this is timezone-independent, so it is safe to render on the
// server and hydrate on the client with no zone drift — unlike Intl.DateTimeFormat.
If a value carries calendar units (years, months, weeks) you cannot round() past days without a relativeTo, because month/year lengths vary — see the Temporal duration arithmetic guide for .round({ largestUnit, relativeTo }) and .total().
Edge cases
Zero and sub-second values
An all-zero record renders as the smallest displayed unit at zero (e.g. '0 sec'), not an empty string. Control which units survive with <unit>Display. For sub-second precision, set fractionalDigits and supply milliseconds/microseconds/nanoseconds; the fractional part attaches to the smallest unit shown.
const f = new Intl.DurationFormat('en-US', { style: 'digital', fractionalDigits: 3 });
f.format({ seconds: 5, milliseconds: 250 }); // '0:00:05.250'
Negative durations
A negative Temporal.Duration (sign === -1) formats with the locale's minus sign across all units. Do not prepend your own -; that double-signs the output.
Un-normalized overflow
Intl.DurationFormat does not carry units. Passing { minutes: 95 } renders '95 min', not '1 hr 35 min'. Always round({ largestUnit }) before formatting. See format a duration as hours and minutes for the full normalization recipe.
Digital style with missing units
style: 'digital' defaults hours/minutes/seconds to 'numeric'/'2-digit' and shows 0:00 shape. If you only supply { minutes: 5 } it still renders the clock as 0:05:00 because the clock units are forced to display.
Gotchas & common pitfalls
- Assuming the API exists — calling
new Intl.DurationFormat()on Safari/older Node throws. Fix: feature-detect and lazy-load@formatjs/intl-durationformat. - Formatting un-normalized records —
{ minutes: 150 }prints150 min. Fix:Temporal.Duration.from(d).round({ largestUnit: 'hours' })first. - Constructing in a loop — same cost profile as
Intl.DateTimeFormat. Fix: cache perlocale+style. - Hand-pluralizing or hand-joining — breaks in multi-plural-form locales. Fix: let
style+ locale do it. - Rounding calendar units without
relativeTo—round({ largestUnit: 'years' })on a duration withdaysthrows. Fix: passrelativeTo(aPlainDate/ZonedDateTime) so variable month lengths resolve.
Testing checklist
| Scenario | Input | Expected (en-US) |
|---|---|---|
| Long style, full | { hours: 2, minutes: 15, seconds: 30 }, style: 'long' |
2 hours, 15 minutes, 30 seconds |
| Digital clock | { hours: 1, minutes: 35 }, style: 'digital' |
1:35:00 |
| Needs normalization | { minutes: 95 } → round({largestUnit:'hours'}), style: 'short' |
1 hr, 35 min |
| Hide zero unit | { hours: 1, seconds: 0 }, secondsDisplay: 'auto' |
1 hr |
| Negative | { minutes: -5 }, style: 'short' |
-5 min |
| Sub-second | { seconds: 5, milliseconds: 250 }, fractionalDigits: 3 |
5.25 sec (long) / 0:00:05.250 (digital) |
Run the suite across locales and against the polyfill to catch native-vs-polyfill drift:
# Force the polyfill path by running on a runtime without native Intl.DurationFormat,
# and sweep locales that exercise multi-plural-form rules.
LOCALES="en-US,fr-FR,pl-PL,ar-EG" npx vitest run duration-format
FAQ
Is Intl.DurationFormat safe to use in production today?
Only with a fallback. It is TC39 Stage 3 and ships in recent Chrome, Node, and Safari, but is absent on older runtimes. Feature-detect typeof Intl.DurationFormat === 'function' and lazy-load the @formatjs/intl-durationformat polyfill when missing.
Does it normalize 95 minutes into 1 hour 35 minutes?
No. Intl.DurationFormat renders exactly the units you pass. Normalize first with Temporal.Duration.from(d).round({ largestUnit: 'hours' }), then format the result.
Can I pass a Temporal.Duration directly to .format()?
Yes. A Temporal.Duration is structurally compatible with the duration record the API expects, so df.format(myDuration) works without conversion.
When should I use this instead of Intl.RelativeTimeFormat?
Use Intl.DurationFormat for an elapsed amount ("2 hr 15 min"). Use Intl.RelativeTimeFormat for a point relative to now ("2 hours ago", "in 3 days").