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.

Relative-time APIsProducing localized relative labelsRelative-time APIsIntl.RelativeTimeFormat'in 3 days' / '3 days ago'rtf.format(value, unit)signed value: −past, +futurenumeric: 'auto''yesterday' vs '1 day ago'style: short/narrow'3 days' vs '3d'

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.

Hand-rolled labelsManual thresholds miss locale and futureLegacyModernMath.floor(delta/86400)+ ' days ago'English onlyno future, no pluralsIntl.RelativeTimeFormatlocalized + pluralspast and futureone API

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.

Delta to labelChoose a unit, format the signed valueDelta to labeldelta (s)unit + signrtf.format

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.

Threshold ladderWalk seconds → minutes → hours → days → years by magnitudeThreshold ladder< 60 sseconds< 60 mminutes< 24 hhourselsedays/years

Production implementation

A production relative-time system has three parts: a unit-selection step that picks the largest unit whose magnitude the delta fits, the Intl.RelativeTimeFormat call that localizes the chosen value, and a refresh mechanism that keeps live labels honest. The unit selection is best expressed as a descending table of thresholds in seconds — year, month, week, day, hour, minute, second — choosing the first the magnitude clears, which keeps "120 minutes ago" from appearing instead of "2 hours ago". Delegating the wording to Intl with numeric: 'auto' yields idiomatic phrases including the special "yesterday"/"tomorrow" forms, in every locale, with no per-language code.

The refresh mechanism matters because relative labels drift with real time. Schedule updates on a cadence matched to the displayed unit — frequently while the label reads in seconds, less often as it grows — rather than a fixed tight interval that wastes work, and stop refreshing once the label switches to an absolute date past a threshold. For day-and-above granularity, compute the delta on civil dates in the user's zone rather than raw elapsed milliseconds, because "yesterday" is a calendar-day concept. Cache the formatter per locale, since relative labels commonly appear in long feeds where repeated construction is costly.

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

Robust time-ago utilityThreshold table + cached formatter + signRobust time-ago utilitydeltaunit tableRelativeTimeFormatlabel

Edge cases

The ±1 special-word cases are the signature edge: with numeric: 'auto', an offset of ‑1 day renders "yesterday" and +1 "tomorrow", while numeric: 'always' forces "1 day ago"/"in 1 day". Choose the mode to match the feature — most user-facing labels want 'auto'. The unit-boundary cases are next: a delta right at 60 minutes should read "1 hour ago", not "60 minutes ago", which the largest-fitting-unit selection handles, and a delta of a few seconds should read "just now" or "in a few seconds" depending on your rounding.

The calendar-versus-elapsed distinction is the subtle edge for day granularity. "Yesterday" depends on which side of midnight in the user's zone two timestamps fall, not on a 24-hour elapsed span, so a message sent 20 hours ago can be "today" or "yesterday" depending on the clock. Compute day-level deltas on PlainDate values in the user's zone. Finally, very large deltas ("in 500 days") are legible-but-useless, so switch to an absolute formatted date beyond a chosen range, and be deliberate about future-versus-past sign handling so "in" and "ago" are never swapped.

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.

Relative-time edge casesBoundaries and directionRelative-time edge casesJust now< 1 min'now'Futurepositive value'in 2 days'numeric auto'yesterday'vs '1 day ago'

Gotchas & common pitfalls

Relative-time pitfallsSign and unit mistakesWrongRightalways subtract now−tfuture shows 'ago'signed valuedirection correctfixed 'days' unit'400 days ago'pick unit by magnitude'last year'

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

Relative-time test matrixUnits and direction assert correctlyAssertions that prove the edge case−45 s45 seconds ago+3 hin 3 hours−2 d auto'2 days ago'−400 dlast year

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.

How do I build a relative-time (time-ago) system with Intl?

Select the largest unit whose magnitude the delta fits using a descending table of second thresholds, pass that signed value to Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(value, unit) for localized phrasing, and refresh live labels on a cadence matched to the displayed unit. Compute day-and-above deltas on civil dates in the user's zone, cache the formatter per locale, and switch to an absolute date past a chosen range.

What is the difference between numeric 'auto' and 'always' in RelativeTimeFormat?

With numeric: 'auto', the formatter uses idiomatic special words where the locale has them — 'yesterday', 'today', 'tomorrow' — for small offsets, while numeric: 'always' forces the numeric form like '1 day ago' and 'in 1 day'. Most user-facing relative labels want 'auto' so each language produces its natural word; use 'always' only when you specifically need the numeric phrasing.

Why compute day-level relative time on civil dates in the user's zone?

Because 'yesterday' and 'tomorrow' are calendar-day concepts anchored to where midnight falls, not raw 24-hour elapsed spans. A message sent 20 hours ago can be 'today' or 'yesterday' depending on which side of the user's midnight the two timestamps sit. Reducing to PlainDate values in the user's zone makes the label match the user's calendar; dividing elapsed milliseconds does not.