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.
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-Originand unlock a realrenderTimeinstead 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
PerformanceObserverand 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
renderTimeand youridentifierproves 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
renderTimereflects an actual paint and not a layout event. - RUM signal. In your store, the
hero_rendermetric 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-Originis present; without it,renderTimein your logs reads0and your fallback toloadTimeis silently engaging.
Edge cases & gotchas
- Cross-origin
renderTimeis0without opt-in. If the hero is served from a CDN on a different origin, that origin must returnTiming-Allow-Origin: https://www.example.com(or*). Until it does, treat anyrenderTime === 0from a cross-originurlas missing and fall back toloadTime, which is always exposed. Settingcrossoriginon the<img>does not substitute for the header. - Element Timing is Chromium-only. Safari and Firefox emit no
elemententries. 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-imageelement reportsurlfor the image but theintersectionRectreflects the container, not the painted image — a large container with a small visible image can overstatevisibleArea. Text heroes reportloadTimeas0by design; userenderTimeexclusively 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
urlmatches 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
elemententry; 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.
Related
- Element Timing API — the parent reference covering the full Element Timing surface and when to reach for it.
- Instrumenting User Timing Marks in an SPA — measure render moments after client-side route changes, where Element Timing does not re-fire.
- LCP Measurement & Optimization — the ranking metric to reconcile your hero render time against.