Intl.RelativeTimeFormat for Relative Time Labels: A Native Alternative to moment.js and dayjs
AI generated
JS
() =>
JavaScript · Internationalization · Time Formatting
Intl.RelativeTimeFormat for Relative Time Labels
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.

13 min read Intl.RelativeTimeFormat Time formatting No moment.js

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.

11. FAQ: Intl.RelativeTimeFormat: The Essentials at a Glance

1Does Intl.RelativeTimeFormat compute the time difference itself?
No, the API only formats an already computed numeric value and a unit. Computing the difference between two points in time remains the job of your own code.
2What does numeric: 'auto' do differently from 'always'?
'auto' allows idiomatic short forms like 'yesterday' or 'today', 'always' consistently forces the numeric form like '1 day ago', even when a more natural alternative exists.
3Which time units are supported?
year, quarter, month, week, day, hour, minute, and second, each also in plural form. Quarter is often overlooked but useful for business contexts.
4Does format() round the number automatically?
No, format(2.7, 'day') outputs 'in 2.7 days'. Rounding to whole numbers has to happen before the call, in your own helper function, for example with Math.round().
5Is Intl.RelativeTimeFormat a replacement for moment.js or dayjs?
Only for formatting relative time. For date arithmetic, parsing, or timezone conversion you still need a library like date-fns or the upcoming Temporal.
6How often should a live timestamp update?
Ideally matched to the displayed unit: second-level values need a short interval, day- or week-level values are fine with hourly or even rarer updates.
7What does formatToParts() provide beyond format()?
An array of part objects with type and value instead of a finished string, which lets you style individual parts like the number separately, for example in bold in a UI component.
8Does the API also work for future points in time?
Yes, positive values produce future phrasing like 'in 2 hours', negative values produce past phrasing like '2 hours ago', controlled purely by the sign of the first argument.
9Do I need to load separate data for each locale?
No, the locale data is part of the JavaScript engine or operating system and does not need to be shipped as a separate bundle asset, unlike most library locales.
10Are there browsers or Node versions without support?
All current evergreen browsers and Node.js from version 13 onward fully support Intl.RelativeTimeFormat, older environments may need a polyfill from formatjs.