My Duration Formatter Broke in French. Intl.DurationFormat Doesn't.
Here's a function that ships in more codebases than anyone wants to admit: function formatDuration(hours, minutes, seconds) { const parts = []; if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
Here's a function that ships in more codebases than anyone wants to admit:
function formatDuration(hours, minutes, seconds) {
const parts = [];
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
if (seconds > 0) parts.push(`${seconds} second${seconds !== 1 ? "s" : ""}`);
return parts.length ? parts.join(", ") : "0 seconds";
}
formatDuration(1, 46, 40); // "1 hour, 46 minutes, 40 seconds"
It works. It ships. Code review doesn't blink — the ternary is a little ugly, but it's correct, and everyone's seen worse. Then the product adds a French locale, and formatDuration keeps returning "1 hour, 46 minutes, 40 seconds" to someone whose UI is in French everywhere else.
Nobody wrote a bug here. formatDuration does exactly what it was written to do. The bug is that it was only ever written to do one thing: speak English.
The ternary that only counts to two
The !== 1 ? "s" : "" trick works in English because English plurals are binary — one thing, or not-one things. Pick any other language and that assumption quietly stops holding:
- French still needs the trailing "s" logic, but also reorders the sentence with "et" before the last item:
1 h, 46 min et 40 s. - Russian and Polish have three plural categories, chosen by the last digit of the number, with exceptions for 11–14.
- Arabic has up to six — zero, one, two, few, many, other — each a genuinely different word shape, not just a suffix.
A two-way ternary can express exactly one rule: English's. It's not almost right for everything else; it's correct for one language and silently wrong for the rest, in a way that never throws, never logs, and never shows up until someone files "the app is broken in French" with no repro steps that mean anything to you.
This is also why the npm ecosystem has half a dozen duration-formatting packages, most of them shipping a locale-data JSON file to work around exactly this. The browser has had equivalent locale data loaded since the day it started, for Intl.NumberFormat and Intl.RelativeTimeFormat. Duration formatting was the gap. As of 2025, it isn't anymore.
Intl.DurationFormat, tested for real
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Intl.DurationFormat takes a locale and a style, and formats a duration object built from plural field names — hours, minutes, seconds, up through years:
const dtf = new Intl.DurationFormat("en", { style: "long" });
dtf.format({ hours: 1, minutes: 46, seconds: 40 });
// "1 hour, 46 minutes, 40 seconds"
new Intl.DurationFormat("fr-FR", { style: "short" })
.format({ hours: 1, minutes: 46, seconds: 40 });
// "1 h, 46 min et 40 s"
Same duration, same call shape, correct output in both languages — word order, conjunction, and all. Nothing about your code changes between locales; only the string you pass as the first argument does. Four style values cover the usual cases, and short is the default if you omit it:
const d = { hours: 1, minutes: 46, seconds: 40 };
new Intl.DurationFormat("en", { style: "long" }).format(d); // "1 hour, 46 minutes, 40 seconds"
new Intl.DurationFormat("en", { style: "short" }).format(d); // "1 hr, 46 min, 40 sec"
new Intl.DurationFormat("en", { style: "narrow" }).format(d); // "1h 46m 40s"
new Intl.DurationFormat("en", { style: "digital" }).format(d); // "1:46:40"
I ran all four of those in a real browser before putting them in this post, rather than trust a blog post that did the same thing eight months before this API's behavior settled — and they're exactly what's above, character for character.
Three sharp edges, found by actually calling it
The surface API is simple. A few of its rules only show up when you poke at it, and they're worth knowing before they poke back.
Field names are strict, and wrong ones don't warn you. The object is keyed by plural unit names only — hours, not hour or h. Pass the wrong key and it isn't a typo warning, it's silence followed by a real error the moment nothing valid is left to format:
new Intl.DurationFormat("en").format({ h: 1, m: 30 });
// throws a TypeError — no valid duration fields were provided
Every field has to be a whole number — fractions need a different lever. format({ hours: 1.5 }) throws, because Intl.DurationFormat validates its input the same way Temporal.Duration does, and Temporal.Duration fields are integers. If you need sub-second precision, it's a separate option, paired with a milliseconds field:
new Intl.DurationFormat("en", { style: "digital", fractionalDigits: 2 })
.format({ minutes: 5, seconds: 30, milliseconds: 500 });
// "0:05:30.50"
Negative durations work — mixed-sign ones don't. This one surprised me. I assumed negative input would just throw, the way the fractional case does. It doesn't, as long as every non-zero field agrees on sign:
new Intl.DurationFormat("en").format({ hours: -1, minutes: -30 });
// "-1 hr, 30 min" — the sign is shown once, on the duration as a whole
new Intl.DurationFormat("en").format({ hours: -1, minutes: 30 });
// throws a RangeError — one field's positive, one's negative
That's not a bug to route around — it's the formatter refusing to guess what a duration with one negative field and one positive one is even supposed to mean. If you're formatting something that can go negative, like time remaining past a deadline, normalize the sign yourself before you build the record, rather than discover this in production.
One more that's easy to miss: digital style always renders hours, minutes, and seconds, even ones you never passed in. format({ minutes: 5, seconds: 30 }) with no hours field at all still prints "0:05:30", not "5:30" — useful once you expect it, confusing the first time your 5-minute timer grows a leading zero you didn't ask for.
It also takes a Temporal.Duration — or just a string
If you're already building a Temporal.Duration (durations as mutation-proof values, not raw numbers, is the whole pitch of that API), you don't need to unpack it into a plain object first — it has its own toLocaleString, backed by the same formatter:
const d = Temporal.Duration.from({ hours: 1, minutes: 46, seconds: 40 });
d.toLocaleString("fr-FR", { style: "long" });
// "1 heure, 46 minutes et 40 secondes"
And format() itself accepts a bare ISO 8601 duration string, no object at all:
new Intl.DurationFormat("en").format("PT1H46M40S");
// "1 hr, 46 min, 40 sec"
Which matters if a duration is already coming off the wire as an ISO string — a common shape for API responses — and you'd otherwise be writing a parser just to hand the pieces back to a formatter.
The honest caveat
Intl.DurationFormat reached Baseline "newly available" in March 2025 — current Chrome, Firefox, and Safari all ship it, but "newly available" means roughly the last year and a half, not the last decade. Feature-detect before you rely on it in anything that has to work everywhere today:
const canUseDurationFormat = typeof Intl.DurationFormat !== "undefined";
And check your runtime, not just your browser target: as of this writing, Node 22 doesn't have it, Node 23 does — I checked both directly rather than trust a changelog. If part of your duration formatting happens server-side, that's the kind of thing that passes locally on a newer machine and fails in a container pinned to an older image.
None of that is a reason to keep the ternary around as the permanent solution. It's a reason to wrap the native call in a small fallback for now, and delete the fallback later — which is a much smaller fix than discovering, from a support ticket in a language you don't read, that your duration formatter was never really finished.
What's the oldest hand-rolled formatDuration in your codebase, and how many locales does it silently fail in right now?
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.