MSEdgeExplainers

Paint Timing for performance.mark() Explainer

Author: Wangsong Jin, Andy Luhrs

Status of this Document

This document is a starting point for engaging the community and standards bodies in developing collaborative solutions fit for standardization. As the solutions to problems described in this document progress along the standards-track, we will retain this document as an archive and use this section to keep the community up-to-date with the most current standards venue and content location of future work and discussions.

Table of Contents

Introduction

Proper measurement and understanding of end-to-end user experience is key to optimizing web performance. Today, the web platform provides several paint timing APIs, each measuring paint timing in different contexts. Some are fully automatic: FP/FCP, LCP and LoAF report milestones the browser selects. Others, like Element Timing, let developers annotate specific elements for paint observation. However, the ability for developers to measure their own arbitrary visual updates remains limited.

This proposal extends performance.mark() with the paintTiming option, closing that gap by letting developers capture the actual paint time and presentation time following any JS execution, adding more complete measurement of real end-to-end user experience.

Goals

Non-goals

The Problem

Existing web performance APIs leave a gap between two kinds of measurement. On one side, User Timing (performance.mark() / performance.measure()) lets developers timestamp arbitrary points in their own JavaScript, but those marks are recorded synchronously in script and say nothing about when (or whether) the resulting visual update reached the screen. On the other side, paint-related APIs like FP/FCP, LCP, Element Timing, Event Timing, LoAF, and Container Timing do report paint-related timing information, but only for specific scenarios — platform-selected milestones, annotated elements, interaction-driven updates, or progressive area growth. Today, developers have no general-purpose way to ask, for any arbitrary visual change: "when did the update I just made actually paint?"

Common workarounds like double-rAF or rAF+setTimeout approximate when the rendering update completes, but are unreliable (see Nolan Lawson's analysis), and none provides presentationTime — an implementation-defined presentation timestamp for the frame.

Consider a developer measuring when a chat response finishes rendering. The framework updates existing DOM nodes in place — changing text content and styles — without adding new elements. Existing declarative APIs like Container Timing stay silent because the painted area hasn't grown. The developer resorts to requestAnimationFrame to approximate the paint time:

Single requestAnimationFrame

<!DOCTYPE html>
<html>
<body>
  <div id="chat">
    <div class="message">Hello, how can I help?</div>
  </div>
  <script>
    // Server responds, framework updates the message in place
    onChatResponse((text) => {
      document.querySelector('.message').textContent = text;

      requestAnimationFrame(() => {
        performance.mark('chat-response-rendered');
      });
    });
  </script>
</body>
</html>

Since requestAnimationFrame callbacks run before the style and layout, the recorded timestamp is earlier than when the content is actually rendered. It is better than logging at the moment of the DOM update, but still only an approximation.

Double requestAnimationFrame

onChatResponse((text) => {
  document.querySelector('.message').textContent = text;

  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      performance.mark('chat-response-rendered');
    });
  });
});

The second rAF fires after the first frame's paint, getting closer to the actual paint time. However, there is no guarantee that this captures the frame that corresponds to the change. This gets worse when observers (e.g., ResizeObserver, IntersectionObserver) are present — their callbacks add work between frames, making the second rAF even less likely to land on the expected frame.

requestAnimationFrame + setTimeout

onChatResponse((text) => {
  document.querySelector('.message').textContent = text;

  requestAnimationFrame(() => {
    setTimeout(() => {
      performance.mark('chat-response-rendered');
    }, 0);
  });
});

This defers the mark to the next task after the rAF callback, which is more likely to land after the paint. However, the overshoot is non-deterministic due to other queued tasks — the timestamp ends up well past the actual frame, making the measurement less precise.

With performance.mark(name, { paintTiming: true }) option

The following end-to-end example shows a page that updates chat content in place and measures how long it takes for the response to be painted and presented to the user:

<!DOCTYPE html>
<html>
<body>
  <div id="chat">
    <div class="message">Hello, how can I help?</div>
  </div>
  <script>
    // 1. Set up observer to collect marks with paint timing
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.paintTime) {
          console.log(`${entry.name}:`);
          console.log(`  Time to paint:   ${entry.paintTime - entry.startTime}ms`);
          if (entry.presentationTime) {
            console.log(`  Time to present: ${entry.presentationTime - entry.startTime}ms`);
          }
        }
      }
    }).observe({ type: 'mark' });

    // 2. Server responds, framework updates the message in place
    onChatResponse((text) => {
      document.querySelector('.message').textContent = text;

      // 3. Mark the next paint after this DOM update
      performance.mark('chat-response-rendered', { paintTiming: true });
    });
  </script>
</body>
</html>

