Element Timing API

Largest Contentful Paint tells you when the single biggest element painted, but it cannot tell you when your hero image, primary call-to-action, or main copy block reached the screen — and on most pages those are not the same element. The Element Timing API closes that gap: you annotate specific elements with an elementtiming attribute, the browser emits a PerformanceElementTiming entry the moment each one renders, and you read a per-element renderTime instead of a single document-wide number. This is the precision instrument for measuring the render of the content that actually drives your funnel.

This guide sits within Custom Metrics & Business Impact Tracking, and it is the natural counterpart to the Largest Contentful Paint work in the standard-vitals domain. Where LCP gives you one comparable, browser-chosen score, Element Timing lets you name the elements you care about and track each independently. The capture mechanics build on the buffered PerformanceObserver setup used for every field metric on this site, the aggregation follows the same p75 sampling discipline, and the script-instrumented intervals it complements come from the User Timing API: Marks & Measures cluster. Once you have per-element render times, you join them to behavior through Conversion Funnel Correlation. The most common application — timing a marketing hero independently of LCP — is detailed in Tracking Hero Render Time with Element Timing.

Element Timing vs LCP on a page timeline A horizontal timeline shows navigation start, then loadTime and renderTime markers for a hero image, primary CTA, and main copy. LCP scores only the largest element, while Element Timing reports each annotated element separately. nav start time hero-image loadTime then renderTime primary-cta text node renderTime main-copy first paint of block LCP scores ONE element the largest candidate only Element Timing scores each annotated element by name

renderTime is null cross-origin without Timing-Allow-Origin only images and text nodes are eligible

Each annotated element emits its own entry with a renderTime, while LCP collapses the page to one score. See LCP measurement & optimization for the standard-vitals counterpart.

What the Element Timing API Measures

The Element Timing API is intentionally narrow. Two kinds of element are eligible: image content (<img>, <image> inside <svg>, <video> poster frames, and CSS background-image) and text-containing block-level elements. You opt an element in by adding the elementtiming attribute with a string identifier; nothing is observed until you annotate it. The browser then records the element’s geometry, its resource load time where applicable, and the paint frame in which it first became visible, and surfaces all of that as a PerformanceElementTiming entry through a PerformanceObserver subscribed to type: 'element'.

The single most important field is renderTime: a DOMHighResTimeStamp, relative to navigation start, of the frame in which the element painted. For images you also get loadTime, the moment the underlying resource finished downloading. The interval between loadTime and renderTime is the browser’s decode-and-paint cost — a resource can be fully downloaded yet still wait on main-thread contention or a large decode before it reaches the screen, and only Element Timing exposes that gap per element.

Because the API is opt-in per element, it is cheap: you are not measuring every paint, only the handful of elements you have decided matter to the funnel. That is what makes it complement rather than duplicate LCP. LCP answers “how fast does this page feel by its largest paint?”; Element Timing answers “when did this specific content the user came for actually arrive?”

Field Reference

A PerformanceElementTiming entry carries the following fields. Treat this table as the contract your collector reads against.

Field Type Meaning Notes / failure mode
name string "image-paint" or "text-paint" Identifies the paint kind, not your label
identifier string The value of the elementtiming attribute Your join key; pick stable, namespaced names
renderTime number (ms) Paint frame timestamp, relative to nav start 0 for cross-origin images without Timing-Allow-Origin
loadTime number (ms) Resource load finish, relative to nav start 0 for text nodes (no resource)
intersectionRect DOMRectReadOnly Visible rectangle at first paint Empty if the element painted off-screen
naturalWidth / naturalHeight number Intrinsic image dimensions 0 for text nodes
id string The element’s DOM id attribute Empty string if the element has no id
element Element | null Live reference to the node null if the node was removed before the callback ran
url string Image resource URL Empty for text-paint entries
entryType string Always "element" The observer subscription type
startTime number (ms) Equals renderTime Provided for PerformanceEntry uniformity

