Element Timing API: Measure Render Times Beyond LCP in 2026

Tag any image, price, or button with elementtiming and read its real paint timestamp from PerformanceObserver. Practical RUM setup, cross-origin gotchas, and the new Container Timing trial.

Element Timing API Guide (2026)

Updated: June 30, 2026

The Element Timing API lets you measure the exact render time of any image, text node, or background-image element by tagging it with an elementtiming attribute and observing it through a PerformanceObserver. It's the same underlying API that powers Largest Contentful Paint, but instead of letting Chrome pick the largest element, you pick the elements that actually drive your business: the hero, the price, the "Add to Cart" button, the third product card. In 2026 it remains the cleanest path to hero-render-time RUM, and the new Container Timing origin trial extends it to whole components.

  • Element Timing reports a precise renderTime for any element you annotate with the elementtiming attribute (images, video posters, background-images, and text-bearing elements).
  • LCP is built on top of Element Timing. Adding your own annotations costs almost nothing and gives you metrics LCP can't capture.
  • Cross-origin images return renderTime: 0 unless the response carries a Timing-Allow-Origin header; fall back to loadTime or fix the header.
  • Container Timing entered an origin trial in Chrome 148 (m148–m153) and lets you measure when a whole component (card, sidebar, product grid) has finished its first paint.
  • Safari and Firefox still ignore the elementtiming attribute, but Chromium share is enough for production RUM today.
  • Send entries through the same web-vitals-style RUM pipeline you already run; budget alerts work the same way as for Core Web Vitals.

What is the Element Timing API?

The Element Timing API is a low-overhead browser API that exposes a PerformanceElementTiming entry for any element you've explicitly opted in by setting an elementtiming attribute. The entry carries a renderTime (the timestamp when the browser actually drew the element to the screen), a loadTime for image-paint entries, the identifier you chose, the resolved natural and intrinsic sizes, and a reference back to the element itself. Entries are surfaced through the standard PerformanceObserver machinery with an entryType of "element".

You can think of it as the developer-controlled twin of LCP. LCP heuristically picks one element per page; Element Timing lets you instrument as many elements as you want, and the entries fire even after LCP has finalized. The PerformanceElementTiming interface on MDN is the canonical reference. On the e-commerce work I've been doing, it's become the only way to get reliable render timing for parts of the page that LCP refuses to acknowledge: a 70-pixel-wide price, an inline countdown timer, that stubborn third product tile nobody scrolls past.

How is Element Timing different from LCP?

LCP and Element Timing share the same plumbing, but they answer different questions. LCP answers "when did the most visually dominant content arrive?" using Chrome's built-in heuristic (largest paint area, modified by viewport, with text and image candidates picked automatically). Element Timing answers "when did this specific element arrive?" using an identifier you picked. The two metrics often disagree on which element matters, and that disagreement is the entire point.

On a product detail page I instrumented recently, LCP almost always fired on a large lifestyle banner near the top of the page. Conversion analytics, on the other hand, correlated with the moment the price and the "Add to Cart" button became visible, neither of which qualified as LCP candidates. Element Timing let me track both, alongside LCP, without touching the LCP entry itself. Once you have a per-element render number, you can build alerts on the specific paint that maps to revenue, rather than the one Google decided was largest. The web.dev custom metrics guide walks through the same reasoning with a publisher example, and it generalises to anything with a clear "hero moment."

If you want to optimize what LCP does measure, our deep dive on Largest Contentful Paint sub-parts covers the TTFB, resource-load and render-delay slices that make up the LCP number. Element Timing is the next step once that number is good and you still want more granular visibility.

Which elements can have an elementtiming attribute?

The elementtiming attribute is honoured on a specific list of DOM nodes: <img>, <image> elements inside an <svg>, the poster image of a <video>, any element with a CSS background-image that resolves to a fetched resource, and any element that contains text nodes (most commonly <p>, <h1><h6>, <div> with direct text content, and <span>). The browser will silently ignore the attribute on other elements. A <div> that only contains other elements isn't an observed candidate, which catches a lot of teams off guard the first time.

The attribute value is a free-form identifier string. I prefix mine by page template so I can group entries downstream (pdp.hero, pdp.price, plp.tile-1, plp.tile-3). The identifier becomes the identifier property on the entry, separate from name (which is always "image-paint" or "text-paint").

<!-- Hero product image -->
<img src="/i/sku-9921/hero.avif"
     elementtiming="pdp.hero"
     width="720" height="720" fetchpriority="high">

<!-- Price block -->
<p class="price" elementtiming="pdp.price">€249.00</p>

<!-- Background-image hero (banner) -->
<div class="hero-banner" elementtiming="home.hero-banner">
  Summer Sale
</div>

How to observe element entries with PerformanceObserver

