Native, localized relative time without moment.js or dayjs
For phrases like '3 days ago' or 'in 2 hours' you no longer need an external library: Intl.RelativeTimeFormat handles localized relative time natively in the browser, including rounding logic for all common time units.
Table of Contents
- 1. The Problem With Hand-Built Time Labels
- 2. Basics and Syntax
- 3. Available Time Units
- 4. numeric: 'always' vs. 'auto'
- 5. Computing the Difference and Picking a Unit
- 6. formatToParts for Custom Styling
- 7. Practical Example: Live Activity Feed
- 8. Comparison to moment.js, dayjs, and timeago.js
- 9. Best Practices and Summary
- 10. Summary
- 11. FAQ
1. The Problem With Hand-Built Time Labels
Activity feeds, comment lists, and notifications rarely show an absolute date; they show relative labels like '3 days ago' or 'an hour ago', because that is easier for humans to place than '2026-08-02T14:30:00Z'. That exact formatting was, for years, the domain of libraries: moment.js with its fromNow() method, later dayjs with the relativeTime plugin, or the lightweight timeago.js.
The catch with those solutions: moment.js is now considered frozen and, without tree-shaking, carries considerable bundle weight; dayjs needs an extra plugin plus its own locale files per language; timeago.js often misses edge cases like quarters or narrow formats. Intl.RelativeTimeFormat solves the same task natively, without a single extra line of code for locale data.
2. Basics and Syntax
The constructor new Intl.RelativeTimeFormat(locale, options) creates a formatter whose format() method expects two arguments: a numeric value (positive for the future, negative for the past) and a time unit as a string ('day', 'hour', 'minute', and so on). format(-3, 'day') returns '3 days ago' in English, format(2, 'hour') returns 'in 2 hours'.
Importantly, the method itself does not compute a time difference, it only formats an already computed numeric value. Calculating the difference between two points in time and picking the right unit remains the caller's job, which gives more control but also requires a small helper function.
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
console.log(rtf.format(-3, 'day')); // "3 days ago"
console.log(rtf.format(2, 'hour')); // "in 2 hours"
console.log(rtf.format(0, 'day')); // "today"
3. Available Time Units
As a unit, format() accepts 'year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', and 'second', each also in plural form as 'years' and so on; the engine treats both spellings identically. Choosing the right unit directly affects readability: '3600 seconds' is technically correct but much harder for people to grasp than 'one hour'.
The 'quarter' unit is often overlooked but useful for business dashboards, for example 'last quarter' instead of '3 months ago'. Important: RelativeTimeFormat does not round the number you pass in itself; a call with format(2.7, 'day') still outputs 'in 2.7 days', rounding to whole units has to happen before the call.
4. numeric: 'always' vs. 'auto'
The numeric option controls whether idiomatic short forms like 'yesterday', 'today', or 'tomorrow' are used. With numeric: 'auto', format(-1, 'day') returns the more natural string 'yesterday' instead of '1 day ago'. With numeric: 'always', the engine consistently forces the numeric form, even where an idiomatic alternative would exist.
In practice, 'auto' is almost always the better choice for UI text because it sounds more natural, while 'always' fits situations where consistent, predictable formatting matters more than natural language, for example in tabular overviews where varying phrase lengths would disrupt the layout.
const auto = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
console.log(auto.format(-1, 'day')); // "yesterday"
const always = new Intl.RelativeTimeFormat('en', { numeric: 'always' });
console.log(always.format(-1, 'day')); // "1 day ago"
5. Computing the Difference and Picking a Unit
Since the API itself does not compute time differences, almost every project needs a small helper function that determines the right unit and rounded value from two points in time. A common approach is a threshold cascade: if the difference is under 60 seconds, format in seconds; under 60 minutes, format in minutes, and so on up to years.
It matters to round consistently from the same reference point, usually with Math.round() or Math.trunc(), so the display does not jitter slightly on every re-render. For live displays that update on an interval, Math.floor() is recommended for past values, so 'a few seconds ago' does not jump to '1 minute ago' after only half a second.
function formatRelative(date, locale = 'en') {
const diffSeconds = (date.getTime() - Date.now()) / 1000;
const units = [
['year', 31536000], ['month', 2592000], ['week', 604800],
['day', 86400], ['hour', 3600], ['minute', 60], ['second', 1],
];
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
for (const [unit, secondsInUnit] of units) {
if (Math.abs(diffSeconds) >= secondsInUnit || unit === 'second') {
return rtf.format(Math.round(diffSeconds / secondsInUnit), unit);
}
}
}
6. formatToParts for Custom Styling
Like many Intl APIs, RelativeTimeFormat also offers a formatToParts() method that returns an array of part objects, each with type ('literal' or 'integer') and value, instead of a finished string. That lets you, for example, render only the number in bold while the surrounding text stays normal, without parsing the formatted string with regex.
This is especially useful in component frameworks where individual parts of a string should become separate elements, for example {number} {unit}. Without formatToParts you would either have to guess at the whole sentence structure or operate with language-dependent regex patterns, which breaks again with every newly supported language.
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'always', style: 'long' });
console.log(rtf.formatToParts(-3, 'day'));
// [{type:'integer',value:'3',unit:'day'}, {type:'literal',value:' days ago'}]
7. Practical Example: Live Activity Feed
A classic use case is an activity feed whose timestamps update without a page reload, for example from '30 seconds ago' to '1 minute ago'. That combines the formatRelative helper from section 5 with setInterval, which recomputes the display at a sensible cadence without re-rendering the entire page.
One detail that is easy to miss: the interval should match the currently displayed unit. For second-level values a one-second interval is fine, for day-level values a per-minute update would be wasteful; an hourly update, or even one only on tab focus, is enough and saves CPU time and battery on mobile devices.
function bindLiveTimestamp(element, date) {
const update = () => { element.textContent = formatRelative(date); };
update();
const interval = setInterval(update, 30000);
return () => clearInterval(interval); // Cleanup
}
8. Comparison to moment.js, dayjs, and timeago.js
moment.js delivers very similar output via fromNow(), but is now officially considered 'legacy' and, without tree-shaking, carries several hundred kilobytes depending on the locale set. dayjs is much leaner at roughly 2 KB core, but needs an additional plugin for relative time plus its own locale imports per supported language, which have to load in sync with the UI language.
Intl.RelativeTimeFormat naturally adds zero bundle weight since it is part of the JavaScript engine, but it covers formatting only, no date arithmetic, no parsing, no timezone conversion. For pure 'X ago' displays the native API is the leaner choice; for complex date calculations a library like date-fns or Temporal (once stable) still makes sense.
9. Best Practices and Summary
Intl.RelativeTimeFormat replaces an entire class of library for the most common i18n use case, relative time in the UI, without adding a single kilobyte of bundle weight. The API only handles formatting; computing and rounding the time difference remains the job of a small, well-testable helper function in your own code.
In practice, numeric: 'auto' is recommended for more natural UI text, a threshold cascade for unit selection, and, for live updates, an interval matched to the unit instead of a fixed one-second tick. Combined with instance caching like the other Intl APIs, the result is a performant, fully native solution.
| Approach | Bundle Size | Localization | Date Arithmetic |
|---|---|---|---|
| Intl.RelativeTimeFormat | 0 KB (native) | Full via CLDR | No, formatting only |
| moment.js | ~300 KB (without tree-shaking) | Via locale files | Yes |
| dayjs + plugin | ~5 KB + plugin/locales | Via locale imports | Yes |
| timeago.js | ~2 KB | Limited | No |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Intl.RelativeTimeFormat: The Essentials at a Glance
Core principle
format(value, unit) formats an already computed numeric distance, it does not compute time differences itself.
numeric: auto
Produces more natural phrasing like 'yesterday' instead of '1 day ago', recommended for UI text.
Your own helper
A threshold cascade picks the right unit from the time difference and rounds consistently.
Bundle advantage
Zero added weight compared to moment.js or dayjs, since it is part of the JavaScript engine itself.