Skip to content
JavaScript Basics: Describe Relative Times with Intl.RelativeTimeFormat

JavaScript Basics: Describe Relative Times with Intl.RelativeTimeFormat

October 25, 2026

A status display often needs “3 minutes ago” rather than a full timestamp. Intl.RelativeTimeFormat supplies the language and number formatting. Your application still chooses the reference instant, unit, and rounding policy. Keeping those jobs separate makes the result easier to test.

These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only that installed version was tested. They use built-in features and assertions, with no packages, network requests, or data files. All timestamps and locales are explicit. The output below is the observed output.

Prepare a scratch directory

You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep this terminal open. These commands stop the shell if setup fails; save no sources until both directory commands succeed. Save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

1. Choose numeric labels or calendar words

Save this as choose-relative-time-labels.mjs:

import assert from 'node:assert/strict';

assert.deepEqual(Intl.RelativeTimeFormat.supportedLocalesOf(['en-US']), ['en-US']);
const numeric = new Intl.RelativeTimeFormat('en-US', { numeric: 'always' });
const natural = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });
const offsets = [-1, 0, 1, 2];
const numbered = offsets.map(value => numeric.format(value, 'day'));
const named = offsets.map(value => natural.format(value, 'day'));

assert.deepEqual(numbered, ['1 day ago', 'in 0 days', 'in 1 day', 'in 2 days']);
assert.deepEqual(named, ['yesterday', 'today', 'tomorrow', 'in 2 days']);
assert.equal(numeric.format(-0, 'day'), '0 days ago');
assert.equal(natural.format(-0, 'day'), 'today');
console.log(`numeric: ${numbered.join(' | ')}`);
console.log(`auto: ${named.join(' | ')}`);
console.log(`numeric negative zero: ${numeric.format(-0, 'day')}`);

Run it:

node choose-relative-time-labels.mjs

Output:

numeric: 1 day ago | in 0 days | in 1 day | in 2 days
auto: yesterday | today | tomorrow | in 2 days
numeric negative zero: 0 days ago

Negative values describe the past; positive values describe the future. The numeric option controls whether an available word such as “yesterday” can replace a number. It does not select days instead of hours. The negative-zero assertion is useful when a calculation rounds a small negative duration to zero: numeric formatting can still show a past-tense label. See the RelativeTimeFormat specification.

2. Put the rounding policy in a small helper

Save this as format-relative-minutes.mjs:

import assert from 'node:assert/strict';

const formatter = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });

function relativeMinutes(target, reference) {
  if (!(target instanceof Date) || !(reference instanceof Date)) {
    throw new TypeError('Expected two Date objects');
  }
  const targetMs = target.getTime();
  const referenceMs = reference.getTime();
  if (!Number.isFinite(targetMs) || !Number.isFinite(referenceMs)) {
    throw new RangeError('Expected valid dates');
  }
  const rounded = Math.trunc((targetMs - referenceMs) / 60_000);
  const minutes = Object.is(rounded, -0) ? 0 : rounded;
  return formatter.format(minutes, 'minute');
}

const reference = new Date('2026-10-25T12:00:00Z');
const earlier = relativeMinutes(new Date('2026-10-25T11:58:30Z'), reference);
const later = relativeMinutes(new Date('2026-10-25T12:01:30Z'), reference);
const nearby = relativeMinutes(new Date('2026-10-25T11:59:30Z'), reference);
assert.equal(earlier, '1 minute ago');
assert.equal(later, 'in 1 minute');
assert.equal(nearby, 'this minute');
assert.throws(() => relativeMinutes(new Date(NaN), reference), RangeError);
assert.throws(() => relativeMinutes('2026-10-25', reference), TypeError);
console.log(`earlier: ${earlier}`);
console.log(`later: ${later}`);
console.log(`nearby: ${nearby}`);
console.log('invalid date and non-Date input: rejected');

Run it:

node format-relative-minutes.mjs

Output:

earlier: 1 minute ago
later: in 1 minute
nearby: this minute
invalid date and non-Date input: rejected

This helper deliberately reports complete elapsed minutes by truncating toward zero in both directions. A difference of ninety seconds becomes one minute; a difference of thirty seconds becomes zero. The nearby label therefore covers a short rolling interval around the reference, not necessarily the same wall-clock minute. Use a different policy if that is not appropriate for your UI.

Passing the reference as an argument avoids a hidden call to Date.now and makes the examples repeatable. Real applications can pass the current time at their boundary. The helper always uses minutes, even for a very large difference; deciding when to switch to hours or days belongs in another application rule.

3. Keep the numeric parts without parsing the sentence

Save this as inspect-relative-time-parts.mjs:

import assert from 'node:assert/strict';

const formatter = new Intl.RelativeTimeFormat('en-US', { numeric: 'always' });
const parts = formatter.formatToParts(-1234.5, 'second');
const text = parts.map(part => part.value).join('');
const numberParts = parts.filter(part => part.type !== 'literal');
const numberText = numberParts.map(part => part.value).join('');

assert.equal(text, formatter.format(-1234.5, 'second'));
assert.equal(text, '1,234.5 seconds ago');
assert.equal(numberText, '1,234.5');
assert.ok(numberParts.every(part => part.unit === 'second'));
assert.throws(() => formatter.format(Infinity, 'second'), RangeError);
assert.throws(() => formatter.format(1, 'fortnight'), RangeError);

const natural = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });
assert.deepEqual(natural.formatToParts(-1, 'day'), [
  { type: 'literal', value: 'yesterday' },
]);
console.log(`complete text: ${text}`);
console.log(`numeric text: ${numberText}`);
console.log(`numeric types: ${numberParts.map(part => part.type).join(',')}`);
console.log('yesterday: one literal part');
console.log('infinite value and unsupported unit: rejected');

Run it:

node inspect-relative-time-parts.mjs

Output:

complete text: 1,234.5 seconds ago
numeric text: 1,234.5
numeric types: integer,group,integer,decimal,fraction
yesterday: one literal part
infinite value and unsupported unit: rejected

A renderer can style numeric parts while preserving their order and all surrounding literals. Do not assume that the number comes first or that every result contains a number. The yesterday assertion demonstrates the latter case. The formatToParts contract lets the caller retain the formatter’s structure instead of splitting a localized sentence on spaces.

Practical limits

This formatter does not subtract dates, choose units, refresh an old label, or calculate calendar boundaries. In particular, dividing elapsed milliseconds by 86,400,000 is not a general way to count local calendar days across clock changes. Keep those calculations separate. Locale data and punctuation can change between runtimes, so treat these exact English strings as observed tutorial output rather than a cross-version data format. Node’s available locale data depends on its internationalization build configuration.

Clean up

In the same terminal, remove only the three source files, return to your starting directory, and remove the empty scratch directory:

rm -- choose-relative-time-labels.mjs format-relative-minutes.mjs inspect-relative-time-parts.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on