Tracking Hero Render Time with Element Timing

The Largest Contentful Paint timestamp tells you that something large painted, but it does not promise that the specific element your product cares about — the hero image, the headline, the above-the-fold call to action — was that something. On a page with a full-bleed video poster, an oversized cookie banner, or a late-swapping background, the element your designers obsess over and the element the browser scores as largest can diverge. The Element Timing API closes that gap: you annotate the exact node you care about and the browser reports when it rendered. This page is the runnable answer to one question — how do you measure, to the millisecond, when your hero finishes painting and ship that number to Real-User Monitoring — and it belongs to the Element Timing API section this page sits under. It covers the elementtiming attribute, observing entries with PerformanceObserver and buffered: true, reading renderTime versus loadTime and intersectionRect, the cross-origin Timing-Allow-Origin requirement, and how to reconcile the result against Largest Contentful Paint.

Hero element-timing capture flow A hero element marked with elementtiming paints after navigation start; the browser emits an element entry whose renderTime the observer reads and beacons to RUM, alongside the LCP candidate for comparison. 0 ms time Hero markup elementtiming="hero" renderTime hero painted LCP candidate PerformanceObserver type:'element' → RUM buffered:true replays the entry even if you observe late
Element Timing reports the hero's renderTime directly; LCP may resolve to a different node later. Compare both, then beacon the hero number alongside your LCP measurement.

Prerequisites

Before you instrument hero render time, confirm the following are in place:

  • A genuinely stable hero element. Element Timing reports the first paint of the annotated node. If your hero swaps from a low-quality placeholder to a full image, you must decide which node carries the attribute — annotating the placeholder measures a paint that the user perceives as incomplete.
  • A modern Chromium target. The Element Timing API is shipped in Chromium-based browsers (Chrome, Edge). Safari and Firefox do not implement it as of this writing, so the capture must be feature-detected and treated as a Chromium-only signal.
  • Server control over response headers for any image hosted on a different origin, so you can add Timing-Allow-Origin and unlock a real renderTime instead of a coarsened zero.
  • A working beacon path. This page assumes you already report to an ingestion endpoint via sendBeacon; we only add the hero metric to that existing flow.
  • Familiarity with PerformanceObserver and buffered entry replay, covered in the web-vitals API implementation reference.

How to track hero render time

The four steps below take you from raw markup to a metric in your RUM store. Each step is copy-pasteable and explains why it is necessary.

Step 1 — Annotate the hero with elementtiming

Add the elementtiming attribute to the exact element you want measured. The value is an opaque identifier you choose; it is echoed back on every entry as identifier, so use a stable, descriptive string.

<img
  src="/assets/hero-2400.webp"
  srcset="/assets/hero-1200.webp 1200w, /assets/hero-2400.webp 2400w"
  sizes="100vw"
  width="2400"
  height="1100"
  alt="Product hero"
  fetchpriority="high"
  elementtiming="hero" />

<h1 elementtiming="hero-headline">Ship performance you can prove</h1>

Why: Element Timing only emits entries for elements that carry the attribute, plus images and text considered LCP candidates. Annotating explicitly removes ambiguity — you are no longer guessing which node the browser scored. The elementtiming attribute works on <img>, <image> inside SVG, <video> poster frames, background-image elements, and block-level text containers. Setting width/height and fetchpriority="high" is unrelated to measurement but keeps the hero from shifting layout, which matters when you correlate against Cumulative Layout Shift.

Step 2 — Observe element entries with a buffered observer

Register a PerformanceObserver for the element entry type and pass buffered: true so you receive entries that fired before the observer existed.

function observeHero(onEntry) {
  if (!('PerformanceObserver' in window) ||
      !PerformanceObserver.supportedEntryTypes ||
      !PerformanceObserver.supportedEntryTypes.includes('element')) {
    return; // Element Timing unsupported (Safari, Firefox)
  }

  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.identifier === 'hero' ||
          entry.identifier === 'hero-headline') {
        onEntry(entry);
      }
    }
  });

  po.observe({ type: 'element', buffered: true });
  return po;
}

Why: The hero typically paints early — often before your analytics bundle has executed. Without buffered: true, an observer registered after that paint would never see the entry, silently producing zero hero coverage. Buffering replays entries the browser retained, so even a late-loading script captures the render. Feature-detecting supportedEntryTypes avoids throwing on browsers that do not implement the type.

Step 3 — Read renderTime, loadTime, and intersectionRect

A PerformanceElementTiming entry exposes several fields. Read them with care because their meaning differs between same-origin and cross-origin resources.

function normalizeHero(entry) {
  // renderTime is the paint time; loadTime is when the resource finished
  // downloading. For text, loadTime is 0. For cross-origin images without
  // Timing-Allow-Origin, renderTime is coarsened to 0 — fall back to loadTime.
  const renderTime = entry.renderTime || entry.loadTime;
  const rect = entry.intersectionRect; // visible area at paint, in CSS px
  const wasVisible = rect && rect.width > 0 && rect.height > 0;

  return {
    id: entry.identifier,
    element: entry.element ? entry.element.tagName.toLowerCase() : null,
    url: entry.url || null,            // image URL, '' for text
    renderTime: Math.round(renderTime),
    loadTime: Math.round(entry.loadTime),
    // Coverage: how much of the hero was actually on screen when it painted.
    visibleArea: wasVisible ? Math.round(rect.width * rect.height) : 0,
  };
}
Field Meaning Same-origin Cross-origin without TAO
renderTime First paint of the element (DOMHighResTimeStamp) Real value Coarsened to 0
loadTime Resource download finish; 0 for text Real value Real value
intersectionRect Visible rect at paint time, CSS pixels Populated Populated
identifier Your elementtiming value Echoed Echoed
url Resource URL; '' for text nodes Populated Populated