Unlike the workarounds above, paintTime is captured directly at the rendering update — not approximated by rAF. When available, presentationTime provides an implementation-defined presentation timestamp for the frame.

Proposed API

This extends the existing performance.mark() with an opt-in paintTiming option:

performance.mark(markName, { paintTiming: true });

When paintTiming is set, the resulting PerformanceMark entry gains two additional attributes from PaintTimingMixin. The entry is delivered to PerformanceObserver after the next rendering update, with both values populated.

Attribute Description
paintTime When the rendering update completed. 0 when paintTiming: true is not set or no paint occurred.
presentationTime When the frame was presented to the screen. null when paintTiming: true is not set, no paint occurred, or the UA does not support presentation timestamps.

What developers can measure

Behavior

Key Design Decisions

Entry Delivery and Mutability

performance.mark() synchronously returns a PerformanceMark with paintTime and presentationTime unpopulated (0 / null). The same object is accessible via PerformanceObserver and getEntriesByName() — all three return the identical (===) object. After the rendering update completes, the browser fills in the internal slots and notifies the PerformanceObserver. Reading these values immediately after performance.mark() returns will yield unpopulated values.

Fallback when no paint occurs

If the user agent believes that updating the rendering would have no visible effect — for example, the DOM was not modified, the modified content is outside the viewport, or no repaint is needed — the entry is still delivered to the PerformanceObserver with paintTime set to 0 and presentationTime set to null. This ensures developers always receive a response to their performance.mark() call.

// Developer changes a hidden element, then marks:
hiddenDiv.style.color = 'red';  // not visible
performance.mark('hidden-update', { paintTiming: true });

// Observer still fires:
// entry.paintTime         → 0    (no paint occurred)
// entry.presentationTime  → null (no frame presented)

In the observer callback, paintTime === 0 unambiguously means "no paint occurred" — there is no confusion with "not yet populated", since entries are only delivered after resolution.

This design ensures developers always receive a callback, avoiding "dangling marks" that never resolve. If PaintTimingMixin later adds a unified fallback getter such as renderTime (Issue #121), marks would inherit it automatically through the mixin.

Entry delivery alternatives considered and rejected

Relationship to Other APIs

Several existing APIs provide paint-related timing. This proposal is complementary — it fills a gap none of them cover.

Element Timing

Element Timing reports the first rendering time of individual elements annotated with the elementtiming attribute. It is declarative (HTML-driven), fires once per element (on first paint), and only tracks timing-eligible content (images and text). It does not detect subsequent updates to already-painted content, nor does it detect non-text/image changes such as background colors or borders.

performance.mark() with paintTiming: true is imperative (JS-driven), captures any rendering frame regardless of content type, and works for both initial paints and subsequent updates. It is especially useful in complex applications using frameworks like React, where the actual DOM elements are abstracted away by middleware libraries, making it impractical to add elementtiming attributes to the right elements.

Container Timing

Container Timing tracks progressive paint coverage within a DOM subtree annotated with the containertiming attribute. It emits entries each time the painted area grows — useful for measuring component visual completeness during page load (e.g., "when is this widget fully rendered?").

However, Container Timing only fires when new, previously unpainted area is covered. Repainting the same area (e.g., updating text in place, changing a color) does not trigger a new entry. Like Element Timing, it only detects image and text paints — elements without text or image content (such as input fields, canvas, or SVG) do not trigger entries even when they expand the painted area.

performance.mark() with paintTiming: true captures any visual change — including repaints of existing content — making it suitable for interaction-driven updates where the DOM region doesn't change but the content does.

Interaction Contentful Paint (ICP)

Interaction Contentful Paint reports contentful paint updates within the same document that are initiated by user interactions. It uses AsyncContext to automatically track causality from an interaction through asynchronous operations to the eventual paint. ICP covers interaction-triggered updates comprehensively, but does not cover updates triggered by non-interaction sources (e.g., fetch() completions, WebSocket messages, timers, server-sent events).

performance.mark() with paintTiming: true covers updates triggered by anything — whether interaction-driven or not. The two are complementary: ICP provides rich, automatic attribution for interaction-driven paints; paint-timed marks provide a lightweight, imperative mechanism for any scenario.

Alternatives Considered

Dedicated markPaintTime() API

An earlier version of this proposal introduced a new performance.markPaintTime(label) method that would create a new PerformancePaintTimeMark entry type:

performance.markPaintTime('chat-input-rendered');

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.paintTime - entry.startTime);
  }
}).observe({ type: 'mark-paint-time' });

This approach has the advantage of clear intent — a dedicated API for a dedicated purpose — but adds a new entry type and observer type to an already fragmented paint timing landscape. After feedback from the Web Performance Working Group, we moved to extending performance.mark() to reuse the existing PerformanceMark interface, reducing API surface and avoiding further fragmentation.

