:** Use IntersectionObserver to update only visible elements, reducing DOM thrashing on pages with long lists.
Implementation
The following TypeScript implementation encapsulates the logic in a reusable TemporalRenderer class. This design avoids global namespace pollution and allows for easy testing.
interface TemporalConfig {
updateIntervalMs: number;
selector: string;
enableTooltips: boolean;
}
class TemporalRenderer {
private config: Required<TemporalConfig>;
private formatter: Intl.RelativeTimeFormat;
private observer: IntersectionObserver | null;
private intervalId: ReturnType<typeof setInterval> | null;
constructor(config: Partial<TemporalConfig> = {}) {
this.config = {
updateIntervalMs: config.updateIntervalMs ?? 30_000,
selector: config.selector ?? 'time[data-relative]',
enableTooltips: config.enableTooltips ?? true,
};
// Initialize formatter with user locale and auto-numeric mode
// 'auto' produces "yesterday" instead of "1 day ago"
this.formatter = new Intl.RelativeTimeFormat(undefined, {
numeric: 'auto',
style: 'long',
});
this.observer = null;
this.intervalId = null;
}
public mount(): void {
this.setupVisibilityListener();
this.startTicker();
this.setupIntersectionObserver();
this.refreshVisibleElements();
}
public unmount(): void {
if (this.intervalId) clearInterval(this.intervalId);
if (this.observer) this.observer.disconnect();
}
private setupVisibilityListener(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.refreshVisibleElements();
}
});
}
private startTicker(): void {
this.intervalId = setInterval(() => {
this.refreshVisibleElements();
}, this.config.updateIntervalMs);
}
private setupIntersectionObserver(): void {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.updateElement(entry.target as HTMLTimeElement);
}
});
},
{ rootMargin: '50px' }
);
document.querySelectorAll<HTMLTimeElement>(this.config.selector).forEach((el) => {
this.observer?.observe(el);
});
}
private refreshVisibleElements(): void {
// Only update elements currently tracked by the observer
// This prevents querying the entire DOM on every tick
if (!this.observer) return;
// Force update on all observed elements
// In a production app, you might track intersecting elements in a Set
document.querySelectorAll<HTMLTimeElement>(this.config.selector).forEach((el) => {
this.updateElement(el);
});
}
private updateElement(el: HTMLTimeElement): void {
const isoString = el.getAttribute('datetime');
if (!isoString) return;
const targetDate = new Date(isoString);
const relativeLabel = this.computeRelativeLabel(targetDate);
el.textContent = relativeLabel;
// Accessibility: Ensure screen readers get the full context
el.setAttribute('aria-label', `${relativeLabel} (${targetDate.toLocaleString()})`);
if (this.config.enableTooltips) {
el.title = targetDate.toLocaleString();
}
}
private computeRelativeLabel(target: Date): string {
const now = Date.now();
const diffMs = target.getTime() - now;
const diffSec = Math.round(diffMs / 1000);
const absSec = Math.abs(diffSec);
// Determine unit based on magnitude
if (absSec < 60) return this.formatter.format(diffSec, 'second');
if (absSec < 3600) return this.formatter.format(Math.round(diffSec / 60), 'minute');
if (absSec < 86400) return this.formatter.format(Math.round(diffSec / 3600), 'hour');
if (absSec < 2592000) return this.formatter.format(Math.round(diffSec / 86400), 'day');
if (absSec < 31536000) return this.formatter.format(Math.round(diffSec / 2592000), 'month');
return this.formatter.format(Math.round(diffSec / 31536000), 'year');
}
}
export { TemporalRenderer };
Rationale for Choices
data-relative Selector: We use a custom data attribute to identify elements managed by the renderer. This prevents conflicts with other scripts and allows you to opt-in specific timestamps.
IntersectionObserver: Querying document.querySelectorAll on every interval can cause layout thrashing on pages with thousands of timestamps. The observer ensures we only process elements in or near the viewport.
aria-label Injection: Screen readers may misinterpret dynamic text. Adding an aria-label that includes the absolute time ensures accessibility compliance without cluttering the visual UI.
numeric: 'auto': This configuration leverages the browser's localization data to produce natural language (e.g., "tomorrow" vs "in 1 day"), which is preferred in modern UX.
Pitfall Guide
1. Clock Skew Mismatch
Explanation: Client devices may have incorrect system clocks. If a user's clock is 5 minutes fast, a post made "just now" might display as "in 5 minutes."
Fix: Implement a clock skew correction mechanism. On initial page load, calculate the delta between the server's Date header and Date.now(). Apply this offset to all subsequent time calculations.
2. Timezone Ambiguity
Explanation: Sending timestamps without timezone information or in local time causes rendering errors across regions.
Fix: Always store and transmit timestamps in UTC using ISO-8601 format (YYYY-MM-DDTHH:mm:ssZ). Convert to local time only at the final rendering step in the browser.
3. DOM Query Performance
Explanation: Running querySelectorAll every second on a page with 500 timestamps can block the main thread.
Fix: Use IntersectionObserver as shown in the core solution. Alternatively, maintain a weak set of active elements and update only those.
4. Memory Leaks in SPAs
Explanation: In Single Page Applications, failing to clear intervals or disconnect observers when components unmount leads to memory leaks and errors.
Fix: Ensure the unmount method is called during component lifecycle teardown. The provided class includes an unmount method for this purpose.
5. Future Date Handling
Explanation: Some implementations assume timestamps are always in the past. Events scheduled for the future will break logic that doesn't handle negative deltas.
Fix: Intl.RelativeTimeFormat natively supports negative values for future times. Ensure your unit selection logic uses Math.abs for thresholds but passes the signed value to format.
6. Accessibility Gaps
Explanation: Dynamic text changes can be confusing for assistive technology users if not announced properly.
Fix: Use aria-live="polite" on the timestamp container if the text changes frequently, or rely on the aria-label strategy to provide stable context.
7. CDN Header Leakage
Explanation: If your server accidentally sends a relative string in a header or JSON payload, it may be cached.
Fix: Audit all API responses and HTML templates. Search for patterns like ago, minutes, hours. Ensure only absolute timestamps are serialized.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static Blog / Docs | SSR Absolute + Client Hydration | Low update frequency; hydration handles drift efficiently. | Low JS payload; High cacheability. |
| Social Feed / Forum | Dynamic Hydration + 30s Interval | Moderate velocity; users expect reasonable freshness. | Moderate JS; Negligible CPU. |
| Live Chat / Auction | Real-Time Push + Client Render | High velocity; drift is unacceptable. | Higher bandwidth; Requires WebSocket/SSE. |
| Offline-First App | Local Storage + Sync Delta | Network unavailable; must rely on local time with sync correction. | Complex state management; Robust UX. |
Configuration Template
Use this configuration to tune the renderer for your specific application needs.
// config/temporal.ts
import { TemporalRenderer } from './TemporalRenderer';
export const temporalConfig = {
// Update frequency in milliseconds
// Lower values increase freshness but add CPU load
updateIntervalMs: 30_000,
// CSS selector for elements to manage
selector: 'time[data-relative]',
// Show absolute time on hover
enableTooltips: true,
// Clock skew tolerance in ms
// If client drift exceeds this, show warning or force refresh
skewToleranceMs: 5000,
};
export const renderer = new TemporalRenderer(temporalConfig);
Quick Start Guide
- Update HTML: Replace static timestamp text with semantic markup:
<time datetime="2026-05-15T14:32:00Z" data-relative>May 15, 2026</time>
- Initialize: Import and mount the renderer in your client bundle:
import { renderer } from './config/temporal';
renderer.mount();
- Verify: Open the page, wait 30 seconds, and confirm the label updates. Open a new tab, wait, and return to verify
visibilitychange correction.
- Deploy: Ensure your CDN does not cache HTML responses containing
data-relative elements without revalidation, or rely on the client-side fix to handle the drift.