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
The production pattern for human-readable durations is a two-stage pipeline — normalize with Temporal, render with Intl.DurationFormat — with the formatter cached per locale and style. Normalization decides how the duration is balanced (do 100 minutes become "1 hr 40 min" or stay "100 min"?) via Temporal.Duration.round with a chosen largestUnit, and rendering turns the balanced record into localized, correctly-pluralized text. Keeping the two stages separate means you can change the granularity without touching the wording and change the locale without touching the arithmetic, and it avoids the manual carry math that mishandles negatives and zero-padding.
Caching matters because durations often appear in lists — task times, media lengths, countdowns — and constructing an Intl.DurationFormat per item is measurable overhead; build one per (locale, style) and reuse it. Because Intl.DurationFormat is relatively new, a production deployment should feature-detect it and provide a fallback (a simpler formatter, or the polyfill) for runtimes that lack it, so the feature degrades gracefully rather than throwing. The end result is durations that are localized, correctly balanced, and cheap to render everywhere.
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
The style-and-unit interaction is the main edge case: style: 'long' produces "1 hour, 40 minutes", 'short' the abbreviated form, 'narrow' the most compact, and 'digital' a clock-style "1:40:00" — and which units appear depends on how you balanced the duration before formatting. A duration rounded to largestUnit: 'hour' will never show days even if it exceeds 24 hours, which may or may not be what you want; decide the balancing deliberately. Zero-valued units are another corner — whether to show "0 minutes" or omit it — that the style and options control.
Calendar-unit durations are the subtler edge. Days, months, and years are variable-length, so a duration containing them cannot be balanced to a single figure without a reference date; if you need to total or re-balance such a duration, that requires a relativeTo anchor at the Temporal stage before formatting. For purely fixed-unit durations (hours, minutes, seconds) no anchor is needed and the pipeline is simplest. Negative durations (an overdue countdown) should be handled explicitly — decide whether to show a sign, a "overdue" label, or clamp to zero — rather than rendering a raw negative, which reads poorly in most locales.
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").
How do I format a human-readable duration in JavaScript?
Use a two-stage pipeline: normalize the duration with Temporal.Duration.round to a chosen largestUnit so units carry correctly, then render it with Intl.DurationFormat in a chosen style ('long', 'short', 'narrow', or 'digital'), which handles localization and pluralization. Cache the formatter per locale and style, and feature-detect Intl.DurationFormat with a fallback since it is relatively new.
What do the Intl.DurationFormat styles produce?
'long' produces worded output like '1 hour, 40 minutes'; 'short' the abbreviated '1 hr, 40 min'; 'narrow' the most compact form; and 'digital' a clock-style readout like '1:40:00'. All render the same balanced Temporal.Duration, so the choice is presentational. Which units appear depends on how you balanced the duration beforehand — a value rounded to largestUnit 'hour' never shows days even past 24 hours.
Do I need a relativeTo anchor to format a duration?
Only if the duration contains calendar units (days, months, years) and you need to total or re-balance it, because those units are variable-length and have no single size without a reference date. Purely fixed-unit durations (hours, minutes, seconds) balance and format with no anchor. Supply relativeTo at the Temporal stage when calendar units are involved, then format the result.
How do I handle a negative or overdue duration when formatting?
Decide explicitly rather than rendering a raw negative, which reads poorly in most locales. Compare the duration against zero and either show a signed value, switch to an 'overdue' label, or clamp to zero, depending on the feature. Handle this at the Temporal stage before formatting so Intl.DurationFormat only ever receives the presentation you intend.
Is Intl.DurationFormat available everywhere?
Not yet — it is a relatively recent addition, so older runtimes may lack it. Feature-detect it and provide a fallback, such as a simpler hand-built formatter for fixed units or the Temporal polyfill's support, so the feature degrades gracefully instead of throwing. Once detected, cache the formatter per locale and style to avoid repeated construction cost in lists of durations.