You read entries by registering a PerformanceObserver for the element entry type. Two flags matter: buffered: true so you catch entries that fired before your observer was constructed (critical if you lazy-load your analytics bundle), and a defensive check on PerformanceObserver.supportedEntryTypes so you don't throw on Safari.

// rum/element-timing.js
const SUPPORTED = PerformanceObserver
  .supportedEntryTypes
  .includes("element");

if (SUPPORTED) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry is a PerformanceElementTiming
      reportElementPaint({
        id:        entry.identifier,
        name:      entry.name,         // "image-paint" | "text-paint"
        loadTime:  entry.loadTime,     // 0 for text
        renderTime: entry.renderTime,  // 0 for cross-origin without TAO
        url:       entry.url,          // resource URL for images
        intrinsic: { w: entry.intrinsicSize?.width,
                     h: entry.intrinsicSize?.height },
        rect:      entry.intersectionRect,
        navStart:  performance.timeOrigin,
      });
    }
  });

  observer.observe({ type: "element", buffered: true });
}

The callback runs on an idle task, so it doesn't contend with the main thread the way a requestAnimationFrame-based hand-rolled timer would. The buffered: true flag is what makes this safe to lazy-load: the browser holds element entries in a small ring buffer and flushes them when your observer subscribes. I hit this exact bug on a RUM rollout last year, where the hero paint was always missing because the analytics bundle loaded a few hundred milliseconds too late. Flip the buffered flag on, and the entry shows up.

renderTime vs loadTime: which one do you report?

For image-paint entries the entry carries two timestamps. loadTime is the moment the underlying resource finished fetching and was attached to the element, whichever happened later. renderTime is the moment the browser actually painted that image to the screen. For text-paint entries, loadTime is always 0 and only renderTime is meaningful.

You almost always want renderTime. The render timestamp is what users actually experience, and what LCP itself reports. loadTime is useful as a fallback when renderTime is zero (a cross-origin image without Timing-Allow-Origin) or as a diagnostic to separate "network slow" from "main-thread blocked rendering it." A common reporting pattern:

const paintTime = entry.renderTime || entry.loadTime;
const networkPart = entry.loadTime;
const renderDelay = entry.renderTime
  ? Math.max(0, entry.renderTime - entry.loadTime)
  : null;

That renderDelay calculation is the closest thing Element Timing has to a built-in attribution signal. If renderDelay is high while loadTime is low, your image arrived early but the main thread was busy when it was time to commit the frame. That's a textbook Long Animation Frames problem waiting for a fix.

Why is renderTime 0 for cross-origin images?

Browsers expose renderTime as a precise screen-paint timestamp, and a precise paint timestamp can be used as a covert timing channel for cross-origin content. To prevent that, image-paint entries for cross-origin resources have their renderTime coerced to 0 unless the response carries a Timing-Allow-Origin header that lets your origin read it. Some browsers now expose a slightly coarsened render time in this situation, but you can't rely on that for production telemetry.

The same header unblocks Resource Timing detail (DNS, TCP, request, response) for those resources, so it's usually worth setting it once at the CDN edge for every static asset host. After that, your renderTime values match what LCP reports for the same image. They should, since both metrics are reading the same paint timestamp.

Shipping Element Timing entries to RUM

The wiring is identical to the way you ship Core Web Vitals. Buffer entries, batch on visibilitychange === "hidden", send with navigator.sendBeacon. If you've already got the web-vitals library wired into your RUM, drop Element Timing into the same beacon payload. They share a transport, an aggregation strategy, and a dashboarding pattern.

const queue = [];

function reportElementPaint(rec) {
  queue.push(rec);
}

function flush() {
  if (!queue.length) return;
  const body = JSON.stringify({
    url: location.pathname,
    nav: performance.getEntriesByType("navigation")[0]?.type,
    entries: queue.splice(0),
  });
  navigator.sendBeacon("/rum/element-timing", body);
}

addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") flush();
});
addEventListener("pagehide", flush);

On the server side, aggregate by identifier and bucket by template (PDP, PLP, home). The p75 and p95 of renderTime per identifier is the metric you actually want on a dashboard. Single-page numbers are noise. Set budgets the same way you would for LCP: a target p75 (mine sits at 1.8 s for the hero product image, 2.1 s for the price block), and alert when a release pushes p75 above the budget. SpeedCurve and a handful of other RUM vendors collect elementtiming entries natively; if you self-host RUM, the snippet above is the whole integration.

Container Timing: the 2026 evolution

Element Timing only measures individual elements. A product card on a PLP has an image, a title, a price, and a rating, and what a shopper actually waits for is the whole tile, not any one of those pieces. The Container Timing origin trial, developed by Bloomberg and shipped in Chromium by Igalia, fills that gap. It's available behind chrome://flags/#enable-experimental-web-platform-features in Chrome 145+ and runs as an origin trial from Chrome 148 through Chrome 153.

