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
A robust "time ago" formatter picks the largest unit whose magnitude the elapsed time fits — seconds, then minutes, hours, days, and so on — and hands that signed value to Intl.RelativeTimeFormat, which supplies the localized, pluralized phrase ("3 minutes ago", "in 2 hours"). Structuring the unit selection as a descending list of thresholds in seconds, choosing the first the magnitude clears, keeps the logic compact and correct, and delegating the wording to Intl means every locale gets its idiomatic phrasing without per-language code. Using numeric: 'auto' additionally lets the formatter substitute "yesterday"/"tomorrow" for the ±1-day case where the locale has a special word.
Two production concerns shape the design. First, "time ago" is inherently live — it drifts as real time passes — so a rendered "2 minutes ago" must be refreshed, ideally on a cadence matched to the current unit (every few seconds while it reads in seconds, every minute while in minutes) rather than a fixed tight interval that wastes work. Second, past a threshold, relative phrasing stops being useful — "in 400 days" is worse than an absolute date — so switch to a formatted date beyond a chosen range. Deciding that crossover is a product choice, and making it explicit keeps labels meaningful across the whole time span.
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
The first pitfall is hand-writing the pluralization and phrasing ("1 minutes ago"), which is English-centric and grammatically wrong in many languages; delegate to Intl.RelativeTimeFormat. The second is choosing the wrong unit — showing "120 minutes ago" instead of "2 hours ago" — by not selecting the largest fitting unit; use a descending threshold table. The third is failing to refresh a live label, so "just now" is still shown an hour later; schedule updates on a cadence matched to the displayed unit.
A fourth pitfall is computing the elapsed time on the wrong basis for day-granularity labels: "yesterday" is a calendar-day concept in the user's zone, not a raw 24-hour span, so for day-and-above units reduce to civil dates in the user's zone rather than dividing elapsed milliseconds. A fifth is emitting unbounded relative phrases far into the past or future; switch to an absolute date beyond a threshold. Finally, cache the Intl.RelativeTimeFormat per locale rather than constructing it for every item in a long feed, where the repeated construction cost is noticeable.
-
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.
How do I display 'time ago' labels with Intl.RelativeTimeFormat?
Select the largest unit whose magnitude the elapsed time fits — using a descending table of second thresholds — and pass that signed value to Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(value, unit). It returns the localized, pluralized phrase like '3 minutes ago' or 'in 2 hours', and with numeric: 'auto' it uses special words such as 'yesterday' where the locale has one. Refresh the label as time passes, and switch to an absolute date past a chosen range.
How often should a 'time ago' label update?
On a cadence matched to the unit currently displayed: every few seconds while it reads in seconds, every minute while in minutes, and progressively less often as the unit grows. A fixed tight interval wastes work, while never refreshing leaves stale labels like 'just now' shown an hour later. Matching the refresh to the displayed unit keeps the label honest without unnecessary re-renders.
Should 'time ago' always use relative phrasing?
No. Past a threshold, relative phrases like 'in 400 days' are less legible than an absolute date, so switch to a formatted date beyond a chosen range — many UIs stay relative within a week and go absolute after. Also compute day-and-above units on civil dates in the user's zone, since 'yesterday' is a calendar-day concept rather than a raw 24-hour span.