Display Time-Ago Labels with Intl.RelativeTimeFormat
To show a localized "3 hours ago" label, compute a signed delta in epoch milliseconds, pick the largest unit whose magnitude is at least one, and pass that value and unit to a cached Intl.RelativeTimeFormat. Part of Intl.RelativeTimeFormat for relative dates.
Why this scenario is tricky
Two failure modes hide in what looks like a one-liner.
First, unit selection. Intl.RelativeTimeFormat formats a number and a unit you hand it — it does not inspect the date or decide whether 5400 seconds reads better as minutes or hours. A naive timeAgo that always uses seconds prints "5400 seconds ago"; one that hardcodes minutes prints "90 minutes ago" for something that should say "1 hour ago". You must walk descending thresholds and stop at the first unit that fits, carrying the sign so direction stays correct.
Second, staleness and update cadence. A label rendered once is a snapshot. Leave the tab open and "2 minutes ago" silently becomes a lie. The fix is a self-updating component, but a fixed one-second interval is wasteful for a label reading "3 years ago", and a one-minute interval makes a fresh "5 seconds ago" label visibly lag. The update interval must scale with the chosen unit. Both problems are made worse if you compute the delta from wall-clock fields (getHours(), getDate()) instead of absolute epoch milliseconds, where a DST shift or month boundary throws the count off by an hour or a day.
Minimal working solution
The shortest correct version: cache one formatter, walk thresholds, keep the sign.
type Unit = Intl.RelativeTimeFormatUnit;
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
// Descending spans in seconds; the first one the magnitude fits in wins.
const STEPS: [number, Unit][] = [
[60, 'second'], [60, 'minute'], [24, 'hour'],
[7, 'day'], [4.34524, 'week'], [12, 'month'], [Infinity, 'year'],
];
function timeAgo(date: Date, now = Date.now()): string {
let d = (date.getTime() - now) / 1000; // signed seconds — negative means past
for (const [span, unit] of STEPS) {
if (Math.abs(d) < span) return rtf.format(Math.round(d), unit); // sign preserved
d /= span; // roll up to the next unit
}
return rtf.format(Math.round(d), 'year');
}
console.log(timeAgo(new Date(Date.now() - 3 * 3600_000))); // "3 hours ago"
Full production version
The production utility validates input, caches per locale/option signature, exposes the chosen unit (so a UI can schedule its own refresh), and accepts an injected "now" for deterministic tests and server rendering.
type Unit = Intl.RelativeTimeFormatUnit;
interface TimeAgoOptions {
locale?: string | string[];
numeric?: 'auto' | 'always';
style?: 'long' | 'short' | 'narrow';
now?: number; // epoch ms; inject on the server / in tests to avoid clock drift
}
interface TimeAgoResult {
label: string;
unit: Unit; // the unit chosen — drive refresh cadence from this
value: number; // signed rounded magnitude
}
// One formatter per (locale, numeric, style). Construction loads CLDR + compiles
// rules, so never rebuild inside a render or an interval tick.
const formatterCache = new Map<string, Intl.RelativeTimeFormat>();
function getFormatter(locale: string | string[], numeric: 'auto' | 'always', style: 'long' | 'short' | 'narrow') {
const key = JSON.stringify([locale, numeric, style]);
let fmt = formatterCache.get(key);
if (!fmt) {
fmt = new Intl.RelativeTimeFormat(locale, { numeric, style });
formatterCache.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' }, // 30.44/7 — average weeks per month
{ amount: 12, unit: 'month' },
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
];
export function timeAgo(date: Date | number, opts: TimeAgoOptions = {}): TimeAgoResult {
const targetMs = typeof date === 'number' ? date : date.getTime();
// Invalid Date -> getTime() is NaN; reject rather than print "NaN years ago"
if (!Number.isFinite(targetMs)) {
throw new RangeError('timeAgo: received an invalid Date or timestamp');
}
const nowMs = opts.now ?? Date.now();
let duration = (targetMs - nowMs) / 1000; // signed seconds; absolute, DST-safe
let chosen: TimeAgoResult = { label: '', unit: 'year', value: Math.round(duration) };
for (const { amount, unit } of DIVISIONS) {
if (Math.abs(duration) < amount) {
chosen = { label: '', unit, value: Math.round(duration) };
break;
}
duration /= amount; // sign survives division, magnitude rolls up
}
const fmt = getFormatter(opts.locale ?? 'en', opts.numeric ?? 'auto', opts.style ?? 'long');
chosen.label = fmt.format(chosen.value, chosen.unit);
return chosen;
}
// How long until the label could change, given its unit. Drives setTimeout cadence.
const REFRESH_MS: Record<string, number> = {
second: 1_000,
minute: 60_000,
hour: 3_600_000,
};
export function refreshDelay(unit: Unit): number {
// Anything day-scale or larger only needs a daily tick
return REFRESH_MS[unit] ?? 86_400_000;
}
A self-updating consumer recomputes on a timer whose interval matches the current unit, so a seconds label ticks every second while a years label barely wakes up:
function mountTimeAgo(el: HTMLElement, date: Date, opts?: TimeAgoOptions): () => void {
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
const { label, unit } = timeAgo(date, opts);
el.textContent = label;
// Reschedule at the cadence of the *current* unit, not a fixed interval
timer = setTimeout(tick, refreshDelay(unit));
};
tick();
return () => clearTimeout(timer); // call on unmount to stop the loop
}
Verification snippet
Inject a fixed now so assertions never race the wall clock, then check unit selection, sign, rounding, and the worded form.
import { timeAgo, refreshDelay } from './time-ago';
const NOW = Date.UTC(2026, 5, 19, 12, 0, 0); // fixed reference instant (epoch ms)
const at = (msAgo: number) => new Date(NOW - msAgo);
// Largest-unit selection and the worded "yesterday" form
console.assert(timeAgo(at(30_000), { now: NOW }).label === '30 seconds ago', 'seconds');
console.assert(timeAgo(at(3 * 3600_000), { now: NOW }).label === '3 hours ago', 'hours');
console.assert(timeAgo(at(24 * 3600_000), { now: NOW }).label === 'yesterday', 'worded day');
// Sign: a future instant must read as future
console.assert(timeAgo(new Date(NOW + 2 * 24 * 3600_000), { now: NOW }).label === 'in 2 days', 'future');
// Rounding: 46 minutes rounds up to one hour and the unit reflects it
const r = timeAgo(at(46 * 60_000), { now: NOW });
console.assert(r.unit === 'hour' && r.label === '1 hour ago', 'rounds up to hour');
// Refresh cadence scales with unit
console.assert(refreshDelay('second') === 1_000, 'second cadence');
console.assert(refreshDelay('year') === 86_400_000, 'year cadence');
// Invalid input is rejected, not silently rendered
let threw = false;
try { timeAgo(new Date('not-a-date'), { now: NOW }); } catch { threw = true; }
console.assert(threw, 'invalid date throws');
console.log('all timeAgo assertions passed');
Common pitfalls
-
Stripping the sign. Computing
Math.abs(delta)and appending your own"ago"breaks future labels and non-English word order.// wrong — loses direction and reinvents localization const mins = Math.abs(Math.round(delta / 60000)); return `${mins} minutes ago`; // right — keep the sign, let CLDR phrase it return rtf.format(Math.round(delta / 60000), 'minute'); -
Fixed update interval. A one-second
setIntervalfor every label burns CPU on a"3 years ago"element. UserefreshDelay(unit)and reschedule each tick. -
Wall-clock delta.
date.getDate() - now.getDate()miscounts across a month boundary or DST shift. SubtractgetTime()(epoch ms), which is absolute. -
Rebuilding the formatter per tick.
new Intl.RelativeTimeFormat(...)insidetick()reloads CLDR on every refresh. Construct once via the cache and reuse.
Frequently Asked Questions
How do I make the label update itself over time?
Recompute on a timer whose interval matches the current unit — every second while the label is in seconds, every minute while in minutes, daily once it reaches days or larger. The refreshDelay(unit) helper returns that interval; reschedule with setTimeout after each tick and clear it on unmount.
Why does my "ago" label show the wrong tense for future dates?
You likely stripped the sign with Math.abs and hardcoded the word "ago". Keep the signed value and pass it straight to Intl.RelativeTimeFormat.format — negative reads as past, positive as future, in the correct word order for every locale.
Can I test timeAgo without the result depending on the real clock?
Yes. Inject a fixed now (epoch ms) through the options argument and assert against it. All examples here pass { now: NOW } so the output is deterministic regardless of when or where the test runs.