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.

timeAgo data flow: delta to label to refresh cadenceAn event in the past and the current moment define a negative epoch-millisecond delta. The delta selects the largest sensible unit and produces a localized label. The chosen unit also sets how often the label should be recomputed.eventnowdelta = event − now (negative ms)pick largest unit"3 hours ago"refresh every: unit cadence

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

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.