The two fields that bite people are renderTime and identifier. renderTime reads back as 0 (not null in the entry, though it is conceptually “unavailable”) whenever the image is served cross-origin and the response lacks a Timing-Allow-Origin header that permits your origin — the browser withholds the precise render timestamp as a cross-origin information leak. And identifier is whatever you put in the attribute, so an un-namespaced label like hero risks colliding with a third-party widget that annotates its own element; prefix yours, for example app-hero-image.

Measurement Implementation

The production pattern is an observer subscribed with buffered: true so element entries that fired before your script booted are replayed, normalized into a custom_-prefixed measure, and handed to the same beacon path as every other field metric. The snippet below also derives the decode-and-paint gap and guards the cross-origin 0 case so a withheld timestamp never poisons your aggregate.

<img src="/hero.avif"
     elementtiming="app-hero-image"
     fetchpriority="high"
     width="1200" height="630"
     alt="Product hero">
<button elementtiming="app-primary-cta" class="cta">Start free trial</button>
<p elementtiming="app-main-copy">The one-line value proposition the user came to read.</p>
// Capture Element Timing entries and forward them as custom measures.
const WATCHED = new Set(['app-hero-image', 'app-primary-cta', 'app-main-copy']);

function recordElementTiming(entry) {
  // Cross-origin images without Timing-Allow-Origin report renderTime 0.
  const crossOriginBlocked = entry.name === 'image-paint' && entry.renderTime === 0;
  if (crossOriginBlocked) {
    reportBeacon({
      metric: `custom_render_${entry.identifier}`,
      value: null,
      reason: 'cross-origin-tao-missing',
      url: entry.url
    });
    return;
  }

  // renderTime is the headline number; loadTime exists only for images.
  const renderTime = entry.renderTime;
  const decodePaintGap = entry.loadTime > 0
    ? Math.max(0, renderTime - entry.loadTime)
    : null;

  // Mirror into the User Timing buffer so DevTools shows it on the timeline.
  performance.measure(`custom_render_${entry.identifier}`, {
    start: 0,
    end: renderTime
  });

  reportBeacon({
    metric: `custom_render_${entry.identifier}`,
    value: Math.round(renderTime),
    loadTime: entry.loadTime ? Math.round(entry.loadTime) : null,
    decodePaintGap: decodePaintGap !== null ? Math.round(decodePaintGap) : null,
    naturalWidth: entry.naturalWidth || null,
    connection: navigator.connection?.effectiveType || 'unknown',
    deviceMemory: navigator.deviceMemory ?? null
  });
}

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (WATCHED.has(entry.identifier)) recordElementTiming(entry);
  }
});
// buffered:true replays entries that painted before this code ran.
observer.observe({ type: 'element', buffered: true });

The reportBeacon function is the shared transport documented in Custom Metrics & Business Impact Tracking: it buffers events and flushes them through navigator.sendBeacon on visibilitychange/pagehide. Crucially, when an element times out cross-origin you still emit a beacon — with value: null and a reason — so the rate of blocked measurements is itself observable rather than silently shrinking your sample.

One subtlety worth internalizing: a single observer with buffered: true is enough for the whole page. You do not instantiate one observer per element. Subscribe once, filter on identifier, and let the WATCHED set drive which elements reach your beacon.

Debugging Workflow

