Intl.RelativeTimeFormat for Relative Dates

Render localized relative labels — 3 hours ago, in 2 days, last week — without a formatting library, by mapping a time delta to the right unit and handing it to the platform's CLDR data. Part of Intl API & Legacy Date Patterns.

What breaks without it

Hand-rolled "time ago" strings break the moment your audience is not English. "1 days ago" shows the wrong plural; "il y a 1 jours" is wrong in French; languages like Polish, Arabic, and Russian have multiple plural categories (one, few, many, other) that a value === 1 ? 'day' : 'days' ternary cannot express. You also lose words like English "yesterday" / "tomorrow" and the equivalents in every other locale. The result is a UI that looks broken to most of the planet, plus a brittle pile of string concatenation that desyncs from the actual elapsed time as the clock advances.

Intl.RelativeTimeFormat solves the linguistic half: given a numeric value and a unit, it produces a correctly pluralized, correctly worded, locale-aware phrase. Your job shrinks to the arithmetic half — compute a signed delta and choose the largest sensible unit.

This diagram shows the cascade from a raw second-delta down to the unit and output string the formatter receives.

Unit selection cascade for Intl.RelativeTimeFormatA signed delta in seconds is compared against thresholds (60, 3600, 86400, 2629800, 31557600). The first threshold it falls below selects the unit; the value is divided and rounded, then passed to format(value, unit) to produce a localized string.delta = target − now (s)|d| < 60 → second|d| < 3600 → minute|d| < 86400 → hour|d| < 2629800 → day/monthelse → yearround(d / divisor)= valueformat(value, unit)"in 2 days"

API reference

Member Signature Returns Notes
Constructor new Intl.RelativeTimeFormat(locales?, options?) instance locales is a string or ordered array; expensive to build — cache it.
format rtf.format(value: number, unit: Unit) string value is signed: negative = past, positive = future.
formatToParts rtf.formatToParts(value, unit) Array<{ type, value, unit? }> Use for semantic markup or styling the number separately.
resolvedOptions rtf.resolvedOptions() { locale, numeric, style, numberingSystem } Shows the actually-resolved locale after fallback.
supportedLocalesOf Intl.RelativeTimeFormat.supportedLocalesOf(locales) string[] Probe support before committing to a locale.

Unit is one of 'year' | 'quarter' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' (each also accepts a plural form like 'days'). Options:

Option Values Effect
numeric 'always' (default) | 'auto' 'auto' allows worded output (yesterday, next week); 'always' forces numeric (1 day ago).
style 'long' (default) | 'short' | 'narrow' Controls verbosity: in 2 months vs in 2 mo. vs +2mo.

The unit takes a signed value. rtf.format(-1, 'day')"1 day ago"; rtf.format(2, 'day')"in 2 days". The sign carries direction; the magnitude carries quantity.

Approach A: hand-rolled time-ago

A from-scratch implementation makes the limits obvious. It works for English and nothing else.

// Approach A — illustrative only. DO NOT ship to a localized product.
function naiveTimeAgo(date: Date): string {
  const deltaSec = Math.round((date.getTime() - Date.now()) / 1000); // signed seconds
  const abs = Math.abs(deltaSec);
  const suffix = deltaSec < 0 ? 'ago' : 'from now'; // direction handled by hand
  if (abs < 60) return `${abs} seconds ${suffix}`;
  const mins = Math.round(abs / 60);
  // BUG SURFACE: this ternary is the *only* plural rule we get
  const unit = mins === 1 ? 'minute' : 'minutes';
  return `${mins} ${unit} ${suffix}`;
}

Limitations, none of which are fixable without re-implementing CLDR:

Treat Approach A only as the thing Intl.RelativeTimeFormat replaces.

Approach B: Intl.RelativeTimeFormat

The standard API handles pluralization, wording, and digit shaping from CLDR data. You compute the signed delta and pick a unit; it does the linguistics.

// Build once and reuse — see the caching section below.
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'long' });

// numeric:'auto' lets the engine substitute worded forms where the locale has them
console.log(rtf.format(-1, 'day'));  // "yesterday"  (not "1 day ago")
console.log(rtf.format(-2, 'day'));  // "2 days ago"
console.log(rtf.format(3, 'hour'));  // "in 3 hours"

// numeric:'always' forces the numeric phrasing even when a word exists
const numeric = new Intl.RelativeTimeFormat('en', { numeric: 'always' });
console.log(numeric.format(-1, 'day')); // "1 day ago"

