DEV Community

Cover image for Why 'x time ago' is broken everywhere and how to actually fix it
Alan West
Alan West

Posted on

Why 'x time ago' is broken everywhere and how to actually fix it

The bug that's been quietly spreading across the web

Have you noticed something weird lately? You open a forum, a news site, a social platform — and the timestamps are off. Something posted "2 hours ago" was actually posted yesterday. A comment marked "just now" is from last week. I started spotting it a few months back and assumed it was a one-off. Then I saw it on three more sites in a single afternoon.

It's not a coincidence. It's a class of bug that's quietly piling up because of how most teams implement relative timestamps. Let me walk through what's actually going wrong, and how to fix it properly.

What's actually happening under the hood

The typical implementation looks something like this:

// The classic broken version
function timeAgo(timestamp) {
  const seconds = Math.floor((Date.now() - timestamp) / 1000);

  if (seconds < 60) return 'just now';
  if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
  return `${Math.floor(seconds / 86400)} days ago`;
}
Enter fullscreen mode Exit fullscreen mode

Looks fine, right? It is — at the moment the HTML is generated. The problem is that this function runs once, on the server, when the page is rendered. Then the result gets baked into the HTML as a static string.

With aggressive CDN caching, edge rendering, and longer cache TTLs that became popular over the last couple of years, that HTML can sit in a cache for hours. So a page rendered Tuesday morning saying "5 minutes ago" might be served to someone Wednesday afternoon. The string never updates.

I ran into exactly this issue on a project last month. Our "posted X ago" labels looked normal on first load, but our CDN was happily serving cached HTML for up to 6 hours. Anyone hitting a warm cache saw stale timestamps.

The other half of the problem

Even sites that compute the timestamp client-side often render it once on mount and never touch it again. So if a user leaves a tab open for an hour, "2 minutes ago" is still on screen — even though it's now closer to "an hour ago."

Combine the two issues and you get the mess we're all seeing.

The root cause, stated plainly

Relative timestamps are a derived value. They depend on now(). But "now" is not a constant — it changes every second. Treating the output of timeAgo() as a static string violates that. You're storing a snapshot of a moving target.

The fix has two parts:

  1. Send the absolute timestamp to the browser, not the relative string.
  2. Compute the relative string in the browser, and refresh it periodically.

Step-by-step: building a timestamp that actually works

Step 1: Render the absolute time in the HTML

Use the <time> element. It exists for exactly this purpose and has been part of the HTML spec since HTML5. Stick the ISO-8601 timestamp in the datetime attribute:

<!-- Render this from the server -->
<time datetime="2026-05-15T14:32:00Z" class="relative-time">
  May 15, 2026
</time>
Enter fullscreen mode Exit fullscreen mode

The text content is a fallback for users with JS disabled or for crawlers. The datetime attribute is the source of truth for your JavaScript.

Step 2: Hydrate it on the client

Use the browser's built-in Intl.RelativeTimeFormat. It's well-supported now and handles localization for free. Here's the MDN reference if you want the full API surface.

const rtf = new Intl.RelativeTimeFormat(navigator.language, {
  numeric: 'auto', // gives you 'yesterday' instead of '1 day ago'
  style: 'long',
});

function formatRelative(date) {
  const diffMs = date.getTime() - Date.now();
  const diffSec = Math.round(diffMs / 1000);
  const absSec = Math.abs(diffSec);

  // Pick the right unit based on magnitude
  if (absSec < 60) return rtf.format(diffSec, 'second');
  if (absSec < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
  if (absSec < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
  if (absSec < 2592000) return rtf.format(Math.round(diffSec / 86400), 'day');
  if (absSec < 31536000) return rtf.format(Math.round(diffSec / 2592000), 'month');
  return rtf.format(Math.round(diffSec / 31536000), 'year');
}
Enter fullscreen mode Exit fullscreen mode

Notice that diffMs can be negative (past) or positive (future). Intl.RelativeTimeFormat handles both — you don't need separate branches for "ago" vs "in."

Step 3: Keep it updated while the page is open

This is the part most implementations skip. A label that says "30 seconds ago" should not still say that ten minutes later. Run a single ticker that updates every visible <time> element:

function refreshAllTimestamps() {
  document.querySelectorAll('time.relative-time').forEach((el) => {
    const iso = el.getAttribute('datetime');
    if (!iso) return;
    el.textContent = formatRelative(new Date(iso));
  });
}

// Update once on load, then every 30 seconds
refreshAllTimestamps();
setInterval(refreshAllTimestamps, 30_000);

// Also refresh when the tab regains focus — handles long-idle tabs
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') refreshAllTimestamps();
});
Enter fullscreen mode Exit fullscreen mode

That visibilitychange listener is the bit that catches the "left a tab open overnight" case. Without it, returning to a stale tab still shows yesterday's relative labels.

Step 4: Make the absolute time available on hover

If the user really wants to know when, give them the precise time too:

function refreshAllTimestamps() {
  document.querySelectorAll('time.relative-time').forEach((el) => {
    const iso = el.getAttribute('datetime');
    if (!iso) return;
    const date = new Date(iso);
    el.textContent = formatRelative(date);
    // Tooltip shows the full local time
    el.title = date.toLocaleString();
  });
}
Enter fullscreen mode Exit fullscreen mode

Small thing, but useful. I always end up wanting it on news sites that hide the absolute date.

Why your server-rendered timestamps drift

A quick sanity check on why caching breaks this so easily:

  • Edge caches commonly hold pages for minutes to hours.
  • Service workers can serve cached HTML for even longer.
  • Static site generators bake timestamps at build time — that could be days ago.
  • Even server-side rendered apps with revalidation may serve stale HTML for the duration of the revalidation window.

If any of those are in your stack, server-rendered relative strings are guaranteed to drift. The only correct source of truth at render time is the absolute instant the event happened.

Prevention tips for future you

  • Never serialize a relative time. Treat "X ago" as a UI-only computation, never as data.
  • Always store and transmit UTC. Convert to local time at the very last step in the browser.
  • Watch your clock skew. If the server and client disagree by 30 seconds, you'll see "in 20 seconds" on posts that should say "just now." For chat-style apps, send a server timestamp with the response and use it as the reference clock.
  • Test with a frozen clock. Tools like Jest's useFakeTimers or Vitest's vi.useFakeTimers() let you assert that your formatter handles boundaries (59s → 1m, 23h → 1d) correctly.
  • Audit your CDN headers. If you're caching HTML aggressively, search the response for the literal string ago and minutes. If you find any, you have a bug waiting to happen.

The whole thing comes down to one principle: don't precompute values that depend on the current time, then cache them. It sounds obvious when you say it out loud — but it's exactly the trap half the web is currently stuck in.

Top comments (0)