When a watched element’s renderTime p75 regresses, work the problem in this order rather than guessing:

  1. Identify the regressed element. Query your RUM store for the custom_render_* metric whose p75 moved, segmented by release. Confirm it is one named element, not a broad page-wide slowdown that would point at LCP or TTFB instead.
  2. Trace the waterfall in the lab. Reproduce in Chrome DevTools with CPU and network throttling matched to the affected cohort. In the Performance panel, the element’s entry appears under Timings; line up its renderTime against the network request and any long task on the main thread.
  3. Split load from paint. Compare the beaconed loadTime and renderTime. If loadTime is fine but the gap is large, the resource arrived on time and the cost is decode or main-thread contention — not a network problem. If loadTime itself is late, it is a fetch-priority or origin problem.
  4. Correlate with overlapping work. Check the Long Task & Main-Thread Attribution stream for a long task whose window overlaps the render frame. A 200 ms task that lands just before renderTime is almost certainly the cause of a wide decode-and-paint gap.
  5. Validate the fix in lab. Apply the candidate fix — fetchpriority="high", a preload, an aspect-ratio reservation, or moving a script off the critical path — and confirm the lab renderTime improves before shipping.
  6. Deploy and monitor the delta. Roll out behind a flag where possible, then watch the p75 of the specific custom_render_* metric for the affected cohort. The win is the delta on that element, not on page-level LCP, which may not move at all if the element was never the LCP candidate.

Field-Data Analysis Patterns

A single global p75 hides exactly the cohorts you most need to fix. Element render times stratify sharply by the variables that govern decode cost and download time, so always segment.

Segment Why it diverges What to watch
Device class (deviceMemory) Low-RAM devices decode large images slowly Wide decode-and-paint gap on ≤ 2 GB cohort
Network type (effectiveType) slow-4g and 3g inflate loadTime High loadTime, small gap — a fetch problem
Geography Distance to the image origin / CDN edge loadTime skew by region; CDN coverage gaps
Cross-origin rate Third-party image hosts without Timing-Allow-Origin Share of value: null beacons per element

The diagnostic discipline is to read loadTime and the decode-and-paint gap separately. A regression concentrated in loadTime on a slow-4g, distant-geography cohort is a delivery problem solved at the edge cache and TTFB layer. A regression concentrated in the gap on a low-memory cohort is a decode/main-thread problem solved by shrinking the image, choosing a cheaper codec, or yielding the blocking task. Reporting only a blended p75 would average those two distinct failures into one number you cannot act on.

Optimization Strategies

Element Timing does not just diagnose — it quantifies the impact of each lever on the specific element you care about, with a clean before/after on its renderTime p75.

Technique Targets Typical effect on element renderTime
fetchpriority="high" on the hero <img> loadTime Pulls the request ahead of lower-priority assets; earlier loadTime
<link rel="preload"> for the hero resource loadTime Starts the fetch during HTML parse, before the <img> is discovered
width/height or aspect-ratio reservation render frame Avoids re-layout that delays the paint and prevents CLS
Smaller payload / modern codec (AVIF, WebP) decode gap Shorter decode shrinks the loadTime-to-renderTime gap
Break up the blocking long task decode gap Frees the main thread so the decoded image can paint sooner

The reason to pair these with Element Timing rather than LCP is attribution. If you add fetchpriority="high" to the hero and LCP does not move, that does not mean the change failed — it may mean a different element is the LCP candidate. The element’s own custom_render_app-hero-image p75 tells you whether the change you made worked on the element you changed, which is the only honest before/after.

Failure Modes & Gotchas

  • Cross-origin renderTime is withheld. An image served from a different origin reports renderTime as 0 unless the response includes a Timing-Allow-Origin header naming your origin (or *). This is the most common reason a hero served from a third-party CDN looks “instant” in your data. Fix it by adding Timing-Allow-Origin at the image host; until then, treat 0 as “unknown,” never as “fast.”
  • Only images and text nodes are eligible. You cannot annotate a <div> that contains only a background gradient, an SVG built from <path> shapes with no text, or a canvas. Background-image CSS counts; pure vector and canvas do not. If the element you care about is none of these, fall back to a User Timing mark fired from a paint callback.
  • Late annotation is not retroactive. Adding elementtiming to an element after it has already painted produces no entry — the attribute must be present when the element first renders. For server-rendered HTML this is automatic; for client-rendered nodes, set the attribute in the same render that mounts the element.
  • Removed elements yield a null element reference. If a node is detached before your observer callback runs, entry.element is null. Rely on entry.identifier (and entry.id) for your join key, never on the live node reference.
  • Browser support is uneven. Element Timing ships in Chromium-based browsers. As of this writing Safari and Firefox do not implement it, so a meaningful share of traffic emits no element entries at all. Detect support with PerformanceObserver.supportedEntryTypes?.includes('element') and fall back to LCP plus a User Timing mark on unsupported engines, and remember your element-render p75 is computed only over the supporting-browser cohort — keep that denominator explicit in dashboards.
  • First-paint only. An entry is emitted for the element’s first paint, not for subsequent re-renders. If a skeleton paints first and the real image swaps in, the entry reflects the skeleton frame; annotate the final element, not a placeholder.