// style controls verbosity; locale data does the pluralization
const fr = new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'short' });
console.log(fr.format(-1, 'day'));  // "hier"
console.log(fr.format(-3, 'day'));  // "il y a 3 j" (short style, correct word order)

The crucial detail: the API never inspects a Date. It formats a number plus a unit. Computing that number — the delta and its unit — is entirely on you, which is why unit selection is the real engineering work here.

Choosing the right unit from a delta

A relative label should use the largest unit whose magnitude is at least one. 90 seconds reads better as 2 minutes ago than 90 seconds ago. Walk descending thresholds and stop at the first that fits. Compute the delta in epoch milliseconds so it is timezone-independent and DST-safe — you are subtracting two absolute instants, not wall-clock fields.

type Unit = Intl.RelativeTimeFormatUnit;

// Thresholds in seconds, largest first. Approximate month/year (CLDR averages):
// a month is 30.44 days, a year is 365.25 days — fine for "ago" UIs, not for billing.
const DIVISIONS: { amount: number; unit: Unit }[] = [
  { amount: 60, unit: 'second' },
  { amount: 60, unit: 'minute' },
  { amount: 24, unit: 'hour' },
  { amount: 7, unit: 'day' },
  { amount: 4.34524, unit: 'week' },
  { amount: 12, unit: 'month' },
  { amount: Number.POSITIVE_INFINITY, unit: 'year' },
];

function pickUnit(fromMs: number, toMs: number): { value: number; unit: Unit } {
  let duration = (toMs - fromMs) / 1000; // signed seconds; sign survives division
  for (const { amount, unit } of DIVISIONS) {
    // If the magnitude fits inside this unit's span, this is the unit to use
    if (Math.abs(duration) < amount) {
      return { value: Math.round(duration), unit };
    }
    duration /= amount; // roll up to the next larger unit, sign intact
  }
  return { value: Math.round(duration), unit: 'year' };
}

Rounding choice matters: Math.round gives the nearest unit (46 minutes1 hour), while Math.trunc floors toward zero (46 minutes stays 46 minutes). For "time ago" UIs Math.round reads more naturally; for countdowns where you must not overstate remaining time, Math.trunc is safer.

Production implementation

A unified utility caches one formatter per (locale, numeric, style) combination, validates the input, and survives server rendering by accepting an explicit "now".

type Unit = Intl.RelativeTimeFormatUnit;

interface RelativeOptions {
  locale?: string | string[];
  numeric?: 'auto' | 'always';
  style?: 'long' | 'short' | 'narrow';
  now?: number; // epoch ms; pass a fixed value on the server to avoid clock drift
}

// Cache keyed by resolved option signature. Constructing a formatter is expensive
// (CLDR load + rule compilation); reuse it across every render.
const cache = new Map<string, Intl.RelativeTimeFormat>();

function getFormatter(opts: Required<Omit<RelativeOptions, 'now'>>): Intl.RelativeTimeFormat {
  const key = JSON.stringify([opts.locale, opts.numeric, opts.style]);
  let fmt = cache.get(key);
  if (!fmt) {
    fmt = new Intl.RelativeTimeFormat(opts.locale, {
      numeric: opts.numeric,
      style: opts.style,
    });
    cache.set(key, fmt);
  }
  return fmt;
}

const DIVISIONS: { amount: number; unit: Unit }[] = [
  { amount: 60, unit: 'second' },
  { amount: 60, unit: 'minute' },
  { amount: 24, unit: 'hour' },
  { amount: 7, unit: 'day' },
  { amount: 4.34524, unit: 'week' },
  { amount: 12, unit: 'month' },
  { amount: Number.POSITIVE_INFINITY, unit: 'year' },
];

export function formatRelative(target: Date | number, opts: RelativeOptions = {}): string {
  const targetMs = target instanceof Date ? target.getTime() : target;
  // Guard against Invalid Date (getTime() returns NaN) and bad numbers
  if (!Number.isFinite(targetMs)) {
    throw new RangeError('formatRelative: target is not a valid date or timestamp');
  }
  const nowMs = opts.now ?? Date.now();

  let duration = (targetMs - nowMs) / 1000; // signed seconds
  let chosen: { value: number; unit: Unit } = { value: Math.round(duration), unit: 'year' };
  for (const { amount, unit } of DIVISIONS) {
    if (Math.abs(duration) < amount) {
      chosen = { value: Math.round(duration), unit };
      break;
    }
    duration /= amount;
  }

  const fmt = getFormatter({
    locale: opts.locale ?? 'en',
    numeric: opts.numeric ?? 'auto',
    style: opts.style ?? 'long',
  });
  return fmt.format(chosen.value, chosen.unit);
}