The API mirrors Element Timing. You annotate a container with a containertiming attribute, and a PerformanceObserver watching the container entry type fires when every contentful descendant inside that container has finished its first paint. That "every descendant" aggregation is the part you can't recreate from Element Timing alone. Without it, you'd have to record paint times for each child and reduce them client-side, hoping you covered every case.

<article class="tile" containertiming="plp.tile-3">
  <img src="/i/sku-7733/thumb.avif" width="320" height="320">
  <h3>Linen Field Shirt</h3>
  <p class="price">€89.00</p>
  <p class="rating">4.6 / 5 (212)</p>
</article>

<script>
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.firstRenderTime, entry.lastPaintedSubElement, ...
    sendBeacon("/rum/container-timing", entry.toJSON());
  }
}).observe({ type: "container", buffered: true });
</script>

Until Container Timing ships unflagged, treat it as "measure today on Canary, ship the snippet behind a feature flag, swap on when it becomes default." The API surface has been stable across the trial, and the entry shape is close enough to Element Timing that the same RUM beacon can carry both.

E-commerce patterns I actually ship

Honestly, a handful of identifiers cover most of what an e-commerce team cares about. I deploy the same set across PDP, PLP, and homepage, and let the identifier prefix tell the rest of the pipeline which template it is. On a recent rollout, these were the entries I kept on the dashboard once the noise was filtered out:

  • pdp.hero: the primary product image. The number that maps closest to "the page feels ready."
  • pdp.price: text-paint timing for the price block. If this lags the hero by more than 200 ms, the price is being injected client-side and we have a hydration regression.
  • pdp.add-to-cart: text-paint on the button label. If it's missing, the button is rendering before its localized label resolves.
  • plp.tile-1 ... plp.tile-6: explicit tile annotations so we can see whether tile 3 lags tile 1 (a common image-priority bug fixed with fetchpriority on the right tiles).
  • home.banner: the rotating hero. We watch its render time independently of LCP, because LCP often switches between banner variants per page load.

Six to ten identifiers per page is the sweet spot. Past that, dashboards turn into wallpaper and nobody looks at them. Element Timing is cheap, but discipline about which elements you measure is what makes it useful.

Browser support and graceful degradation

Chromium browsers (Chrome, Edge, Opera, Brave, Samsung Internet) support elementtiming in stable. Firefox doesn't implement the attribute, and Safari doesn't either; both will ignore it silently, so you can ship the attribute in HTML without breaking anything. The observer-side feature-detect (PerformanceObserver.supportedEntryTypes.includes("element")) handles the JavaScript half. For the Container Timing origin trial, gate observer registration on supportedEntryTypes.includes("container") for the same reason.

Chromium share is enough in practice. Element Timing data from Chrome and Edge generalises to Safari and Firefox well enough for SLOs because the underlying rendering pipeline behaves similarly. Slow images are slow everywhere. If you need parity across engines for the same metric, fall back to a hand-rolled IntersectionObserver + requestAnimationFrame probe; it's less precise (you measure visibility rather than paint commit) but works in every browser shipped this decade.

Frequently Asked Questions

Does Element Timing slow down the page?

No. The elementtiming attribute is parsed once, and the entries are produced on the browser's normal paint pipeline. The cost is a few bytes of HTML per annotation and a small ring buffer the browser already maintains for LCP candidates. The observer callback runs on an idle task, so it doesn't block the main thread.

Will adding elementtiming to a banner make it the LCP element?

No. LCP is selected by Chrome's heuristic based on visible size and viewport position, independent of which elements you've annotated. The annotation only opts the element into a separate PerformanceElementTiming entry that LCP doesn't read.

Why is renderTime sometimes earlier than loadTime?

For text-paint entries loadTime is always 0, so any non-zero renderTime will appear "earlier." For image-paint entries the two are usually renderTime >= loadTime, but browsers may coarsen renderTime for cross-origin images, which can produce a slightly earlier-looking value. Use Math.max(loadTime, renderTime) when you need a single conservative number.

Can I use Element Timing inside a Shadow DOM?

Yes. The elementtiming attribute is honoured on elements inside shadow trees, and the entry's element property returns the inner shadow-DOM node (subject to the usual closed-shadow-root access rules). Web components that need component-level visibility should consider Container Timing once it ships unflagged.

Does Safari support the elementtiming attribute?

Not as of mid-2026. WebKit hasn't implemented Element Timing, so Safari ignores the attribute and produces no entries. Feature-detect with PerformanceObserver.supportedEntryTypes.includes("element") and skip observer registration on unsupported engines.

Robin Chowdhury
About the Author Robin Chowdhury

Frontend performance architect at a large e-commerce site. Spends his days fighting third-party scripts.