CI/CD Integration

Element render times belong in the same regression gate as your standard vitals, with one caveat: lab Element Timing is only representative if the element is annotated and the lab run loads the real resource. A Lighthouse user-flow or a Puppeteer script can read the entries directly and fail the build when a watched element’s renderTime exceeds its budget.

// puppeteer-element-timing-gate.js — fail CI if a watched element paints late.
import puppeteer from 'puppeteer';

const BUDGETS_MS = {
  'app-hero-image': 2500,
  'app-primary-cta': 1800
};

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.emulateCPUThrottling(4);
await page.goto('https://staging.example.com/', { waitUntil: 'networkidle0' });

const timings = await page.evaluate(() =>
  performance.getEntriesByType('element').map((e) => ({
    identifier: e.identifier,
    renderTime: e.renderTime
  }))
);

let failed = false;
for (const [id, budget] of Object.entries(BUDGETS_MS)) {
  const entry = timings.find((t) => t.identifier === id);
  if (!entry) {
    console.error(`MISSING: no element-timing entry for "${id}"`);
    failed = true;
    continue;
  }
  if (entry.renderTime > budget) {
    console.error(`SLOW: ${id} renderTime ${Math.round(entry.renderTime)}ms > ${budget}ms`);
    failed = true;
  }
}

await browser.close();
process.exit(failed ? 1 : 0);

Set each budget slightly tighter than the element’s current field p75 so the gate catches regressions before they reach users, and treat a missing entry as a hard failure — a refactor that drops the elementtiming attribute would otherwise silently blind your field monitoring without breaking any test. This mirrors the budgeting approach used across the Web Vitals API Implementation cluster.

FAQ

How is Element Timing different from LCP?

LCP reports the render time of the single largest contentful element the browser picks, recomputed as larger elements paint. Element Timing reports the render time of each element you annotate with elementtiming, by name, independent of size. Use LCP for a standardized, comparable page score and Element Timing to measure the specific content — hero, CTA, main copy — that matters to your funnel even when it is not the LCP candidate.

Why is my element’s renderTime always 0?

The element is almost certainly a cross-origin image whose response lacks a Timing-Allow-Origin header permitting your origin. The browser withholds the precise render timestamp as a cross-origin protection and reports 0. Add Timing-Allow-Origin: * (or your specific origin) at the image host, and until then treat 0 as “unknown,” not as a fast render.

Which elements can I annotate?

Image content — <img>, <svg> <image>, <video> poster, and CSS background-image — and text-containing block-level elements. Plain <div> containers without text or image content, canvas, and pure-vector SVG are not eligible. For those, fall back to a User Timing mark fired from a paint callback.

Does Element Timing work in Safari and Firefox?

No. As of this writing only Chromium-based browsers implement it; Safari and Firefox emit no element entries. Feature-detect with PerformanceObserver.supportedEntryTypes.includes('element'), fall back to LCP plus a User Timing mark, and keep in mind your element-render p75 is computed over the supporting-browser cohort only.

What is the difference between renderTime and loadTime?

loadTime is when the image resource finished downloading; renderTime is when the element actually painted to the screen. The gap between them is the browser’s decode-and-paint cost. A small loadTime with a large gap means the network was fine but decode or main-thread contention delayed the paint — a different fix than a slow download.