All marks automatically include paintTime

Instead of an opt-in paintTiming option, every performance.mark() call could automatically include paintTime and presentationTime via PaintTimingMixin, with a fallback value when no paint occurs:

performance.mark('chat-input-rendered');
// paintTime automatically populated (real value or fallback)

This approach aligns most closely with the vision of paint timing as a first-class performance timeline primitive, allowing every mark to be grouped by frame in per-paint reporting. However:

We consider opt-in (paintTiming: true) the more practical starting point. This is forward-compatible — the opt-in can be removed in the future once fallback behavior and per-paint reporting infrastructure are in place.

requestPostAnimationFrame (rPAF)

requestPostAnimationFrame fires immediately after the rendering update completes. Using it for the same chat-input example:

onChatResponse((text) => {
  document.querySelector('.message').textContent = text;

  requestPostAnimationFrame(() => {
    performance.mark('chat-response-rendered');
  });
});

This would approximate paintTime more accurately than double-rAF, since the callback fires right after paint rather than at the start of the next frame. However:

Open Questions

paintTime vs. a new "post-paint" timestamp

The current design reuses paintTime from PaintTimingMixin, which is captured at step 11.14.21 of the rendering update — right before the browser performs the actual paint. This means it does not include the cost of paint itself, so it is not truly the last piece of main-thread work for the frame.

A "post-paint" timestamp — captured after paint completes — would more accurately reflect the total main-thread rendering cost. However:

We welcome feedback on whether paintTime is sufficient for developer needs or whether a post-paint timestamp is worth pursuing despite these tradeoffs.

Automatic paint timing for all marks

As discussed in Alternatives Considered, a future direction could make paintTime available on all performance.mark() entries by default (without paintTiming: true), once the PaintTimingMixin fallback behavior and per-paint reporting infrastructure are defined. We welcome feedback on whether opt-in or automatic is the right default.

Initial value of paintTime: non-nullable vs. nullable

performance.mark() synchronously returns a PerformanceMark with paintTime unpopulated. Two sub-options exist for this initial value. We lean toward Sub-option A for PaintTimingMixin reuse, but welcome feedback.

Sub-option A — Non-nullable (reuse PaintTimingMixin)

const mark = performance.mark('my-mark', { paintTiming: true });
mark.paintTime         // 0 (initial, before paint)
mark.presentationTime  // null (initial, before paint)

// After paint, browser fills internal slots:
mark.paintTime         // 165.00
mark.presentationTime  // 172.00 (or null if UA does not support)

Sub-option B — Nullable (custom attributes)

const mark = performance.mark('my-mark', { paintTiming: true });
mark.paintTime         // null (initial, before paint)
mark.presentationTime  // null (initial, before paint)

// After paint, browser fills internal slots:
mark.paintTime         // 165.00
mark.presentationTime  // 172.00 (or null if UA does not support)

Security and Privacy Considerations

Appendix: Rendering Pipeline and Timing

performance.mark() with paintTiming: true captures timestamps at specific points in the browser's rendering pipeline.

paintTime

paintTime is the rendering update end time, captured after style and layout. This is the same timestamp that FP/FCP/LCP use via PaintTimingMixin, defined at step 11.14.21 of the event loop.

Note: Below diagram illustrates the Chromium rendering architecture. Other browser engines may have a different pipeline structure, but the spec-defined timing semantics remain the same.

paintTime in the rendering pipeline

presentationTime

presentationTime is the implementation-defined time when the composited frame is presented to the display. presentationTime is not supported by all user agents — it will be null when the UA does not implement presentation timestamps. When supported, the exact meaning depends on the operating system. On some platforms, the precise time when pixels are presented to the display is not available, in which case presentationTime will report the next closest time, which is typically when the frame is sent to the GPU.

Note: Below diagram uses Chromium's architecture as an example. Other browser engines may structure this differently, but presentationTime refers to the moment the composited frame is presented to the display. presentationTime in the path from rendering to display

Appendix: WebIDL

// Extends User Timing spec — https://w3c.github.io/user-timing/
dictionary PerformanceMarkOptions {
  any detail;
  DOMHighResTimeStamp startTime;
  boolean paintTiming = false;   // NEW — opt-in to paint timing
};

// PerformanceMark gains PaintTimingMixin attributes
PerformanceMark includes PaintTimingMixin;

// PaintTimingMixin already defined in Paint Timing spec:
// interface mixin PaintTimingMixin {
//   readonly attribute DOMHighResTimeStamp paintTime;
//   readonly attribute DOMHighResTimeStamp? presentationTime;
// };
//
// For marks without paintTiming: true, paintTime is 0 and
// presentationTime is null.