Combining with a Temporal-derived delta

If your data layer already uses Temporal, derive the delta from Temporal.Instant (absolute time) so DST never distorts the count. Temporal.Instant.until returns a Temporal.Duration; read .total('seconds') for a single signed magnitude rather than reasoning about a balanced duration. For full duration mechanics, see Temporal duration arithmetic.

import { Temporal } from '@js-temporal/polyfill';

function relativeFromInstants(target: Temporal.Instant, now: Temporal.Instant, rtf: Intl.RelativeTimeFormat) {
  // .total('second') collapses the duration to one signed number — no balancing surprises
  const seconds = now.until(target).total('second');
  // feed `seconds` into the same DIVISIONS walk as above, then rtf.format(value, unit)
  return seconds;
}

Locale fallbacks

Pass an ordered array; the engine walks it left-to-right until a supported locale is found, then falls back to the runtime default. Probe support up front with supportedLocalesOf when you must guarantee a match.

// 'fr-CA' preferred, then generic 'fr', then 'en' as a backstop
const rtf = new Intl.RelativeTimeFormat(['fr-CA', 'fr', 'en'], { numeric: 'auto' });
console.log(rtf.resolvedOptions().locale); // the locale actually selected after fallback

const supported = Intl.RelativeTimeFormat.supportedLocalesOf(['xx-Fake', 'de']);
// ['de'] — 'xx-Fake' is dropped; use this to decide before constructing

Edge cases

The zero/near-zero delta

A delta under one second rounds to 0. With numeric: 'auto', rtf.format(0, 'second') yields "now" in English — usually what you want. If you prefer "just now" or a hard floor, special-case Math.abs(seconds) < 5 before formatting rather than fighting the rounding.

Boundary rounding flips direction reading

45 minutes rounds to 1 hour, which is fine, but 30 seconds in the future can round to 0 and read as "now" even though it is upcoming. If "in a moment" must stay future-tensed, clamp tiny positive deltas to format(1, 'second') instead of letting them collapse to zero.

Month and year drift

The 30.44-day month and 365.25-day year are CLDR averages, not calendar truth. "2 months ago" may be off by a day or two against an actual Temporal.PlainDate difference. This is acceptable for activity feeds and unacceptable for anything contractual — there, compute calendar months with Temporal and only use RelativeTimeFormat for the final wording.

Stale labels in long-lived UIs

A "2 minutes ago" label rendered once becomes wrong as the page sits open. Recompute on an interval whose cadence matches the unit (every few seconds while in seconds, every minute while in minutes); the dedicated guide below covers the self-updating pattern.

Gotchas & common pitfalls

Testing checklist

Scenario Input (delta) Expected (en, numeric:'auto')
Just now 0 s now
Seconds past -30 s 30 seconds ago
Rounds up to minute -46 s 1 minute ago
Yesterday (worded) -1 day yesterday
Future days +2 days in 2 days
Rolls into months -40 days last month
Year boundary +400 days next year

Pin "now" by injecting opts.now so tests are deterministic and never depend on the wall clock. Run the suite across locales and host zones to catch fallback and DST-edge mistakes:

# A delta computed from epoch ms must be host-zone-independent — prove it
TZ=UTC npx vitest run && TZ=America/New_York npx vitest run && TZ=Asia/Kolkata npx vitest run

Frequently Asked Questions

Does Intl.RelativeTimeFormat pick the unit for me?

No. It only formats a number and a unit you supply. You compute the signed delta (ideally from epoch milliseconds or a Temporal.Instant) and choose the largest sensible unit yourself; the API handles pluralization, wording, and digit shaping.

Why does format(-1, 'day') return "yesterday" instead of "1 day ago"?

Because numeric: 'auto' lets the engine substitute the locale's worded form when one exists. Use numeric: 'always' to force 1 day ago. The default is 'always', so set 'auto' explicitly if you want worded output.

Is the value signed?

Yes. Negative values are past (-22 ... ago) and positive values are future (2in 2 ...). Do not strip the sign; the formatter uses it to phrase direction correctly across locales.

How do I avoid creating a new formatter on every render?

Cache instances keyed by (locale, numeric, style) in a Map and reuse them. Constructing a formatter loads CLDR data and compiles rules, so reuse is essential in lists and interval-driven updates.