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.
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:
- Pluralization is binary. Real languages have up to six plural categories.
1 minute/2 minutesis English-only luck. - No worded forms. You cannot produce
yesterday,tomorrow,last weekwithout per-locale lookup tables. - Word order and spacing vary.
agois a suffix in English, a prefix (il y a) in French, and surrounds the number in others. - Digit shaping ignored. Arabic-Indic or Devanagari numerals require a numbering system you would have to wire up manually.
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 minutes → 1 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
- Passing an unsigned value.
rtf.format(2, 'day')is future; for "ago" you must pass-2. Anti-pattern: computingMath.abs(delta)and bolting on your own "ago". Fix: keep the sign on the value and let the formatter phrase it. - Constructing the formatter per call. Inside a list render or a tick interval,
new Intl.RelativeTimeFormat(...)reloads CLDR every time. Fix: cache by option signature as shown above. - Choosing the unit from wall-clock fields. Subtracting
getHours()/getDate()across a DST change or month boundary miscounts. Fix: subtract epoch milliseconds (orTemporal.Instant), which are absolute. - Forcing
numeric: 'always'then re-deriving "yesterday". You lose the locale's worded forms and reinvent Approach A. Fix: usenumeric: 'auto'and trust CLDR. - Ignoring resolved locale. Assuming your requested locale was honored leads to silent fallback to the host locale. Fix: read
resolvedOptions().localeand assert it in tests.
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 (-2 → 2 ... ago) and positive values are future (2 → in 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.