Why: renderTime is the number you actually want — it answers “when did the user see it.” But the browser refuses to expose a precise cross-origin renderTime unless the resource opts in, returning 0 instead. Falling back to loadTime keeps the metric non-null, and intersectionRect lets you discard heroes that painted off-screen (a rotated carousel slide, for example) so they do not pollute your distribution.

Step 4 — Report the hero metric to RUM

Buffer the normalized entry and flush it on a terminal lifecycle event so the metric survives a fast bounce.

const heroSamples = [];

const observer = observeHero((entry) => {
  heroSamples.push(normalizeHero(entry));
});

function flushHero() {
  if (!heroSamples.length) return;
  const payload = JSON.stringify({
    metric: 'hero_render',
    samples: heroSamples.splice(0), // drain so we never double-send
    nav: Math.round(performance.now()),
    url: location.pathname,
  });
  // sendBeacon survives unload; keepalive fetch is the fallback.
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/rum/ingest', payload);
  } else {
    fetch('/rum/ingest', { body: payload, method: 'POST', keepalive: true });
  }
}

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flushHero();
}, { capture: true });
addEventListener('pagehide', flushHero, { capture: true });

Why: Element entries can arrive after the hero paints but before the user is done with the page, so you accumulate and flush once on visibilitychange/pagehide. Firing on the hidden transition rather than unload is what keeps the beacon reliable on mobile, where unload is frequently skipped. The server then aggregates these at p75 the same way it does any vital. The 75th-percentile hero render time is the headline number to track on your dashboard, not the average — a handful of fast renders should never mask a slow tail.

Verifying it works

Confirm capture before trusting the number on a dashboard:

  • DevTools console. Run the snippet below in a fresh page load. A logged entry with a non-zero renderTime and your identifier proves the attribute and observer are wired correctly.
new PerformanceObserver((l) => {
  for (const e of l.getEntries()) {
    console.log(e.identifier, 'render', Math.round(e.renderTime),
                'load', Math.round(e.loadTime), e.intersectionRect);
  }
}).observe({ type: 'element', buffered: true });
  • Performance panel. Record a load trace; the Timings track shows the element-timing marker at the same offset your console logged, cross-checking that renderTime reflects an actual paint and not a layout event.
  • RUM signal. In your store, the hero_render metric should appear with a p75 that is plausibly at or before your LCP p75. A hero render that is consistently later than LCP means you annotated the wrong node — the browser found a larger contentful element that painted earlier.
  • Network tab. For a cross-origin hero, inspect the image response and confirm Timing-Allow-Origin is present; without it, renderTime in your logs reads 0 and your fallback to loadTime is silently engaging.

Edge cases & gotchas

  • Cross-origin renderTime is 0 without opt-in. If the hero is served from a CDN on a different origin, that origin must return Timing-Allow-Origin: https://www.example.com (or *). Until it does, treat any renderTime === 0 from a cross-origin url as missing and fall back to loadTime, which is always exposed. Setting crossorigin on the <img> does not substitute for the header.
  • Element Timing is Chromium-only. Safari and Firefox emit no element entries. Always feature-detect; never assume a missing metric means a fast hero. Segment your dashboard by browser so a Chromium-only metric is not misread as a global p75.
  • Background-image and text heroes. A CSS background-image element reports url for the image but the intersectionRect reflects the container, not the painted image — a large container with a small visible image can overstate visibleArea. Text heroes report loadTime as 0 by design; use renderTime exclusively for them.
  • The hero can re-render. A placeholder-to-full-image swap fires one entry for the placeholder paint if that node carries the attribute. Annotate the final image, or key on the entry whose url matches the high-resolution source, to avoid recording a perceptually incomplete paint.
  • SPA route changes do not re-fire it. Element Timing keys off the initial document load. A client-side navigation that injects a new hero will not emit a fresh element entry; for that, mark the moment yourself with the User Timing API, as the sibling guide below describes.
  • Don’t let it replace LCP. Hero render time is a complementary, design-anchored signal. LCP remains the ranking metric (Good ≤ 2.5 s, Needs Improvement ≤ 4.0 s, Poor > 4.0 s). Report both; investigate when they disagree.

FAQ

How is hero render time different from LCP?

LCP is chosen automatically by the browser as the largest contentful element painted, and it can change candidates during load. Hero render time is the paint time of a specific element you annotated with elementtiming. They often coincide, but on pages with banners, posters, or late swaps they diverge — which is exactly the signal you want.

Why is my renderTime zero?

The element is almost certainly a cross-origin image whose origin does not return Timing-Allow-Origin. The browser coarsens cross-origin render times to 0 for privacy. Add the header on the image origin, or fall back to loadTime in your normalization code.

Do I need the web-vitals library to use Element Timing?

No. Element Timing is observed directly through PerformanceObserver with type: 'element', independent of the web-vitals library. You can run both side by side — the library for the standard vitals, your own observer for the hero.